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

В предыдущем параграфе мы познакомились c неявными анимациями (англ. Implicit Animations).

Они дают возможность оперировать уже готовыми анимированными виджетами и комбинировать их. Но не всегда идеи наших дизайнеров или продактов можно реализовать с помощью этого инструмента.

Для таких нестандартных случаев во Flutter существуют явные анимации (англ. Explicit Animation). В этом параграфе мы поговорим, что это за сущность и из каких компонентов она состоит.

Явные анимации — максимально коротко

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

Их основная идея состоит в том, что мы сами указываем, какие изменения должны происходить в течение времени. А также мы можем задать начальное и конечное состояние объекта и определить, как оно должно изменяться со временем. Это позволяет нам создавать сложные и красивые эффекты.

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

Три слона явных анимаций

Вы наверняка слышали о такой древней теории мироустройства — что мир стоит на трёх слонах, а они на черепахе.

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

Познакомимся с ними поближе.

flt

  • Первый слон — Animation. Это значение любого типа, которое изменяется со временем. Мы можем использовать Animation, чтобы управлять свойствами объектов, такими как положение в пространстве, ориентация, масштаб, прозрачность и так далее. То есть это вычисленное значение анимации.
  • Второй слон — AnimationController. Это «дирижёр» анимации, который управляет её ходом, устанавливая продолжительность, задавая «шкалу времени», начальное значение и другие параметры. С помощью AnimationController мы можем запускать, останавливать, прерывать анимацию, а также слушать её изменения.
  • Третий слон — Tween<T>, помогает нам переложить изменения конкретного типа T на «шкалу времени» контроллера. То есть он проводит операцию отображения одних значений на другие и строит функцию зависимости значений данного типа от значений контроллера. В результате объединения контроллера и Tween мы получаем нужный нам Animation.

💡Если быть более академически верным, то мы, конечно, должны говорить про объединение Animation и Tween для получения нового Animation. И даже больше: объединение Animation и Animatable.

Это более глубокие сущности, мы обсудим их далее.

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

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

А теперь давайте рассмотрим наших «слонов» подробнее.

Animation — слон первый

Тут название говорит само за себя: Animation — это ключевой компонент любой анимации. Но что из себя представляет анимация?

По большому счету, Animation<T> — это абстрактный класс, который хранит в себе переменную типа T и статус анимации, а также сообщает об изменениях любым слушателям. Да, данный класс расширяет интерфейс Listenable, и к нему можно добавить любых слушателей.

Статус анимации представлен множеством (enum) AnimationStatus. Он часто используется для задания той или иной реакции на конкретный этап анимации. Например, чтобы перезапустить анимацию или отослать событие аналитики после того, как анимация закончилась.

Необходимо сказать, что T может быть чем угодно. Animation может работать с любым типом данных, так как именно данный класс не декларирует никаких законов об изменении переменной типа Т. Часто используется Animation<double>, который покрывает достаточно большую часть кейсов.

Подробнее про Animation<double>

Это основная реализация Animation, которая всегда присутствует, когда мы ведем речь об Explicit-анимациях, и без которой мы бы не смогли управлять анимацией.

Почему именно double, ведь выше было сказано, что в Animation<T> может быть любой T? В реальной жизни всё в итоге сводится к некоторой математической функции, которая изменяется по времени, а соотвественно речь в любом случае идет о числах. Даже когда мы хотим сделать, например, Animation<Color>, в итоге мы придём к Animation<double>. Почему так — расскажем чуть позже.

💡 Вы же обратили внимание, что класс Animation — абстрактный? Соответственно, создать экземпляр данного класса нельзя. Отчасти, можно сказать, что Animation — это некоторый интерфейс, который не позволяет менять значение напрямую, а даёт только возможность «прослушать» изменение переменных. Достаточно неплохой пример хорошего применения ООП.

Типичный наследник класса Animation (если быть точнее, Animation<double>) — это AnimationController. Давайте поговорим о нём детальнее.

Слон второй — AnimationController

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

Посмотрим на конструктор класса AnimationController:

Посмотрим на конструктор класса AnimationController
1AnimationController({
2    double? value,
3    this.duration,
4    this.reverseDuration,
5    this.debugLabel,
6    this.lowerBound = 0.0,
7    this.upperBound = 1.0,
8    this.animationBehavior = AnimationBehavior.normal,
9    required TickerProvider vsync,
10}){
11  //...
12}

