Анимации во Flutter

1. Введение

Анимация — отличный способ улучшить пользовательский опыт вашего приложения, донести до пользователя важную информацию и сделать приложение более качественным и приятным в использовании.

Обзор фреймворка анимации Flutter

Flutter отображает анимационные эффекты, перестраивая часть дерева виджетов в каждом кадре. Он предоставляет готовые анимационные эффекты и другие API, упрощающие создание и компоновку анимаций.

  • Неявные анимации — это предварительно созданные эффекты анимации, которые автоматически воспроизводят всю анимацию целиком. При изменении целевого значения анимации она запускается от текущего значения до целевого значения и отображает каждое промежуточное значение, чтобы виджет плавно анимировался. Примерами неявных анимаций являются AnimatedSize , AnimatedScale и AnimatedPositioned .
  • Явные анимации также представляют собой встроенные эффекты анимации, но для их работы требуется объект Animation . Примерами таких объектов являются SizeTransition , ScaleTransition или PositionedTransition .
  • Класс Animation представляет собой класс, описывающий запущенную или остановленную анимацию, и состоит из значения , представляющего целевое значение, до которого выполняется анимация, и статуса , который представляет текущее значение, отображаемое анимацией на экране в любой момент времени. Он является подклассом класса Listenable и уведомляет своих слушателей об изменении статуса во время выполнения анимации.
  • AnimationController — это способ создания анимации и управления её состоянием. Его методы, такие как forward() , reset() , stop() и repeat() позволяют управлять анимацией без необходимости определять отображаемый эффект анимации, например, масштаб, размер или положение.
  • Анимированные окна (tweens) используются для интерполяции значений между начальным и конечным значениями и могут представлять любой тип данных, например, число с плавающей запятой (double), Offset ) или Color .
  • Кривые используются для регулирования скорости изменения параметра во времени. При воспроизведении анимации часто применяется кривая сглаживания , чтобы ускорить или замедлить скорость изменения в начале или конце анимации. Кривые принимают входное значение от 0,0 до 1,0 и возвращают выходное значение от 0,0 до 1,0.

Что вы построите

В этом практическом задании вы создадите игру-викторину с вариантами ответов, в которой будут использованы различные анимационные эффекты и приемы.

3026390ad413769c.gif

Вы увидите, как это сделать...

  • Создайте виджет, который анимирует изменение своего размера и цвета.
  • Создайте эффект переворачивания карты в 3D-формате.
  • Используйте встроенные анимационные эффекты из пакета анимаций.
  • Добавлена ​​поддержка предиктивного ввода жеста «Назад», доступная в последней версии Android.

Что вы узнаете

В этом практическом занятии вы узнаете:

  • Как использовать эффекты, задаваемые неявно, для создания великолепных анимаций без необходимости написания большого количества кода.
  • Как использовать явно анимированные эффекты для настройки собственных эффектов с помощью готовых анимированных виджетов, таких как AnimatedSwitcher или AnimationController .
  • Как использовать AnimationController для определения собственного виджета, отображающего 3D-эффект.
  • Как использовать пакет animations для отображения эффектных анимационных эффектов с минимальными затратами ресурсов.

Что вам понадобится

  • Flutter SDK
  • Среда разработки (IDE), например, VSCode или Android Studio / IntelliJ.

2. Настройте среду разработки Flutter.

Для выполнения этой лабораторной работы вам понадобятся два программных компонента: Flutter SDK и редактор .

Вы можете выполнить это практическое задание, используя любое из следующих устройств:

  • Физическое устройство Android ( рекомендуется для реализации функции предиктивного ввода на шаге 7 ) или iOS , подключенное к компьютеру и настроенное в режим разработчика.
  • Симулятор iOS (требуется установка инструментов Xcode).
  • Эмулятор Android (требуется настройка в Android Studio).
  • Для работы требуется браузер (для отладки необходим Chrome).
  • Для разработки вам потребуется настольный компьютер под управлением Windows , Linux или macOS . Разработка должна производиться на той платформе, на которой вы планируете развертывать приложение. Таким образом, если вы хотите разработать настольное приложение для Windows, вам необходимо использовать Windows для доступа к соответствующей цепочке сборки. Существуют специфические требования к операционной системе, которые подробно описаны в документации по адресу docs.flutter.dev/desktop .

Проверьте установку.

Чтобы убедиться в правильности настройки Flutter SDK и наличии хотя бы одной из указанных выше целевых платформ, воспользуйтесь инструментом Flutter Doctor:

$ flutter doctor

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.24.2, on macOS 14.6.1 23G93 darwin-arm64, locale
    en)
[✓] Android toolchain - develop for Android devices
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio
[✓] IntelliJ IDEA Ultimate Edition
[✓] VS Code
[✓] Connected device (4 available)
[✓] Network resources

