3.5. Анимации: неявные анимации

Автор: Александр Гонтарев

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

Но прежде чем перейти к практике — немного теории: поговорим о том, для чего вообще нужны анимации в приложении.

Роли анимации

Вот базовые сценарии, где стоит применять анимацию:

  • Визуальная обратная связь. Пользователь получает ожидаемый визуальный отклик после взаимодействия с элементом приложения. Например, кнопка при нажатии немного меняет свой размер, чтобы дать понять пользователю, что его нажатие зафиксировано.
  • Функциональное изменение. Элемент меняется после действия пользователя. Например, при добавлении товара в корзину анимируем корзину, чтобы пользователь обратил на неё внимание и знал, куда нужно зайти для оформления заказа.
  • Анимация состояния системы. Используется для длительных процессов, чтобы пользователь понимал, что система в этот момент не зависла. Один из популярных примеров — прогресс-бар при загрузке файлов.

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

Теперь поговорим, как создаются анимации во Flutter.

Типы анимации в Flutter

Во Flutter существует два основных способа создания анимаций:

  • Implicit (неявные анимации) — для плавных переходов между различными состояниями виджета. Эти анимации воспроизводятся автоматически, требуя минимальной настройки и поддержки. Всё реализовано за нас, нам остаётся только использовать их.
  • Explicit (явные анимации) — те, которыми разработчики управляют самостоятельно, задавая поведение и свойства. У таких анимаций нужно определять жизненный цикл, начальные и конечные точки. У нас есть полный контроль над ними.

Выбор способа зависит от задачи. В большинстве случаев вы будете работать с неявными анимациями. И только когда столкнётесь с более комплексными и сложными сценариями (например, нужна возможность проиграть вашу анимацию назад), вам пригодятся явные: они дают больше гибкости и контроля.

Вот подробная шпаргалка по выбору анимации от команды Flutter.

flutter

Примечание

Важно: Далее мы будем рассматривать только неявные анимации и их виджеты (выделены жёлтым на графике). О явных анимациях — в следующем статье.

Какие виджеты для анимации Flutter предоставляет нам из коробки

Как уже упоминали выше, Flutter из коробки предоставляет ряд неявно анимированных виджетов. Обычно они называются AnimatedFoo, где Foo — это имя неанимированной версии этого виджета. Вот часть из тех, которые вам могут пригодиться:

  • AnimatedAlign — версия Align;
  • AnimatedContainer — версия Container;
  • AnimatedDefaultTextStyle — версия DefaultTextStyle;
  • AnimatedTheme — версия Theme;

Больше примеров — в официальной документации.

Анимирование готовых виджетов происходит так:

  • Выбираем виджет по свойству, которое хотим анимировать. Например, если хотим анимировать позиционирование элемента на экране, то нужно выбрать виджет AnimatedAlign.
  • Выбираем значения, по которым пройдёт анимация. Например, элемент был сверху экрана, а мы хотим, чтобы он оказался внизу. Значит, нужно выбрать смену значения свойства alignment с top на bottom.
  • Запускаем анимацию, изменяя это свойство. Например, по клику на кнопку.

Все эти шаги рассмотрим далее на примере. А пока давайте посмотрим, чем AnimatedAlign отличается от «обычного» Align.

Сравнение
Align({
    Key? key,
    AlignmentGeometry alignment = Alignment.center,
    double? widthFactor,
    double? heightFactor,
    Widget? child,
  })

AnimatedAlign({
    Key? key,
    required AlignmentGeometry alignment,
    double? widthFactor,
    double? heightFactor,
    Widget? child,
    Curve curve = Curves.linear,
    required Duration duration,
    VoidCallback? onEnd,
  })

Как видите, в AnimatedAlign появились несколько новых полей:

  • Curve curve = Curves.linear, required.
  • Duration duration.
  • VoidCallback? onEnd.

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

Чтобы элемент мог плавно двигаться по экрану, необходимо определить всего два обязательных поля:

  • duration — определяет, сколько будет длиться наша анимация;
  • alignment — расположение виджета. Мы будем двигать виджет по вертикали.

Но каким образом мы можем подставлять разные значения, между которыми будет анимироваться alignment? Для этого добавим переменную selected и будем менять в ней значение при клике на кнопку с помощью setState.

