Анимации во Flutter

1. Введение

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

Обзор структуры анимации Flutter

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

  • Неявная анимация — это предварительно созданные анимационные эффекты, которые автоматически запускают всю анимацию. Когда целевое значение анимации изменяется, она запускает анимацию от текущего значения до целевого значения и отображает каждое промежуточное значение, чтобы виджет анимировался плавно. Примеры неявной анимации включают AnimatedSize , AnimatedScale и AnimatedPositioned .
  • Явные анимации также представляют собой предварительно созданные анимационные эффекты, но для их работы требуется объект Animation . Примеры включают SizeTransition , ScaleTransition или PositionedTransition .
  • Animation — это класс, который представляет запущенную или остановленную анимацию и состоит из значения, представляющего целевое значение, до которого выполняется анимация, и status , который представляет текущее значение, которое анимация отображает на экране в любой момент времени. Это подкласс Listenable , который уведомляет своих слушателей об изменении статуса во время выполнения анимации.
  • AnimationController — это способ создания анимации и управления ее состоянием. Его методы, такие как forward() , reset() , stop() и repeat() можно использовать для управления анимацией без необходимости определять отображаемый эффект анимации, например масштаб, размер или положение.
  • Анимации используются для интерполяции значений между начальным и конечным значением и могут представлять любой тип, например 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 (View) использует класс QuizViewModel (View-Model), чтобы задавать пользователю вопросы с несколькими вариантами ответов из класса QuestionBank (Model).

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

fbb1e1f7b6c91e21.png

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

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(                                      // NEW
              isActive: score > i,                             // NEW
            )                                                  // NEW
        ],
      ),
    );
  }
}

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 в качестве параметра. Анимация — это класс, который принимает два значения ( begin и end ) и вычисляет промежуточные значения, чтобы анимация могла их отобразить. В этом примере мы будем использовать ColorTween , который соответствует интерфейсу Tween<Color> необходимому для создания нашего эффекта анимации.

Выберите Icon и используйте быстрое действие «Обернуть с помощью 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,                                      // Modify from here...
          );
        },                                                     // To here.
      ),
    );
  }
}

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

8b0911f4af299a60.gif

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

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

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

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

5dabe68d1210b8a1.gif

3a9e7490c594279a.gif

Эти диаграммы (доступны на странице документации Curves API) дают представление о том, как работают кривые. Кривые преобразуют входное значение от 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

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

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

Чтобы открыть DevTools, убедитесь, что приложение работает в режиме отладки, и откройте Инспектор виджетов , выбрав его на панели инструментов «Отладка» в VSCode или нажав кнопку «Открыть Flutter DevTools» в окне инструмента «Отладка» в 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<Offset> можно использовать для преобразования Animation<double> предоставленного AnimatedSwitcher , в Animation<Offset> , который будет предоставлен виджету 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<double> в диапазоне от 0,0 до 1,0 в T ween<Offset> , который переходит от -0,1 до 0,0 по оси x. -ось.

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

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
  },

Настройка макетаBuilder

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

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.center использует Alignment.topCenter .

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

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

6. Контролируйте состояние анимации

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

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

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

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

Создайте новый файл флип_эффект.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 используется для применения эффекта трехмерного вращения для имитации переворачивания карты.

Чтобы использовать этот виджет, оберните каждую карточку ответа виджетом CardFlipEffect. Обязательно укажите key для виджета «Карточка»:

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 для реализации собственной версии. К сожалению, поскольку этому классу необходимо хранить предыдущий виджет в его состоянии, ему необходимо использовать 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 в метод сборки 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>([   // NEW
      if (widget.delayAmount > 0)                   // NEW
        TweenSequenceItem(                          // NEW
          tween: ConstantTween<double>(0.0),        // NEW
          weight: widget.delayAmount,               // NEW
        ),                                          // NEW
      TweenSequenceItem(                            // NEW
        tween: Tween(begin: 0.0, end: 1.0),         // NEW
        weight: 1.0,                                // NEW
      ),                                            // NEW
    ]).animate(_animationController);               // NEW
  }

Наконец, замените анимацию AnimationController новой отложенной анимацией в методе сборки.

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. Используйте пользовательские переходы навигации

До сих пор мы видели, как настраивать эффекты на одном экране, но есть еще один способ использования анимации — использовать ее для перехода между экранами. В этом разделе вы узнаете, как применять эффекты анимации к переходам экрана, используя встроенные эффекты анимации и необычные готовые эффекты анимации, предоставляемые официальным пакетом анимаций на 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(                                         // NEW
        pageBuilder: (context, animation, secondaryAnimation) { // NEW
          return QuestionScreen();                              // NEW
        },                                                      // NEW
        transitionsBuilder:                                     // NEW
            (context, animation, secondaryAnimation, child) {   // NEW
          return FadeTransition(                                // NEW
            opacity: animation,                                 // NEW
            child: child,                                       // NEW
          );                                                    // NEW
        },                                                      // NEW
      ),                                                        // NEW
    );
  },
  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(                          // NEW
            animation: animation,                                // NEW
            secondaryAnimation: secondaryAnimation,              // NEW
            child: child,                                        // NEW
          );                                                     // NEW
        },
      ),
    );
  },
  child: Text('New Game'),
),

Настройте прогнозирующую анимацию спины

1c0558ffa3b76439.gif

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

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

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

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

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

библиотека/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),
        useMaterial3: true,
        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(builder: (context) {       // NEW
        return const QuestionScreen();             // NEW
      }),                                          // NEW
    );
  },
  child: Text('New Game'),
),

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

Виджет AnimatedSwitcher предоставляет только одну анимацию в обратном вызове компоновщика. Чтобы решить эту проблему, пакет 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(                                          // NEW
      layoutBuilder: (entries) {                                            // NEW
        return Stack(                                                       // NEW
          alignment: Alignment.topCenter,                                   // NEW
          children: entries,                                                // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      transitionBuilder: (child, animation, secondaryAnimation) {           // NEW
        return FadeThroughTransition(                                       // NEW
          animation: animation,                                             // NEW
          secondaryAnimation: secondaryAnimation,                           // NEW
          child: child,                                                     // NEW
        );                                                                  // NEW
      },                                                                    // NEW
      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,
          ),
        ),
      ),
    );
  }
}

Используйте ОпенКонтейнер

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 из пакета анимаций, добавив два новых поля для viewModel и обратного вызова открытого контейнера:

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
  • Как применить кривые и анимацию к анимации
  • Как использовать готовые виджеты перехода, такие как AnimatedSwitcher или PageRouteBuilder
  • Как использовать готовые анимационные эффекты из пакета animations , такие как FadeThroughTransition и OpenContainer.
  • Как настроить анимацию перехода по умолчанию, включая добавление поддержки Predictive Back на Android.

3026390ad413769c.gif

Что дальше?

Ознакомьтесь с некоторыми из этих лабораторий кода:

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

Дальнейшее чтение

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

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

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