• No issues found!

3. Запустите стартовое приложение.

Скачайте стартовое приложение

Используйте git для клонирования стартового приложения из репозитория flutter/samples на GitHub.

git clone https://github.com/flutter/codelabs.git
cd codelabs/animations/step_01/

В качестве альтернативы вы можете скачать исходный код в виде ZIP-файла .

Запустите приложение

Для запуска приложения используйте команду flutter run и укажите целевое устройство, например, android , ios или chrome . Полный список поддерживаемых платформ см. на странице «Поддерживаемые платформы» .

flutter run -d android

Вы также можете запускать и отлаживать приложение, используя любую IDE по вашему выбору. Дополнительную информацию см. в официальной документации Flutter.

Изучите код

Стартовое приложение представляет собой викторину с несколькими вариантами ответов, состоящую из двух экранов, построенных по принципу «модель-представление-представление-модель» (MVVM). Экран QuestionScreen (представление) использует класс QuizViewModel (представление-модель) для того, чтобы задавать пользователю вопросы с несколькими вариантами ответов из класса QuestionBank (модель).

  • home_screen.dart - Отображает экран с кнопкой " Новая игра"
  • main.dart — Настраивает MaterialApp для использования Material 3 и отображения главного экрана.
  • model.dart — определяет основные классы, используемые во всем приложении.
  • question_screen.dart — Отображает пользовательский интерфейс викторины.
  • view_model.dart — хранит состояние и логику викторины, отображаемой на экране QuestionScreen

fbb1e1f7b6c91e21.png

Приложение пока не поддерживает никаких анимированных эффектов, за исключением стандартного перехода между окнами, отображаемого классом Navigator во Flutter при нажатии пользователем кнопки «Новая игра» .

4. Используйте неявные эффекты анимации.

Неявные анимации — отличный выбор во многих ситуациях, поскольку они не требуют специальной настройки. В этом разделе вы обновите виджет StatusBar , чтобы он отображал анимированное табло. Чтобы найти распространенные эффекты неявной анимации, ознакомьтесь с документацией API ImplicitlyAnimatedWidget .

206dd8d9c1fae95.gif

Создайте неанимированный виджет табло.

Создайте новый файл lib/scoreboard.dart со следующим кодом:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            Icon(
              Icons.star,
              size: 50,
              color: score < i + 1
                  ? Colors.grey.shade400
                  : Colors.yellow.shade700,
            ),
        ],
      ),
    );
  }
}

Затем добавьте виджет Scoreboard в дочерние элементы виджета StatusBar , заменив виджеты Text , которые ранее отображали счет и общее количество вопросов. Ваш редактор должен автоматически добавить необходимый import "scoreboard.dart" в начало файла.

lib/question_screen.dart

class StatusBar extends StatelessWidget {
  final QuizViewModel viewModel;

  const StatusBar({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Card(
      elevation: 4,
      child: Padding(
        padding: EdgeInsets.all(8.0),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: [
            Scoreboard(                                        // NEW
              score: viewModel.score,                          // NEW
              totalQuestions: viewModel.totalQuestions,        // NEW
            ),
          ],
        ),
      ),
    );
  }
}

Этот виджет отображает значок звездочки для каждого вопроса. Когда на вопрос дан правильный ответ, мгновенно загорается еще одна звездочка без какой-либо анимации. На следующих шагах вы поможете пользователю узнать об изменении его результата, анимируя изменение размера и цвета звездочки.

Используйте неявный эффект анимации.

Создайте новый виджет под названием AnimatedStar , который использует виджет AnimatedScale для изменения величины scale с 0.5 до 1.0 когда звезда становится активной:

lib/scoreboard.dart

import 'package:flutter/material.dart';

class Scoreboard extends StatelessWidget {
  final int score;
  final int totalQuestions;

  const Scoreboard({
    super.key,
    required this.score,
    required this.totalQuestions,
  });

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var i = 0; i < totalQuestions; i++)
            AnimatedStar(isActive: score > i),                 // Edit this line.
        ],
      ),
    );
  }
}

class AnimatedStar extends StatelessWidget {                   // Add from here...
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: Icon(
        Icons.star,
        size: 50,
        color: isActive ? _activatedColor : _deactivatedColor,
      ),
    );
  }
}                                                              // To here.

Теперь, когда пользователь правильно отвечает на вопрос, виджет AnimatedStar обновляет свой размер с помощью неявной анимации. color Icon здесь не анимируется, а только scale , который выполняется виджетом AnimatedScale .

84aec4776e70b870.gif

Используйте анимацию (Tween) для интерполяции между двумя значениями.

Обратите внимание, что цвет виджета AnimatedStar меняется сразу после того, как значение поля isActive становится равным true.