В итоге получим следующий код:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const AnimatedAlignmentExampleWrapper(),
    );
  }
}

class AnimatedAlignmentExample extends StatelessWidget {
  final bool selected;

  const AnimatedAlignmentExample({required this.selected, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedAlign(
      alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
      duration: const Duration(seconds: 1),
      child: GestureDetector(
        child: const Card(
          color: Colors.blue,
          child: SizedBox(
            width: 100.0,
            height: 100.0,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<AnimatedAlignmentExampleWrapper> createState() =>
      _AnimatedAlignmentExampleWrapperState();
}

class _AnimatedAlignmentExampleWrapperState
    extends State<AnimatedAlignmentExampleWrapper> {
  bool selected = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(
              height: 24,
            ),
            Expanded(
              child: Stack(
                children: [
                  AnimatedAlignmentExample(
                    selected: selected,
                  )
                ],
              ),
            ),
            const SizedBox(
              height: 24,
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  selected = !selected;
                });
              },
              child: const Text('Анимировать'),
            ),
            const SizedBox(
              height: 24,
            ),
          ],
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 1

Вот так, меняя всего один параметр, мы смогли добиться плавного перехода виджета из одного состояния в другое. Но можем ли мы как-то ещё повлиять на этот переход?

Конечно можем, для этого нам необходимо погрузиться чуть глубже в устройство AnimatedFoo-виджетов.

Из чего состоят неявно анимируемые виджеты

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

Рассмотрим его поближе.
const ImplicitlyAnimatedWidget({
  super.key,
  this.curve = Curves.linear,
  required this.duration,
  this.onEnd,
});

Как видите, это именно те параметры, которые добавляются в AnimatedFoo-виджеты. С duration мы уже знакомы, но за что отвечают остальные?

  • onEnd — функция, которая сработает в момент, когда анимация завершится. Она может быть полезна для запуска действия (например, другой анимации) в конце текущей анимации.
  • curve — используются для регулировки скорости изменения анимации с течением времени, позволяя ей ускоряться и замедляться, а не меняться с постоянной скоростью.

Рассмотрим их подробнее.

Curve — добавим немного математики

Значение Curve определяет тип кривой, которая отражает зависимость прогресса анимации от времени.

Представьте систему координат с интервалом [0,0; 1,1], где ось X отражает время, ось Y — прогресс анимации. Если провести линию из точки [0; 0] в точку [1; 1], то получится линейная анимация.

Вот так:

flutter

Во Flutter по умолчанию всегда ставится Curves.linear. Но такая анимация выглядит неестественно, поскольку в жизни все объекты двигаются нелинейно, у них есть отрезки замедления и ускорения.

Чтобы это изменить, нужно «искривить» линию на графике (отсюда и название Curves — «кривая»).

Посмотрите, как оживляется анимация с другим значением Curves — например, easeIn. Данная кривая позволяет нашей анимации начинаться медленно и ускоряться к завершению.

flutter

Если сравнить две эти кривые рядом, то можно сразу заметить разницу в работе анимации.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const CurvesExampleWrapper(),
    );
  }
}

class CurvesExample extends StatelessWidget {
  final bool selected;
  final Color color;
  final Curve curve;