Можем выделить его основные особенности:

  1. Обязательно необходимо указать параметр vsync. Для простоты на данном этапе можем всегда считать, что это экземпляр класса SingleTickerProviderMixin. Что это такое — мы разберём в следующих разделах. Сейчас надо лишь запомнить, что это миксин на State виджета. Отсюда следует еще один вывод — для того, чтобы создать контроллер нам нужен State.
  2. Можно задать параметры lowerBoundupperBound(нижняя и верхняя границы анимации) для контроллера. Зачастую значения этих параметров остаются по умолчанию — 0 и 1, так как их достаточно легко интерполировать. То есть, эти значения лишь обозначают начало и конец анимации — попробуйте как-нибудь поиграть с отрицательными числами. Также есть конструктор unbounded()— он применяется в случае, если мы строим анимацию на основе Simulation — еще одного способа задания функции, описывающей анимацию. Но обзор этого способа выходит за рамки текущей статьи.
  3. Параметры duration и reverseDuration — задают время анимации на один цикл (от нижней до верхней границы). Это важный параметр, особенно если мы говорим о Staggered Animations, когда у нас есть несколько последовательных анимаций, завязанных на один контроллер.

💡 Можно заметить, что duration — это не обязательный параметр. Но при этом, при запуске анимаций есть проверка на null, которая может привести к ошибке. Рекомендуем задавать длительность либо в конструкторе, либо через сеттер до того, как анимация запущена.

Остальные параметры важны, но либо достаточно просты для самостоятельного рассмотрения, либо используются сильно реже (например, AnimationBehavior используется при очень тонкой работе с Accessibility).

1late final _controller = AnimationController(vsync: this, duration: Duration(seconds: 1),);
2late final _animation = _controller.view; //по сути это приведение AnimationController as Animation<double>

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

Только свойство мы пока не передали — но это могла бы быть прозрачность (opacity) виджета. Его мы соберём дальше, а пока коротко остановимся, чтобы узнать подробнее про методы запуска анимации.

Методы запуска анимации

Чаще всего используются два:

  • forward() — для обычных анимаций, которые останавливаются после окончания. Запускаем в «прямом» направлении от нижней к верхней границе анимации. Зачастую используется именно он.
  • repeat() — для «бесконечных» анимаций. После достижения верхней границы продолжают анимацию либо в обратном направлении, либо с начала (зависит от аргумента метода). Причем повторять мы можем только определённый отрезок (задаем min и max). Если не задали параметр period, то периодом будет та длительность, что мы задали при создании контроллера.

Остальные методы позволяют более тонко управлять анимацией: переключать её на конкретную точку (animateTo() ), имитировать fling()(быстрый жест по экрану для прокрутки списка), запускать в обратном направлении (reverse()) и останавливать (stop()).

Почти все методы управления анимацией возвращают специальный класс TickerFuture – он позволяет нам дожидаться завершения анимации (await) и совершать действие, если она завершилась успешно. Это справедливо для конечных анимаций.

Также у этого класса есть специальное поле orCancel, которое позволяет реагировать на отменённую анимацию (например, если анимация была запущена после dispose контроллера).

Важно: мы должны всегда вызвать dispose() у AnimationController в конце его жизненного цикла. Наиболее типичное место для этого — метод State.dispose() того виджета, в котором мы его создаем.

Продолжаем. Теперь мы готовы перейти к примеру.

Пример: анимирование свойства opacity

Это будет пошаговый пример — мы начнём писать код и будем последовательно его расширять и дорабатывать. Для экономии места доработки спрятаны под катом. А полная версия — в дартпаде ниже.

Приступим. Для начала сделаем StatefulWidget

Код
1class AnimExample extends StatefulWidget {
2  const AnimExample({super.key});
3
4  @override
5  State<AnimExample> createState() => _AnimExampleState();
6}
7
8class _AnimExampleState extends State<AnimExample> {
9
10  @override
11  Widget build(BuildContext context) {
12    //.. — здесь и далее этот знак обозначает существующий, либо ещё недописанный код.
13  }
14
15  @override
16  void dispose() {
17   
18    super.dispose();
19  }
20}

Далее создадим контроллер. Для этого подключим SingleTickerProviderMixin. Не забудем вызвать метод dispose() у контроллера.