Для создания эффекта анимации цвета можно попробовать использовать виджет AnimatedContainer (который является еще одним подклассом ImplicitlyAnimatedWidget ), поскольку он может автоматически анимировать все свои атрибуты, включая цвет. К сожалению, нашему виджету нужно отображать иконку, а не контейнер.

Вы также можете попробовать AnimatedIcon , который реализует эффекты перехода между формами значков. Но в классе AnimatedIcons нет реализации значка звезды по умолчанию.

Вместо этого мы воспользуемся другим подклассом ImplicitlyAnimatedWidget , называемым TweenAnimationBuilder , который принимает в качестве параметра объект Tween . Tween — это класс, который принимает два значения ( begin и end ) и вычисляет промежуточные значения, чтобы анимация могла их отобразить. В этом примере мы будем использовать ColorTween , который удовлетворяет условию Tween Для создания анимационного эффекта необходим соответствующий интерфейс.

Выберите виджет Icon и воспользуйтесь быстрым действием "Wrap with Builder" в вашей IDE, измените его имя на TweenAnimationBuilder . Затем укажите длительность и ColorTween .

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      duration: _duration,
      child: TweenAnimationBuilder(                            // Add from here...
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {                     // To here.
          return Icon(Icons.star, size: 50, color: value);     // And modify this line.
        },
      ),
    );
  }
}

Теперь перезагрузите приложение, чтобы увидеть новую анимацию.

8b0911f4af299a60.gif

Обратите внимание, что end значение нашего ColorTween изменяется в зависимости от значения параметра isActive . Это происходит потому, что TweenAnimationBuilder перезапускает анимацию всякий раз, когда изменяется значение Tween.end . В этом случае новая анимация запускается от текущего значения анимации до нового конечного значения, что позволяет изменять цвет в любое время (даже во время выполнения анимации) и отображать плавный эффект анимации с правильными промежуточными значениями.

Примените кривую

Оба этих анимационных эффекта воспроизводятся с постоянной скоростью, но анимация часто выглядит более визуально интересной и информативной, когда она ускоряется или замедляется.

Curve применяет функцию сглаживания , которая определяет скорость изменения параметра во времени. Flutter поставляется с набором предварительно созданных кривых сглаживания в классе Curves , таких как easeIn или easeOut .

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Эти диаграммы (доступные на странице документации API Curves ) дают представление о том, как работают кривые. Кривые преобразуют входное значение от 0,0 до 1,0 (отображается по оси x) в выходное значение от 0,0 до 1,0 (отображается по оси y). На этих диаграммах также показан предварительный просмотр того, как выглядят различные эффекты анимации при использовании кривой сглаживания.

Создайте в AnimatedStar новое поле с именем _curve и передайте его в качестве параметра виджетам AnimatedScale и TweenAnimationBuilder .

lib/scoreboard.dart

class AnimatedStar extends StatelessWidget {
  final bool isActive;
  final Duration _duration = const Duration(milliseconds: 1000);
  final Color _deactivatedColor = Colors.grey.shade400;
  final Color _activatedColor = Colors.yellow.shade700;
  final Curve _curve = Curves.elasticOut;                       // NEW

  AnimatedStar({super.key, required this.isActive});

  @override
  Widget build(BuildContext context) {
    return AnimatedScale(
      scale: isActive ? 1.0 : 0.5,
      curve: _curve,                                           // NEW
      duration: _duration,
      child: TweenAnimationBuilder(
        curve: _curve,                                         // NEW
        duration: _duration,
        tween: ColorTween(
          begin: _deactivatedColor,
          end: isActive ? _activatedColor : _deactivatedColor,
        ),
        builder: (context, value, child) {
          return Icon(Icons.star, size: 50, color: value);
        },
      ),
    );
  }
}

В этом примере кривая elasticOut создает преувеличенный пружинный эффект, который начинается с пружинного движения и уравновешивается к концу.

8f84142bff312373.gif

Чтобы увидеть применение этой кривой к AnimatedSize и TweenAnimationBuilder , перезагрузите приложение.

206dd8d9c1fae95.gif

Используйте инструменты разработчика, чтобы включить замедленную анимацию.

Для отладки любых анимационных эффектов Flutter DevTools предоставляет возможность замедлить все анимации в вашем приложении, чтобы вы могли более четко их увидеть.

Чтобы открыть инструменты разработчика, убедитесь, что приложение запущено в режиме отладки, и откройте инспектор виджетов , выбрав его на панели инструментов отладки в VSCode или выбрав кнопку « Открыть инструменты разработчика Flutter» в окне инструментов отладки в IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

После открытия инспектора виджетов нажмите кнопку «Замедлить анимацию» на панели инструментов.

adea0a16d01127ad.png

5. Используйте явные анимационные эффекты.