  const CurvesExample({
    required this.selected,
    required this.color,
    required this.curve,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedAlign(
      alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
      duration: const Duration(seconds: 1),
      curve: curve,
      child: GestureDetector(
        child: Card(
          color: color,
          child: const SizedBox(
            width: 100.0,
            height: 100.0,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<CurvesExampleWrapper> createState() => _CurvesExampleWrapperState();
}

class _CurvesExampleWrapperState extends State<CurvesExampleWrapper> {
  bool selected = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(
              height: 24,
            ),
            Expanded(
              child: Stack(
                children: [
                  Padding(
                    padding: const EdgeInsets.only(right: 116.0),
                    child: CurvesExample(
                      selected: selected,
                      curve: Curves.linear,
                      color: Colors.blue,
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(left: 116.0),
                    child: CurvesExample(
                      selected: selected,
                      curve: Curves.easeIn,
                      color: Colors.red,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(
              height: 24,
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  selected = !selected;
                });
              },
              child: const Text('Анимировать'),
            ),
            const SizedBox(
              height: 24,
            ),
          ],
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 2

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

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

Кастомные кривые

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

Так мы и сделаем — только, чтобы не углубляться в сложные математические вычисления, напишем аналог уже знакомой вам Curves.linear.

Как мы уже упомянули ранее, кривой во Flutter может быть любое отображение функции за период времени t от 0.0 до 1.0 — это функция, f(t) которая занимает некоторое время t и выводит значение.

Однако мы должны соблюдать условия, согласно которым, когда t = 0.0 тогда функция должна выводить 0.0, и когда t = 1.0 функция должна выводить 1.0. В качестве примера предположим, что у нас есть обычная линейная функция:

Это прямая линия с константами k и b, а функция определяется вводом t. Давайте сделаем эту функцию ещё более простой и определим значение 0 для b и 1для k.

Её график выглядит предсказуемо:

flutter

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

@override
double transformInternal(double t) => t;

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

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const CurvesExampleWrapper(),
    );
  }
}

class CurvesExample extends StatelessWidget {
  final bool selected;
  final Color color;
  final Curve curve;

  const CurvesExample({
    required this.selected,
    required this.color,
    required this.curve,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedAlign(
      alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
      duration: const Duration(seconds: 1),
      curve: curve,
      child: GestureDetector(
        child: Card(
          color: color,
          child: const SizedBox(
            width: 100.0,
            height: 100.0,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<CurvesExampleWrapper> createState() => _CurvesExampleWrapperState();
}

class _CurvesExampleWrapperState extends State<CurvesExampleWrapper> {
  bool selected = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(
              height: 24,
            ),
            Expanded(
              child: Stack(
                children: [
                  Padding(
                    padding: const EdgeInsets.only(right: 116.0),
                    child: CurvesExample(
                      selected: selected,
                      curve: Curves
                          .linear, // Платформенная реализация Curves.linear
                      color: Colors.blue,
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(left: 116.0),
                    child: CurvesExample(
                      selected: selected,
                      curve: MyLinearCurve(), // Наша реализация Curves.linear
                      color: Colors.red,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(
              height: 24,
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  selected = !selected;
                });
              },
              child: const Text('Анимировать'),
            ),
            const SizedBox(
              height: 24,
            ),
          ],
        ),
      ),
    );
  }
}

// Созданная нами Curves.linear
class MyLinearCurve extends Curve {
  @override
  double transformInternal(double t) => t;
}

Результат выполнения

Результат выполнения примера 3

Когда бывает полезен onEnd

Выше мы сказали, что метод onEnd полезен для запуска действия в конце текущей анимации.

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

Синий блок ускоряется под конец анимации с помощью Curves.easeIn, а красный, наоборот, замедляется с помощью Curves.easeOut. Создаётся эффект, будто один блок толкает другой.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const OnEndExampleWrapper(),
    );
  }
}

class OnEndExample extends StatelessWidget {
  final Color color;
  final Curve curve;
  final Alignment alignment;
  final VoidCallback onEnd;

  const OnEndExample({
    required this.color,
    required this.curve,
    required this.onEnd,
    required this.alignment,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedAlign(
      alignment: alignment,
      duration: const Duration(seconds: 1),
      curve: curve,
      onEnd: onEnd,
      child: GestureDetector(
        child: Card(
          color: color,
          child: const SizedBox(
            width: 100.0,
            height: 100.0,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<OnEndExampleWrapper> createState() => _OnEndExampleWrapperState();
}

class _OnEndExampleWrapperState extends State<OnEndExampleWrapper> {
  bool firstAnimate = false;
  bool secondAnimate = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(
              height: 24,
            ),
            Expanded(
              child: Stack(
                children: [
                  Padding(
                    padding: const EdgeInsets.only(top: 100.0),
                    child: OnEndExample(
                      curve: Curves.easeIn,
                      color: Colors.blue,
                      alignment: firstAnimate
                          ? Alignment.center
                          : Alignment.bottomCenter,
                      onEnd: () {
                        setState(() {
                          if (firstAnimate == true) {
                            secondAnimate = true;
                          }
                        });
                      },
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(bottom: 100.0),
                    child: OnEndExample(
                      curve: Curves.easeOut,
                      color: Colors.red,
                      alignment: secondAnimate
                          ? Alignment.topCenter
                          : Alignment.center,
                      onEnd: () {},
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(
              height: 24,
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  firstAnimate = !firstAnimate;

                  if (secondAnimate == true) {
                    secondAnimate = false;
                  }
                });
              },
              child: const Text('Анимировать'),
            ),
            const SizedBox(
              height: 24,
            ),
          ],
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 4

Когда onEnd лучше не применять

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

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const OnEndExampleWrapper(),
    );
  }
}

class OnEndExample extends StatelessWidget {
  final Color color;
  final Curve curve;
  final Alignment alignment;
  final VoidCallback onEnd;

  const OnEndExample({
    required this.color,
    required this.curve,
    required this.onEnd,
    required this.alignment,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return AnimatedAlign(
      alignment: alignment,
      duration: const Duration(seconds: 1),
      curve: curve,
      onEnd: onEnd,
      child: GestureDetector(
        child: Card(
          color: color,
          child: const SizedBox(
            width: 100.0,
            height: 100.0,
          ),
        ),
      ),
    );
  }
}

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

  @override
  State<OnEndExampleWrapper> createState() => _OnEndExampleWrapperState();
}

class _OnEndExampleWrapperState extends State<OnEndExampleWrapper> {
  bool firstAnimate = false;
  bool secondAnimate = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            const SizedBox(
              height: 24,
            ),
            Expanded(
              child: Stack(
                children: [
                  Padding(
                    padding: const EdgeInsets.only(top: 100.0),
                    child: OnEndExample(
                      curve: Curves.linear,
                      color: Colors.blue,
                      alignment: firstAnimate
                          ? Alignment.center
                          : Alignment.bottomCenter,
                      onEnd: () {
                        setState(() {
                          if (firstAnimate == true) {
                            secondAnimate = true;

                            return;
                          }

                          firstAnimate = true;
                        });
                      },
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.only(bottom: 100.0),
                    child: OnEndExample(
                      curve: Curves.linear,
                      color: Colors.red,
                      alignment: secondAnimate
                          ? Alignment.topCenter
                          : Alignment.center,
                      onEnd: () {
                        setState(() {
                          if (secondAnimate == false) {
                            firstAnimate = false;

                            return;
                          }

                          secondAnimate = false;
                        });
                      },
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(
              height: 24,
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  firstAnimate = !firstAnimate;
                });
              },
              child: const Text('Анимировать'),
            ),
            const SizedBox(
              height: 24,
            ),
          ],
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 5

Это сработает, но как теперь завершить эту анимацию? Добавлять новые флаги в функцию? Придумывать какие-то костыли? Этого делать не стоит, такой подход может принести много вреда.

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

Какие ещё виджеты для неявных анимаций есть во Flutter

Есть ряд виджетов, которые имеют отличающуюся от ImplicitlyAnimatedWidget реализацию, но также относятся к неявным анимациям.

Например, AnimatedCrossFade: он плавно переходит между двумя заданными дочерними элементами и анимируется между их размерами. Этот тип анимации полезен, когда при изменении состояния приложения необходимо менять один виджет на другой.

Анимация примера 1

Но что делать, если у нас может быть больше, чем два виджета? В таком случае на помощь приходит виджет AnimatedSwitcher. Он может анимироваться между любым количеством элементов, но есть нюанс.

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

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

Анимация примера 2

Как быть, если ни один из уже реализованных виджетов не подошёл

На помощь может прийти TweenAnimationBuilder. Он позволяет анимировать любое свойство виджета.

const TweenAnimationBuilder({
    Key? key,
    required this.tween,
    required Duration duration,
    Curve curve = Curves.linear,
    required this.builder,
    VoidCallback? onEnd,
    this.child,
  })

Данный виджет также наследуется от ImplicitlyAnimatedWidget, но можно заметить, что здесь добавились два новых основных параметра tween и builder.

Рассмотрим их подробнее.

Builder — создадим наш виджет

Необходим для перерисовки нашего виджета на каждый тик анимации. Именно здесь мы будем создавать нужный нам виджет и анимировать его.

Билдер принимает функцию с параметрами, которая в итоге должна вернуть нам Widget.

typedef ValueWidgetBuilder<T> = Widget Function(BuildContext context, T value, Widget? child);

Рассмотрим параметры подробнее:

  • Value — текущее значение анимации из параметра tween класса Tween, с которым познакомимся ниже.
  • child — необходим для оптимизации. Если нам на каждый кадр необходимо будет создавать какой-то сложный виджет, например, картинку с множеством фильтров, то нам в этом поможет параметр child. Если он передаётся в TweenAnimationBuilder, то он создаётся один раз и потом его закешированная версия используется на каждый тик анимации. Поэтому порой этот параметр может сильно улучшить производительность вашей анимации.

Tween — расширяем границы

Tween — это одна из наиболее важных концепций в анимации Flutter, которая используется во всех анимациях наравне с Curves. В уже реализованных виджетах точно так же используется Tween, только он скрыт внутри виджета, поэтому при использовании мы о нём не задумывались.

Так что же это такое? Если коротко, то Tween — это интерполяция между двумя значениями одного типа. Например, мы можем использовать Tween<double> для интерполяции между двумя значениями типа double. Tween определяет начальное и конечное значения анимации, а фреймворк обеспечивает интерполяцию между этими значениями с течением времени.

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

Давайте рассмотрим работу Tween на примере. Представьте, что вам необходимо изменить цвет контейнера с одного на другой. Но если мы просто сделаем резкий переход между двумя цветами, то он может неприятно броситься в глаза:

Анимация примера 3

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

В таких случаях мы создаём ColorTween — он и даст все промежуточные значения между синим и красным цветом, чтобы мы могли их отобразить.

Поскольку TweenAnimationBuilder также наследуется от ImplicitlyAnimatedWidget, то и его использование очень схоже с AnimatedFoo-виджетами. Только теперь вместо каких-то значений в виджетах мы меняем значение в Tween, которое передаём в TweenAnimationBuilder.

Важно отметить, что нам необходимо менять именно параметр end у Tween, поскольку анимация проходит между текущим состоянием и конечным. Если мы меняем значение в start, то ничего не произойдёт.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const Scaffold(
        body: Center(
          child: ColorTweenExample(),
        ),
      ),
    );
  }
}

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

  @override
  State<ColorTweenExample> createState() => _ColorTweenExampleState();
}

class _ColorTweenExampleState extends State<ColorTweenExample> {
  bool isSelected = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          isSelected = !isSelected;
        });
      },
      child: TweenAnimationBuilder(
        tween: ColorTween(
          begin: Colors.blue,
          end: isSelected ? Colors.blue : Colors.red,
        ),
        duration: const Duration(milliseconds: 700),
        builder: (context, value, child) => Card(
          color: value,
          child: const SizedBox(
            height: 150,
            width: 150,
            child: Center(
              child: Text('Анимировать'),
            ),
          ),
        ),
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 6

Теперь анимация выглядит гораздо лучше! При этом мы не задумывались о том, как получить все промежуточные значения между двумя разными цветами. За нас всё сделал Tween.

Сделаем пример поинтересней: немного усложним нашу анимацию с использованием TweenAnimationBuilder. Реализуем анимацию, которая переворачивает наш виджет обратной стороной, при этом стороны у него будут разными.

Первое, что необходимо реализовать, — это поворот виджета по горизонтальной оси Y. Сделаем для этого виджет RotationY, который будет поворачивать переданный в него child на заданное количество градусов. Для непосредственно поворота будем использовать виджет Transform().

Пример виджета RotationY
class RotationY extends StatelessWidget {
  static const double _degrees2Radians = pi / 180;

  final Widget child;
  final double rotationY;

  const RotationY({
    required this.child,
    this.rotationY = 0,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Transform(
      alignment: FractionalOffset.center,
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001) // Магические цифры
        ..rotateY(rotationY * _degrees2Radians),
      child: child,
    );
  }
}

Теперь мы можем легко поворачивать что угодно, просто обернув это в RotationY():

import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const Scaffold(
        backgroundColor: Color(0xFFd9e2fc),
        body: Center(
          child: AnimatedFlip(),
        ),
      ),
    );
  }
}

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

  @override
  State<AnimatedFlip> createState() => _AnimatedFlipState();
}

class _AnimatedFlipState extends State<AnimatedFlip> {
  bool _isFlipped = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          _isFlipped = !_isFlipped;
        });
      },
      child: TweenAnimationBuilder(
        duration: const Duration(milliseconds: 700),
        curve: Curves.easeOut,
        tween: Tween<double>(begin: 0, end: _isFlipped ? 180 : 0),
        builder: (context, value, child) {
          return RotationY(
            rotationY: value,
            child: const Card(
              child: SizedBox(
                width: 150,
                height: 150,
              ),
            ),
          );
        },
      ),
    );
  }
}

class RotationY extends StatelessWidget {
  static const double _degrees2Radians = pi / 180;

  final Widget child;
  final double rotationY;

  const RotationY({
    required this.child,
    this.rotationY = 0,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Transform(
      alignment: FractionalOffset.center,
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001)
        ..rotateY(rotationY * _degrees2Radians),
      child: child,
    );
  }
}

Результат выполнения

Результат выполнения примера 7

flutter

Теперь, когда мы научились вращать виджет, можно добавлять анимацию. Для этого создадим Tween, который будет принимать значения от 0 до 180 — количество градусов, на которое необходимо повернуть наш виджет.

Чтобы мы могли развернуть виджет обратно, необходимо в Tween менять значение end между 0 и 180. Это позволит сначала повернуть виджет на 180 градусов, а потом вернуть его в начальное состояние. Для переключения между двумя значениями заведём переменную _isFlipped, в которой будем указывать, перевёрнут наш виджет или нет.

import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const Scaffold(
        backgroundColor: Color(0xFFd9e2fc),
        body: Center(
          child: AnimatedFlipWrapper(),
        ),
      ),
    );
  }
}