Код
1class AnimExample extends StatefulWidget {
2  const AnimExample({super.key});
3
4  @override
5  State<AnimExample> createState() => _AnimExampleState();
6}
7
8class _AnimExampleState extends State<AnimExample>
9    with SingleTickerProviderStateMixin<AnimExample> {
10
11  late final _controller = AnimationController(
12    vsync: this,
13    duration: Durations.long4, // 600ms
14  );
15
16  //2
17  late final _anim = _controller.view;
18
19 // ...
20
21  @override
22  void dispose() {
23    _controller.dispose();
24    super.dispose();
25  }
26}

Добавим вёрстку.

Код
1class AnimExample extends StatefulWidget {
2  const AnimExample({super.key});
3
4  @override
5  State<AnimExample> createState() => _AnimExampleState();
6}
7
8class _AnimExampleState extends State<AnimExample>
9    with SingleTickerProviderStateMixin<AnimExample> {
10  // ...
11
12  @override
13  Widget build(BuildContext context) {
14    return Scaffold(
15      body: Center(
16        child: Opacity(
17          opacity: _anim.value,
18          child: Container(
19              width: 200,
20              height: 200,
21              decoration: BoxDecoration(
22                borderRadius: BorderRadius.circular(16),
23                color: Colors.deepPurpleAccent,
24              ),
25            ),
26        ),
27      ),
28    );
29  }
30
31 // ...
32}

Добавим старт анимации. Запускать будем при нажатии на цветной контейнер (изначально на центр экрана), при повторном — запускать в обратную сторону.

Код
1class AnimExample extends StatefulWidget {
2  const AnimExample({super.key});
3
4  @override
5  State<AnimExample> createState() => _AnimExampleState();
6}
7
8class _AnimExampleState extends State<AnimExample>
9    with SingleTickerProviderStateMixin<AnimExample> {
10
11  late final _controller = AnimationController(
12    vsync: this,
13    duration: Durations.long4,
14  )
15    // Тут мы завязываемся на статус анимации, для переключения режима кнопки.
16    ..addStatusListener(**_checkStatus**);
17
18  late final _anim = _controller.view;
19
20  bool _needToggleDirection = false;
21
22  @override
23  Widget build(BuildContext context) {
24    return Scaffold(
25      body: Center(
26        child: Opacity(
27          opacity: _anim.value,
28          child: GestureDetector(
29            onTap: **_needToggleDirection
30                ? _controller.reverse
31                : _controller.forward,**
32            child: Container(
33              width: 200,
34              height: 200,
35              decoration: BoxDecoration(
36                borderRadius: BorderRadius.circular(16),
37                color: Colors.deepPurpleAccent,
38              ),
39            ),
40          ),
41        ),
42      ),
43    );
44  }
45 
46 // ...
47
48  **void _checkStatus(AnimationStatus status) {
49    _needToggleDirection = status == AnimationStatus.completed;
50  }**
51}

Если сейчас запустить приложение с этим виджетом, то… ничего не будет происходить. Виджет будет статичной кнопкой. В чем же дело?

Мы упустили один важный нюанс — не связали изменение анимации и обновление стейта. Ведь как мы знаем, основной метод оповещения стейта об изменении — это вызов метода setState. Допишем.

Допишем:

1class AnimExample extends StatefulWidget {
2  const AnimExample({super.key});
3
4  @override
5  State<AnimExample> createState() => _AnimExampleState();
6}
7
8class _AnimExampleState extends State<AnimExample>
9    with SingleTickerProviderStateMixin<AnimExample> {
10  late final _controller = AnimationController(
11    vsync: this,
12    duration: Durations.long4,
13  )
14    **..addListener(_update)**
15     // Тут мы завязываемся на статус анимации, для переключения режима кнопки.
16    ..addStatusListener(**_checkStatus**);
17 
18  // ...
19
20  @override
21  void dispose() {
22  **_controller.removeListener(_update);
23    _controller.removeStatusListener(_checkStatus);**
24    _controller.dispose();
25    super.dispose();
26  }
27
28  **void _update() => setState(() {});
29
30  void _checkStatus(AnimationStatus status) {
31    _needToggleDirection = status == AnimationStatus.completed;
32  }**
33}

Здесь мы использовали функцию-обёртку над setState. Вы можете удивиться — зачем? Тут надо вспомнить, что Function — это такой же класс в Dart, как и всё остальное.

Мы не можем передать setState по ссылке в addListener напрямую, только обернув в анонимную функцию. Но в этом случае, мы не сможем убрать ее из листенера, так как нам придётся в листенер передавать новую анонимную функцию, и это будет два разных объекта. С ипользованием обертки мы можем просто передать ссылку на объект функции _update.