Подобно неявным анимациям, явные анимации представляют собой предварительно созданные эффекты анимации, но вместо целевого значения они принимают в качестве параметра объект Animation . Это делает их полезными в ситуациях, когда анимация уже определена, например, переходом навигации, AnimatedSwitcher или AnimationController .

Используйте явный эффект анимации.

Чтобы начать использовать явный анимационный эффект, оберните виджет Card в AnimatedSwitcher .

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(                                 // NEW
      duration: const Duration(milliseconds: 300),           // NEW
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),                                                     // NEW
    );
  }
}

AnimatedSwitcher по умолчанию использует эффект плавного перехода, но вы можете переопределить его с помощью параметра transitionBuilder . Конструктор переходов предоставляет дочерний виджет, переданный в AnimatedSwitcher , и объект Animation . Это отличная возможность использовать явную анимацию.

В этом практическом занятии первой явной анимацией, которую мы будем использовать, является SlideTransition , которая принимает Animation<Offset> , определяющее начальное и конечное смещение, между которыми будут перемещаться входящие и исходящие виджеты.

Функция animate() имеет вспомогательную функцию, которая преобразует любую Animation в другую Animation с применением этой анимации. Это означает, что Tween быть преобразована в другую анимацию. Tween может использоваться для преобразования Animation предоставлено AnimatedSwitcher в Animation Этот параметр будет предоставлен виджету SlideTransition .

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      transitionBuilder: (child, animation) {               // Add from here...
        final curveAnimation = CurveTween(
          curve: Curves.easeInCubic,
        ).animate(animation);
        final offsetAnimation = Tween<Offset>(
          begin: Offset(-0.1, 0.0),
          end: Offset.zero,
        ).animate(curveAnimation);
        return SlideTransition(position: offsetAnimation, child: child);
      },                                                    // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Обратите внимание, что здесь используется Tween.animate для применения Curve к Animation , а затем для преобразования её из Tween в диапазоне от 0,0 до 1,0, до Tween Этот показатель изменяется от -0,1 до 0,0 по оси X.

В качестве альтернативы, класс Animation имеет функцию drive() , которая принимает любой объект Tween (или Animatable ) и преобразует его в новый Animation . Это позволяет «связывать» объекты Tween, делая результирующий код более лаконичным:

lib/question_screen.dart

transitionBuilder: (child, animation) {
  var offsetAnimation = animation
      .drive(CurveTween(curve: Curves.easeInCubic))
      .drive(Tween<Offset>(begin: Offset(-0.1, 0.0), end: Offset.zero));
  return SlideTransition(position: offsetAnimation, child: child);
},

Еще одно преимущество использования явных анимаций заключается в том, что их можно комбинировать. Добавьте еще одну явную анимацию, FadeTransition , которая использует ту же самую анимацию с затуханием, обернув ею виджет SlideTransition .

lib/question_screen.dart