class AnimatedFlip extends StatelessWidget {
  final Widget front;
  final Widget back;
  final VoidCallback onTap;
  final bool isFlipped;

  const AnimatedFlip({
    required this.front,
    required this.back,
    required this.onTap,
    required this.isFlipped,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: TweenAnimationBuilder(
        duration: const Duration(milliseconds: 700),
        curve: Curves.easeOut,
        tween: Tween<double>(
          begin: 0,
          end: isFlipped ? 180 : 0,
        ),
        builder: (context, value, child) {
          final content = value <= 90 ? front : back;

          return RotationY(
            rotationY: value,
            child: content,
          );
        },
      ),
    );
  }
}

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

  @override
  State<AnimatedFlipWrapper> createState() => _AnimatedFlipWrapperState();
}

class _AnimatedFlipWrapperState extends State<AnimatedFlipWrapper> {
  bool isFlipped = false;

  @override
  Widget build(BuildContext context) {
    return AnimatedFlip(
      front: const Card(
        color: Colors.red,
        child: SizedBox(
          width: 150,
          height: 150,
        ),
      ),
      back: const Card(
        color: Colors.blue,
        child: SizedBox(
          width: 150,
          height: 150,
          child: Center(
            child: Text('Яндекс Образование'),
          ),
        ),
      ),
      isFlipped: isFlipped,
      onTap: () {
        setState(() {
          isFlipped = !isFlipped;
        });
      },
    );
  }
}

