В предыдущем параграфе мы познакомились c неявными анимациями
(англ. Implicit Animations).
Они дают возможность оперировать уже готовыми анимированными виджетами и комбинировать их. Но не всегда идеи наших дизайнеров или продактов можно реализовать с помощью этого инструмента.
Для таких нестандартных случаев во Flutter существуют явные анимации
(англ. Explicit Animation). В этом параграфе мы поговорим, что это за сущность и из каких компонентов она состоит.
Явные анимации — максимально коротко
Явные анимации дают нам полный контроль над каждым аспектом анимации, включая её продолжительность, темп изменения и конечный результат.
Их основная идея состоит в том, что мы сами указываем, какие изменения должны происходить в течение времени. А также мы можем задать начальное и конечное состояние объекта и определить, как оно должно изменяться со временем. Это позволяет нам создавать сложные и красивые эффекты.
Важная фишка такого подхода — мы можем создавать повторяющиеся анимации. Причём сделать это можно без особого труда и с настройкой того, в каком направлении будет протекать анимация на следующем цикле
Три слона явных анимаций
Вы наверняка слышали о такой древней теории мироустройства — что мир стоит на трёх слонах, а они на черепахе.
Так вот, эта метафора неплохо описывает мир анимаций во Flutter, пусть и очень упрощённо. Он тоже стоит на трёх концепциях-слонах и ещё одной сущности, которая подобно черепахе двигает их сквозь время.
Познакомимся с ними поближе.
- Первый слон —
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}
Можем выделить его основные особенности:
- Обязательно необходимо указать параметр
vsync
. Для простоты на данном этапе можем всегда считать, что это экземпляр классаSingleTickerProviderMixin
. Что это такое — мы разберём в следующих разделах. Сейчас надо лишь запомнить, что это миксин наState
виджета. Отсюда следует еще один вывод — для того, чтобы создать контроллер нам нуженState
. - Можно задать параметры
lowerBound
/upperBound
(нижняя и верхняя границы анимации) для контроллера. Зачастую значения этих параметров остаются по умолчанию — 0 и 1, так как их достаточно легко интерполировать. То есть, эти значения лишь обозначают начало и конец анимации — попробуйте как-нибудь поиграть с отрицательными числами. Также есть конструкторunbounded()
— он применяется в случае, если мы строим анимацию на основе Simulation — еще одного способа задания функции, описывающей анимацию. Но обзор этого способа выходит за рамки текущей статьи. - Параметры
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>
.
Вся мощь Tween проявляется, когда мы хотим смаппить на шкалу контроллера значения, которые не являются числовыми (или не представлены одним числом).
В предыдущем параграфе мы упоминали ColorTween
. Это лишь один из множества готовых Tween
— как видите, на изображении ниже даже не помещаются все публичные наследники класса Tween
.

Сосредоточимся на том, что из себя представляет 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 — он как раз и задаёт закон интерполяции. При этом, если присмотреться к этому методу, то мы увидим, что он автоматически работает с любыми типами, которые поддерживают арифметические операции (сложение, вычитание, умножение), а также подчиняются условиям:
- Сложение экземпляров
T
даёт результат того же типа: ( T + T → T). - Вычитание экземпляров
T
даёт результат того же типа: (T - T → T). - Умножение экземпляра
T
на double даёт результат типаT
: (T * double → T).
Такие типы можно использовать напрямую как дженерик Tween<T>
, не делая наследника.
Один из таких типов —
Offset
. У него переопределены арифметические операторы так, что он подходит под данные условия.
Однако, не все типы таковы. У многих типов со статическим методом lerp()
есть своя реализация Tween
, использующая данный метод. Например: ColorTween
, ThemeDataTween
и так далее.
Есть также неожиданные типы, например int
, который тоже не подходит под условие выше. Для него есть специальные IntTween
, StepTween
, которые описывают интерполяцию для целых чисел (они разные, так как используют разные правила округления — 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
:
SingleTickerProviderStateMixin
TickerProviderStateMixin
TestVSync
WidgetTester
(он реализует интерфейсTickerProvider
)
Последние два провайдера предназначены для тестов, и мы не будем их сейчас касаться.
Как следует из названия — SingleTickerProviderStateMixin
поставляет и хранит в себе один единственный тикер.
Важно: его следует использовать, когда вы оперируете лишь одним контроллером для анимации. Это более эффективно.
В противовес, TickerProviderStateMixin
работает уже с несколькими тикерами. Он хранит в себе Set
, и на каждый вызов createTicker()
(вызывается в конструкторе AnimationController
) добавляет созданный Ticker
в этот Set
.
Оба этих миксина подмешиваются к стейту виджета, а следовательно при создании анимации нам зачастую надо использовать StatefulWidget
.
Можем ли мы создать тикер сами? Да, конечно. Но тогда нам следует также хранить его и очищать. Зачастую, это избыточная операция, поэтому используется один из провайдеров выше.
Итак, мы прошлись по основным понятиям явных анимаций.
Коротко напомним, кто есть кто:
Animation
— базовый класс, им мы будем пользоваться, чтобы получить значение анимации.AnimationController
— инструмент, с помощью которого мы управляем анимацией.Tween
— модель для интерполяции любых значений на шкалу «времени» контроллера.Ticker
— теневой кардинал, «часы» для анимации.
В следующем параграфе мы объединим их и разработаем свою анимацию для красивого UI и элегантного UX.