return AnimatedSwitcher(
  transitionBuilder: (child, animation) {
    final curveAnimation = CurveTween(
      curve: Curves.easeInCubic,
    ).animate(animation);
    final offsetAnimation = Tween<Offset>(
      begin: Offset(-0.1, 0.0),
      end: Offset.zero,
    ).animate(curveAnimation);
    final fadeInAnimation = curveAnimation;                            // NEW
    return FadeTransition(                                             // NEW
      opacity: fadeInAnimation,                                        // NEW
      child: SlideTransition(position: offsetAnimation, child: child), // NEW
    );                                                                 // NEW
  },

Настройте layoutBuilder

Вы можете заметить небольшую проблему с AnimationSwitcher . Когда QuestionCard переключается на новый вопрос, он размещается в центре доступного пространства во время выполнения анимации, но когда анимация останавливается, виджет резко перемещается в верхнюю часть экрана. Это приводит к некорректной анимации, поскольку конечное положение карточки с вопросом не совпадает с положением во время выполнения анимации.

d77de181bdde58f7.gif

Для решения этой проблемы AnimatedSwitcher также имеет параметр layoutBuilder , который можно использовать для определения макета. Используйте эту функцию, чтобы настроить конструктор макета таким образом, чтобы карточка была выровнена по верхнему краю экрана:

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return AnimatedSwitcher(
    layoutBuilder: (currentChild, previousChildren) {
      return Stack(
        alignment: Alignment.topCenter,
        children: <Widget>[
          ...previousChildren,
          if (currentChild != null) currentChild,
        ],
      );
    },

Этот код представляет собой модифицированную версию класса defaultLayoutBuilder из класса AnimatedSwitcher , но использует Alignment.topCenter вместо Alignment.center .

Краткое содержание

  • Явные анимации — это эффекты анимации, которые принимают объект Animation (в отличие от ImplicitlyAnimatedWidgets , которые принимают целевое value и duration ).
  • Класс Animation представляет собой анимацию движения, но не определяет конкретный эффект.
  • Используйте Tween().animate или Animation.drive() для применения Tweens и Curves (с помощью CurveTween ) к анимации.
  • Используйте параметр layoutBuilder объекта AnimatedSwitcher , чтобы настроить расположение его дочерних элементов.

6. Управление состоянием анимации

До сих пор все анимации запускались автоматически фреймворком. Неявные анимации запускаются автоматически, а для корректной работы явных анимационных эффектов требуется объект Animation . В этом разделе вы узнаете, как создавать собственные объекты Animation с помощью AnimationController и использовать TweenSequence для объединения анимаций ( Tween ).

Запустите анимацию с помощью AnimationController.

Для создания анимации с помощью AnimationController необходимо выполнить следующие шаги:

  1. Создайте StatefulWidget
  2. Используйте примесь SingleTickerProviderStateMixin в вашем классе State , чтобы передать Ticker в ваш AnimationController
  3. Инициализируйте AnimationController в методе жизненного цикла initState , передав текущий объект State в параметр vsync ( TickerProvider ).
  4. Убедитесь, что ваш виджет перестраивается всякий раз, когда AnimationController уведомляет своих слушателей, используя AnimatedBuilder или вызывая listen() и setState вручную.

Создайте новый файл flip_effect.dart и скопируйте в него следующий код:

lib/flip_effect.dart

import 'dart:math' as math;

import 'package:flutter/widgets.dart';

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });
  }

  @override
  void didUpdateWidget(covariant CardFlipEffect oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (widget.child.key != oldWidget.child.key) {
      _handleChildChanged(widget.child, oldWidget.child);
    }
  }

  void _handleChildChanged(Widget newChild, Widget previousChild) {
    _previousChild = previousChild;
    _animationController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animationController,
      builder: (context, child) {
        return Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..rotateX(_animationController.value * math.pi),
          child: _animationController.isAnimating
              ? _animationController.value < 0.5
                    ? _previousChild
                    : Transform.flip(flipY: true, child: child)
              : child,
        );
      },
      child: widget.child,
    );
  }
}

Этот класс создает AnimationController и перезапускает анимацию всякий раз, когда фреймворк вызывает didUpdateWidget , чтобы уведомить его об изменении конфигурации виджета и о возможном появлении нового дочернего виджета.

AnimatedBuilder гарантирует, что дерево виджетов будет перестроено всякий раз, когда AnimationController уведомляет своих слушателей, а виджет Transform используется для применения эффекта 3D-вращения, имитирующего переворачивание карточки.

Для использования этого виджета оберните каждую карточку с ответом в виджет CardFlipEffect . Обязательно укажите key к виджету Card :

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(                                    // NEW
        duration: const Duration(milliseconds: 300),            // NEW
        child: Card.filled(                                     // NEW
          key: ValueKey(answers[index]),                        // NEW
          color: color,
          elevation: 2,
          margin: EdgeInsets.all(8),
          clipBehavior: Clip.hardEdge,
          child: InkWell(
            onTap: () => onTapped(index),
            child: Padding(
              padding: EdgeInsets.all(16.0),
              child: Center(
                child: Text(
                  answers.length > index ? answers[index] : '',
                  style: Theme.of(context).textTheme.titleMedium,
                  overflow: TextOverflow.clip,
                ),
              ),
            ),
          ),
        ),                                                      // NEW
      );
    }),
  );
}

Теперь перезагрузите приложение, чтобы увидеть, как карточки с ответами переворачиваются с помощью виджета CardFlipEffect .

5455def725b866f6.gif

Вы можете заметить, что этот класс очень похож на явно реализованный эффект анимации. На самом деле, часто бывает хорошей идеей напрямую расширить класс AnimatedWidget , чтобы реализовать свою собственную версию. К сожалению, поскольку этому классу необходимо хранить предыдущий виджет в своем State , ему требуется использовать StatefulWidget . Чтобы узнать больше о создании собственных явных эффектов анимации, см. документацию API для AnimatedWidget .

Добавьте задержку с помощью TweenSequence.

В этом разделе вы добавите задержку к виджету CardFlipEffect , чтобы каждая карта переворачивалась по очереди. Для начала добавьте новое поле с именем delayAmount .

lib/flip_effect.dart

class CardFlipEffect extends StatefulWidget {
  final Widget child;
  final Duration duration;
  final double delayAmount;                      // NEW

  const CardFlipEffect({
    super.key,
    required this.child,
    required this.duration,
    required this.delayAmount,                   // NEW
  });

  @override
  State<CardFlipEffect> createState() => _CardFlipEffectState();
}

Затем добавьте параметр delayAmount в метод build AnswerCards .