Соберём этот код в дартпад и запустим его. Как видите — получилась рабочая анимация, которая запускается при нажатии на кнопку.

Хорошо, теперь мы знаем, как создавать простейшую анимацию и её запускать. Но как быть, если нам нужно изменить значение не с 0 до 1, а, например изменить цвет от одного края спектра до другого? Или от 10 до 1000 пикселей, если мы хотим сделать перемещение элемента?

Для этих целей, а именно маппинга (или интерполяции) одной шкалы на [0;1], нам пригодиться еще одна составная часть явных анимаций — Tween<T>.

Cлон третий — Tween

Мы уже касались Tween в предыдущем параграфе. Здесь мы немного вспомним и расширим информацию оттуда.

Итак, Tween — это линейная интерполяция значений, определенных в свойствахbegin и end. И это единственная задача этого класса.

Когда мы объединяем Tween и AnimationController — начинается вся «магия». Мы как бы переносим значения Tween на шкалу AnimationController. Можно сказать, происходит построение функции , где a — это текущее значение контроллера. Если дело касается только double, то можно обойтись без Tween, так как контроллер уже реализует класс Animation<double>.

flt

Вся мощь Tween проявляется, когда мы хотим смаппить на шкалу контроллера значения, которые не являются числовыми (или не представлены одним числом).

В предыдущем параграфе мы упоминали ColorTween. Это лишь один из множества готовых Tween — как видите, на изображении ниже даже не помещаются все публичные наследники класса Tween.

D0

Сосредоточимся на том, что из себя представляет Tween и какие методы есть у этого класса.

Tween «под капотом»

Мы уже упомянули, что Tween — это модели линейной интерполяции между двумя значениями. Данный класс расширяет интерфейс Animatable.

1class Tween<T extends Object?> extends Animatable\<T\>
2

Тут заострим внимание: Animatable — это важный компонент анимаций, хотя мы и не используем его напрямую, чаще всего на первы план выступает его конкретная реализация — Tween. Данный интерфейс дает возможность превратить входные данные типа Animation<double> в T. По сути Animatable — это сущность, которая может анимироваться.

Его суть описана методом evaluate().

1T evaluate(Animation<double> animation) => transform(animation.value);
2

Вернёмся к Tween. Вот его основные методы:

  • lerp() — декларирован в самом классе Tween;
  • transform() — наследован от Animatable;
  • animate() — также наследован от Animatable.

Есть еще метод chain(). Он также наследуется от Animatable, его значение мы раскроем при рассмотрении кривых.

Метод lerp()

Это основа Tween — он как раз и задаёт закон интерполяции. При этом, если присмотреться к этому методу, то мы увидим, что он автоматически работает с любыми типами, которые поддерживают арифметические операции (сложение, вычитание, умножение), а также подчиняются условиям:

  1. Сложение экземпляров T даёт результат того же типа: ( T + T → T).
  2. Вычитание экземпляров T даёт результат того же типа: (T - T → T).
  3. Умножение экземпляра T на double даёт результат типа T: (T * double → T).

Такие типы можно использовать напрямую как дженерик Tween<T>, не делая наследника.

Один из таких типов — Offset. У него переопределены арифметические операторы так, что он подходит под данные условия.

Однако, не все типы таковы. У многих типов со статическим методом lerp() есть своя реализация Tween, использующая данный метод. Например: ColorTween, ThemeDataTween и так далее.

Есть также неожиданные типы, например int, который тоже не подходит под условие выше. Для него есть специальные IntTweenStepTween,  которые описывают интерполяцию для целых чисел (они разные, так как используют разные правила округления — round() и floor() соответственно).

Мы можем сами переопределить метод lerp() для некоторого класса, но в этом случае рекомендуется вспомнить математику.

Метод transform()

Он достаточно простой — в рамках Animatable он предназначен для делегирования и реализации конкретных трансформаций в наследниках. А в реализации Tween он просто вызывает метод lerp() для вычисления значения Tween при некотором значении контроллера.

Таким образом мы получаем функцию, в которой Tween зависит от значений контроллера (о чем и говорит интерфейс Animatable).

Метод animate()

Это «клей», который связывает AnimationController с Tween и создает Animation<T> на основе Animation<double>.

1Animation<T> animate(Animation<double> parent) {
2    return _AnimatedEvaluation\<T\>(parent, this);
3  }