class RotationY extends StatelessWidget {
  static const double _degrees2Radians = pi / 180;

  final Widget child;
  final double rotationY;

  const RotationY({
    required this.child,
    this.rotationY = 0,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Transform(
      alignment: FractionalOffset.center,
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001)
        ..rotateY(rotationY * _degrees2Radians),
      child: child,
    );
  }
}

Результат выполнения

Результат выполнения примера 8

Наша анимация переворота готова. Теперь необходимо сделать разные стороны для карточки. Для этого мы можем делать подмену вращаемого виджета в момент, когда он поворачивается на 90 градусов.

import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: const Scaffold(
        backgroundColor: Color(0xFFd9e2fc),
        body: Center(
          child: AnimatedFlipWrapper(),
        ),
      ),
    );
  }
}

class AnimatedFlip extends StatelessWidget {
  final Widget front;
  final Widget back;
  final VoidCallback onTap;
  final bool isFlipped;

  const AnimatedFlip({
    required this.front,
    required this.back,
    required this.onTap,
    required this.isFlipped,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: TweenAnimationBuilder(
        duration: const Duration(milliseconds: 700),
        curve: Curves.easeOut,
        tween: Tween<double>(
          begin: 0,
          end: isFlipped ? 180 : 0,
        ),
        builder: (context, value, child) {
          final content = value < 90
              ? front
              : RotationY(
                  rotationY: 180,
                  child: back,
                );

          return RotationY(
            rotationY: value,
            child: content,
          );
        },
      ),
    );
  }
}

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

  @override
  State<AnimatedFlipWrapper> createState() => _AnimatedFlipWrapperState();
}