lib/question_screen.dart

@override
Widget build(BuildContext context) {
  return GridView.count(
    shrinkWrap: true,
    crossAxisCount: 2,
    childAspectRatio: 5 / 2,
    children: List.generate(answers.length, (index) {
      var color = Theme.of(context).colorScheme.primaryContainer;
      if (correctAnswer == index) {
        color = Theme.of(context).colorScheme.tertiaryContainer;
      }
      return CardFlipEffect(
        delayAmount: index.toDouble() / 2,                     // NEW
        duration: const Duration(milliseconds: 300),
        child: Card.filled(
          key: ValueKey(answers[index]),

Затем в классе _CardFlipEffectState создайте новую Animation , которая применяет задержку с помощью TweenSequence . Обратите внимание, что здесь не используются никакие утилиты из библиотеки dart:async , такие как Future.delayed . Это связано с тем, что задержка является частью анимации , а не чем-то, что виджет явно контролирует при использовании AnimationController . Это упрощает отладку эффекта анимации при включении медленных анимаций в DevTools, поскольку используется тот же TickerProvider .

Для использования TweenSequence создайте два объекта TweenSequenceItem : один будет содержать ConstantTween , который поддерживает анимацию на уровне 0 в течение относительного времени, а другой — обычный Tween , изменяющийся от 0.0 до 1.0 .

lib/flip_effect.dart

class _CardFlipEffectState extends State<CardFlipEffect>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;
  Widget? _previousChild;
  late final Animation<double> _animationWithDelay;            // NEW

  @override
  void initState() {
    super.initState();

    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration * (widget.delayAmount + 1),
    );

    _animationController.addListener(() {
      if (_animationController.value == 1) {
        _animationController.reset();
      }
    });

    _animationWithDelay = TweenSequence<double>([              // Add from here...
      if (widget.delayAmount > 0)
        TweenSequenceItem(
          tween: ConstantTween<double>(0.0),
          weight: widget.delayAmount,
        ),
      TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 1.0),
    ]).animate(_animationController);                          // To here.
  }

Наконец, замените анимацию AnimationController на новую отложенную анимацию в методе build .

lib/flip_effect.dart

@override
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: _animationWithDelay,                            // Modify this line
    builder: (context, child) {
      return Transform(
        alignment: Alignment.center,
        transform: Matrix4.identity()
          ..rotateX(_animationWithDelay.value * math.pi),      // And this line
        child: _animationController.isAnimating
            ? _animationWithDelay.value < 0.5                  // And this one.
                  ? _previousChild
                  : Transform.flip(flipY: true, child: child)
            : child,
      );
    },
    child: widget.child,
  );
}

Теперь перезагрузите приложение и наблюдайте, как карты переворачиваются одна за другой. Для разнообразия попробуйте поэкспериментировать с изменением перспективы 3D-эффекта, создаваемого виджетом Transform .

28b5291de9b3f55f.gif

7. Используйте пользовательские переходы навигации.

До сих пор мы рассматривали, как настраивать эффекты на одном экране, но еще один способ использования анимации — это применение ее для перехода между экранами. В этом разделе вы узнаете, как применять анимационные эффекты к переходам между экранами, используя встроенные анимационные эффекты и готовые анимационные эффекты, предоставляемые официальным пакетом animations на pub.dev .

Анимируйте переход между навигационными окнами.

Класс PageRouteBuilder — это Route , позволяющий настраивать анимацию перехода. Он позволяет переопределить функцию обратного вызова transitionBuilder , которая предоставляет два объекта Animation, представляющих входящую и исходящую анимацию, выполняемую навигатором.

Чтобы настроить анимацию перехода, замените MaterialPageRoute на PageRouteBuilder , а для настройки анимации перехода при переходе пользователя с HomeScreen на QuestionScreen используйте FadeTransition (виджет с явной анимацией), чтобы новый экран плавно появлялся поверх предыдущего.

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(                                         // Add from here...
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeTransition(
                opacity: animation,
                child: child,
              );
            },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

Пакет анимаций предоставляет множество встроенных анимационных эффектов, таких как FadeThroughTransition . Импортируйте пакет анимаций и замените виджет FadeTransition виджетом FadeThroughTransition :

lib/home_screen.dart

import 'package;animations/animations.dart';

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) {
          return const QuestionScreen();
        },
        transitionsBuilder:
            (context, animation, secondaryAnimation, child) {
              return FadeThroughTransition(                     // Add from here...
                animation: animation,
                secondaryAnimation: secondaryAnimation,
                child: child,
              );                                                // To here.
            },
      ),
    );
  },
  child: Text('New Game'),
),

Настройте анимацию возврата в исходное положение.

1c0558ffa3b76439.gif

