Анимации во Flutter

Анимации во Flutter

О практической работе

subjectПоследнее обновление: июн. 3, 2025
account_circleАвторы: John Ryan, Justin McCandless

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 для отображения интересных анимационных эффектов с минимальной настройкой.

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

  • Пакет SDK Flutter
  • 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 — определяет основные классы, используемые в приложении.
  • question_screen.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/вопрос_экран.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

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

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

Чтобы открыть DevTools, убедитесь, что приложение работает в режиме отладки, и откройте Widget Inspector , выбрав его на панели инструментов отладки в VSCode или нажав кнопку Open Flutter DevTools в окне инструментов отладки в IntelliJ / Android Studio.

3ce33dc01d096b14.png

363ae0fbcd0c2395.png

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

adea0a16d01127ad.png

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

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

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

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

lib/вопрос_экран.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> , определяющий начальное и конечное смещение, между которыми будут перемещаться входящие и исходящие виджеты.

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

lib/вопрос_экран.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/вопрос_экран.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/вопрос_экран.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/вопрос_экран.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 s.

Запуск анимации с помощью 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/вопрос_экран.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 в метод сборки AnswerCards .

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

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

Анимировать навигационный переход

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

Чтобы настроить анимацию перехода, замените 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 для использования PredictiveBack на Android и эффекта плавного перехода из пакета анимации на других платформах:

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/вопрос_экран.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 и сохраните обратный вызов, который будет использоваться для отображения экрана «Game Over»:

lib/вопрос_экран.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/вопрос_экран.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 и обратного вызова открытого контейнера:

lib/вопрос_экран.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
  • Как настроить анимацию перехода по умолчанию, включая добавление поддержки Predictive Back на Android.

3026390ad413769c.gif

Что дальше?

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

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

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

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

Или ознакомьтесь со следующими статьями на Medium:

Справочные документы