class _AnimatedFlipWrapperState extends State<AnimatedFlipWrapper> {
  bool isFlipped = false;

  @override
  Widget build(BuildContext context) {
    return AnimatedFlip(
      front: const Card(
        color: Colors.red,
        child: SizedBox(
          width: 150,
          height: 150,
        ),
      ),
      back: const Card(
        color: Colors.blue,
        child: SizedBox(
          width: 150,
          height: 150,
          child: Center(
            child: Text('Яндекс Образование'),
          ),
        ),
      ),
      isFlipped: isFlipped,
      onTap: () {
        setState(() {
          isFlipped = !isFlipped;
        });
      },
    );
  }
}

class RotationY extends StatelessWidget {
  static const double _degrees2Radians = pi / 180;

  final Widget child;
  final double rotationY;

  const RotationY({
    required this.child,
    this.rotationY = 0,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Transform(
      alignment: FractionalOffset.center,
      transform: Matrix4.identity()
        ..setEntry(3, 2, 0.001)
        ..rotateY(rotationY * _degrees2Radians),
      child: child,
    );
  }
}

Результат выполнения

Результат выполнения примера 9

Теперь стороны разные, но виджет на обратной стороне повёрнут к нам зеркально. Чтобы это исправить, необходимо дополнительно разворачивать в обратную сторону виджет, который будет у нас сзади.

Полный код
    import 'package:flutter/material.dart';
    import 'dart:math';