Функция «Предиктивный возврат» — это новая функция Android, которая позволяет пользователю заглянуть за текущий маршрут или приложение, чтобы увидеть, что находится позади него, прежде чем начать движение. Анимация заглядывания запускается в зависимости от положения пальца пользователя, когда он проводит им назад по экрану.

Flutter поддерживает анимацию «Назад» на системном уровне, включая эту функцию, когда у Flutter нет маршрутов для добавления в стек навигации, или, другими словами, когда нажатие кнопки «Назад» привело бы к выходу из приложения. Эта анимация обрабатывается системой, а не самим Flutter.

Flutter также поддерживает предиктивную навигацию «Назад» при переходе между маршрутами внутри приложения Flutter. Специальный PageTransitionsBuilder , называемый PredictiveBackPageTransitionsBuilder , отслеживает системные жесты предиктивной навигации «Назад» и управляет переходом между страницами в зависимости от прогресса жеста.

Функция предиктивного возврата поддерживается только в Android U и выше, но Flutter корректно вернется к исходному поведению жеста «назад» и использованию ZoomPageTransitionBuilder . Подробнее об этом, включая раздел о настройке в вашем приложении, читайте в нашей статье в блоге .

В конфигурации ThemeData для вашего приложения настройте PageTransitionsTheme так, чтобы на Android использовался PredictiveBack , а на других платформах — эффект плавного перехода из пакета animations:

lib/main.dart

import 'package:animations/animations.dart';                                 // NEW
import 'package:flutter/material.dart';

import 'home_screen.dart';

void main() {
  runApp(MainApp());
}

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        pageTransitionsTheme: PageTransitionsTheme(
          builders: {
            TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),  // NEW
            TargetPlatform.iOS: FadeThroughPageTransitionsBuilder(),         // NEW
            TargetPlatform.macOS: FadeThroughPageTransitionsBuilder(),       // NEW
            TargetPlatform.windows: FadeThroughPageTransitionsBuilder(),     // NEW
            TargetPlatform.linux: FadeThroughPageTransitionsBuilder(),       // NEW
          },
        ),
      ),
      home: HomeScreen(),
    );
  }
}

Теперь вы можете изменить вызов Navigator.push() обратно на MaterialPageRoute .

lib/home_screen.dart

ElevatedButton(
  onPressed: () {
    // Show the question screen to start the game
    Navigator.push(
      context,
      MaterialPageRoute(                                        // Add from here...
        builder: (context) {
          return const QuestionScreen();
        },
      ),                                                        // To here.
    );
  },
  child: Text('New Game'),
),

Используйте FadeThroughTransition, чтобы изменить текущий вопрос.

Виджет AnimatedSwitcher предоставляет только одну Animation в функции обратного вызова построителя. Для решения этой проблемы пакет animations предоставляет виджет PageTransitionSwitcher .

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({required this.question, super.key});

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(                              // Add from here...
      layoutBuilder: (entries) {
        return Stack(alignment: Alignment.topCenter, children: entries);
      },
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },                                                        // To here.
      duration: const Duration(milliseconds: 300),
      child: Card(
        key: ValueKey(question),
        elevation: 4,
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Text(
            question ?? '',
            style: Theme.of(context).textTheme.displaySmall,
          ),
        ),
      ),
    );
  }
}

Используйте OpenContainer

77358e5776eb104c.png

Виджет OpenContainer из пакета animations предоставляет эффект анимации трансформации контейнера, который расширяется, создавая визуальную связь между двумя виджетами.

Виджет, возвращаемый функцией closedBuilder отображается изначально и расширяется до виджета, возвращаемого функцией openBuilder , при нажатии на контейнер или при вызове функции обратного вызова openContainer .

Чтобы связать функцию обратного вызова openContainer с моделью представления, добавьте новый параметр, передающий viewModel в виджет QuestionCard и сохраняющий функцию обратного вызова, которая будет использоваться для отображения экрана «Игра окончена»:

lib/question_screen.dart

class QuestionScreen extends StatefulWidget {
  const QuestionScreen({super.key});

  @override
  State<QuestionScreen> createState() => _QuestionScreenState();
}