Тут можно увидеть (как мы и говорили ранее), что технически мы склеиваем Animation<double> и Animatable\<T\> и в результате получаем новый Animation<T>. Как это использовать, мы покажем ниже при рассмотрении кривых (Curves).

1final _color = ColorTweeen(begin: Colors.white, end: Colors.black).animate(_controller);

Вы могли заметить еще одну сущность — _AnimatedEvaluation. Это внутренний класс, наследник Animation, который по сути делегирует вычисление value анимации тому Animatable, который был передан ему в конструктор.

Пример
1class _AnimatedEvaluation\<T\> extends Animation<T> with AnimationWithParentMixin<double> {
2  _AnimatedEvaluation(this.parent, this._evaluatable);
3
4  @override
5  final Animation<double> parent;
6
7  final Animatable\<T\> _evaluatable;
8
9  @override
10  T get value => _evaluatable.evaluate(parent);
11
12  @override
13  String toString() {
14    return '$parent\u27A9$_evaluatable\u27A9$value';
15  }
16
17  @override
18  String toStringDetails() {
19    return '${super.toStringDetails()} $_evaluatable';
20  }
21}

А вот и черепаха — Ticker

Раньше мы говорили, что AnimationController — это «дирижёр» анимации. И да, для внешнего пользователя, для программиста, который прописывает анимацию и хочет ею управлять — это действительно дирижер и главный инструмент.

Но, есть у нас серый кардинал, «черепаха» нашего мира анимации — это та самая сущность, которая позволяет вычислять новое значение анимации с течением «времени», да и вообще задаёт это «время». Без неё анимация просто не будет работать. Это сущность — Ticker.

Помните параметр vsync? Он принимает в себя некоторый TickerProvider, который (как видно из названия) поставляет нам Ticker. Таких провайдеров существует несколько, и об их различии мы поговорим чуть позже.

Что такое Ticker

Здесь нам надо задаться вопросом: а вообще что позволяет анимации рассчитаться? Что позволяет ей изменяться с течением времени? Ведь контроллер лишь запускает или останавливает её.

Ответ прост — анимация пересчитывается на «тик». А «тиком» по сути является новый фрейм. Как только у нас появился новый фрейм, вызывается внутренний метод Ticker._tick(), который заставляет контроллер пересчитать внутреннее значение анимации и сообщить об изменении листенерам. Таким образом Ticker генерирует сигналы для отсчета времени анимации.

Если говорить чуть более технически, то Ticker добавляет коллбек scheduleFrameCallback() к сервису связи SchedulerBinding.

Пример
1@protected
2  void scheduleTick({ bool rescheduling = false }) {
3    assert(!scheduled);
4    assert(shouldScheduleTick);
5    _animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick, rescheduling: rescheduling);
6  }

В этом и заключается основная задача Ticker — связать появление фрейма и расчёт анимации.

Зачем нам TickerProvider

Теперь вернемся к TickerProvider. Зачем он нам?

  • Во-первых, это удобный механизм для поставки Ticker в стейт виджета и его хранения.
  • Во-вторых, он выполняет ещё одну функцию (правда, на практике она чаще выполняется в поставляемых фреймворком виджетах, чем в реальных проектах), — это обновление Ticker в случае, если изменился TickerMode.

TickerMode — это специальный виджет, который может замьютить (mute) тикер и все контроллеры в поддереве.

Надо учитывать, что существует несколько готовых TickerProvider:

  1. SingleTickerProviderStateMixin
  2. TickerProviderStateMixin
  3. TestVSync
  4. WidgetTester (он реализует интерфейс TickerProvider)

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

Как следует из названия — SingleTickerProviderStateMixin поставляет и хранит в себе один единственный тикер.

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

В противовес, TickerProviderStateMixin работает уже с несколькими тикерами. Он хранит в себе Set, и на каждый вызов createTicker() (вызывается в конструкторе AnimationController) добавляет созданный Ticker в этот Set.

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

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


Итак, мы прошлись по основным понятиям явных анимаций.

Коротко напомним, кто есть кто:

  1. Animation — базовый класс, им мы будем пользоваться, чтобы получить значение анимации.
  2. AnimationController — инструмент, с помощью которого мы управляем анимацией.
  3. Tween — модель для интерполяции любых значений на шкалу «времени» контроллера.
  4. Ticker — теневой кардинал, «часы» для анимации.

В следующем параграфе мы объединим их и разработаем свою анимацию для красивого UI и элегантного UX.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.5. Анимации: неявные анимации
Следующий параграф3.7. Анимации: практика