    void main() => runApp(MyApp());

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          theme: ThemeData.light(),
          home: const Scaffold(
            backgroundColor: Color(0xFFd9e2fc),
            body: Center(
              child: AnimatedFlipWrapper(),
            ),
          ),
        );
      }
    }

    class AnimatedFlip extends StatelessWidget {
      final Widget front;
      final Widget back;
      final VoidCallback onTap;
      final bool isFlipped;

      const AnimatedFlip({
        required this.front,
        required this.back,
        required this.onTap,
        required this.isFlipped,
        super.key,
      });

      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: onTap,
          child: TweenAnimationBuilder(
            duration: const Duration(milliseconds: 700),
            curve: Curves.easeOut,
            tween: Tween<double>(
              begin: 0,
              end: isFlipped ? 180 : 0,
            ),
            builder: (context, value, child) {
              final content = value < 90
                  ? front
                  : RotationY(
                      rotationY: 180,
                      child: back,
                    );

              return RotationY(
                rotationY: value,
                child: content,
              );
            },
          ),
        );
      }
    }

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

      @override
      State<AnimatedFlipWrapper> createState() => _AnimatedFlipWrapperState();
    }

    class _AnimatedFlipWrapperState extends State<AnimatedFlipWrapper> {
      bool isFlipped = false;

      @override
      Widget build(BuildContext context) {
        return AnimatedFlip(
          front: const Card(
            color: Colors.red,
            child: SizedBox(
              width: 150,
              height: 150,
            ),
          ),
          back: const Card(
            color: Colors.blue,
            child: SizedBox(
              width: 150,
              height: 150,
              child: Center(
                child: Text('Яндекс Образование'),
              ),
            ),
          ),
          isFlipped: isFlipped,
          onTap: () {
            setState(() {
              isFlipped = !isFlipped;
            });
          },
        );
      }
    }

    class RotationY extends StatelessWidget {
      static const double _degrees2Radians = pi / 180;

      final Widget child;
      final double rotationY;

      const RotationY({
        required this.child,
        this.rotationY = 0,
        super.key,
      });

      @override
      Widget build(BuildContext context) {
        return Transform(
          alignment: FractionalOffset.center,
          transform: Matrix4.identity()
            ..setEntry(3, 2, 0.001)
            ..rotateY(rotationY * _degrees2Radians),
          child: child,
        );
      }
    }

Теперь всё выглядит так, как мы и задумывали.


Вот и всё! В этой главе мы познакомились с основными виджетами анимации Flutter, а также коснулись устройства Curves и Tween. Рассмотрели, как можно делать свои анимации с помощью TweenAnimationBuilder.

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

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