class _QuestionScreenState extends State<QuestionScreen> {
  late final QuizViewModel viewModel = QuizViewModel(
    onGameOver: _handleGameOver,
  );
  VoidCallback? _showGameOverScreen;                                    // NEW

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, child) {
        return Scaffold(
          appBar: AppBar(
            actions: [
              TextButton(
                onPressed:
                    viewModel.hasNextQuestion && viewModel.didAnswerQuestion
                    ? () {
                        viewModel.getNextQuestion();
                      }
                    : null,
                child: const Text('Next'),
              ),
            ],
          ),
          body: Center(
            child: Column(
              children: [
                QuestionCard(                                           // NEW
                  onChangeOpenContainer: _handleChangeOpenContainer,    // NEW
                  question: viewModel.currentQuestion?.question,        // NEW
                  viewModel: viewModel,                                 // NEW
                ),                                                      // NEW
                Spacer(),
                AnswerCards(
                  onTapped: (index) {
                    viewModel.checkAnswer(index);
                  },
                  answers: viewModel.currentQuestion?.possibleAnswers ?? [],
                  correctAnswer: viewModel.didAnswerQuestion
                      ? viewModel.currentQuestion?.correctAnswer
                      : null,
                ),
                StatusBar(viewModel: viewModel),
              ],
            ),
          ),
        );
      },
    );
  }

  void _handleChangeOpenContainer(VoidCallback openContainer) {        // NEW
    _showGameOverScreen = openContainer;                               // NEW
  }                                                                    // NEW

  void _handleGameOver() {                                             // NEW
    if (_showGameOverScreen != null) {                                 // NEW
      _showGameOverScreen!();                                          // NEW
    }                                                                  // NEW
  }                                                                    // NEW
}

Добавьте новый виджет, GameOverScreen :

lib/question_screen.dart

class GameOverScreen extends StatelessWidget {
  final QuizViewModel viewModel;
  const GameOverScreen({required this.viewModel, super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(automaticallyImplyLeading: false),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Scoreboard(
              score: viewModel.score,
              totalQuestions: viewModel.totalQuestions,
            ),
            Text('You Win!', style: Theme.of(context).textTheme.displayLarge),
            Text(
              'Score: ${viewModel.score} / ${viewModel.totalQuestions}',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            ElevatedButton(
              child: Text('OK'),
              onPressed: () {
                Navigator.popUntil(context, (route) => route.isFirst);
              },
            ),
          ],
        ),
      ),
    );
  }
}

В виджете QuestionCard замените Card виджетом OpenContainer из пакета animations , добавив два новых поля для viewModel и функции обратного вызова open container:

lib/question_screen.dart

class QuestionCard extends StatelessWidget {
  final String? question;

  const QuestionCard({
    required this.onChangeOpenContainer,
    required this.question,
    required this.viewModel,
    super.key,
  });

  final ValueChanged<VoidCallback> onChangeOpenContainer;
  final QuizViewModel viewModel;

  static const _backgroundColor = Color(0xfff2f3fa);

  @override
  Widget build(BuildContext context) {
    return PageTransitionSwitcher(
      duration: const Duration(milliseconds: 200),
      transitionBuilder: (child, animation, secondaryAnimation) {
        return FadeThroughTransition(
          animation: animation,
          secondaryAnimation: secondaryAnimation,
          child: child,
        );
      },
      child: OpenContainer(                                         // NEW
        key: ValueKey(question),                                    // NEW
        tappable: false,                                            // NEW
        closedColor: _backgroundColor,                              // NEW
        closedShape: const RoundedRectangleBorder(                  // NEW
          borderRadius: BorderRadius.all(Radius.circular(12.0)),    // NEW
        ),                                                          // NEW
        closedElevation: 4,                                         // NEW
        closedBuilder: (context, openContainer) {                   // NEW
          onChangeOpenContainer(openContainer);                     // NEW
          return ColoredBox(                                        // NEW
            color: _backgroundColor,                                // NEW
            child: Padding(                                         // NEW
              padding: const EdgeInsets.all(16.0),                  // NEW
              child: Text(
                question ?? '',
                style: Theme.of(context).textTheme.displaySmall,
              ),
            ),
          );
        },
        openBuilder: (context, closeContainer) {                    // NEW
          return GameOverScreen(viewModel: viewModel);              // NEW
        },                                                          // NEW
      ),
    );
  }
}

4120f9395857d218.gif

8. Поздравляем!

Поздравляем, вы успешно добавили анимационные эффекты в приложение Flutter и изучили основные компоненты системы анимации Flutter. В частности, вы узнали:

  • Как использовать ImplicitlyAnimatedWidget
  • Как использовать ExplicitlyAnimatedWidget
  • Как применять Curves и Tweens к анимации
  • Как использовать готовые виджеты для перехода между страницами, такие как AnimatedSwitcher или PageRouteBuilder
  • Как использовать встроенные анимационные эффекты из пакета animations , такие как FadeThroughTransition и OpenContainer
  • Как настроить анимацию перехода по умолчанию, включая добавление поддержки функции «Предиктивный возврат» на Android.

3026390ad413769c.gif

Что дальше?

Посмотрите некоторые из этих практических заданий:

Или скачайте демонстрационное приложение с примерами анимации , в котором представлены различные техники анимации.

Дополнительная информация

Больше ресурсов по анимации вы найдете на flutter.dev:

Или ознакомьтесь с этими статьями на Medium:

Справочная документация