В предыдущем параграфе мы познакомились 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 — это некоторый интерфейс, который не позволяет менять значение напрямую, а даёт только возможность «прослушать» изменение переменных. Достаточно неплохой пример хорошего применения ООП.
Типичный наследник класса Animation
(если быть точнее, Animation<double>
) — это AnimationController
. Давайте поговорим о нём детальнее.
Слон второй — AnimationController
Без него мы бы не смогли запускать наши анимации, управлять ими. Иногда, его даже называют громким словом «дирижер».
Подробнее про Animation<double>
Это основная реализация Animation, которая всегда присутствует, когда мы ведем речь об Explicit-анимациях, и без которой мы бы не смогли управлять анимацией.
Почему именно double, ведь выше было сказано, что в Animation<T>
может быть любой T
? В реальной жизни всё в итоге сводится к некоторой математической функции, которая изменяется по времени, а соотвественно речь в любом случае идет о числах. Даже когда мы хотим сделать, например, Animation<Color>
, в итоге мы придём к Animation<double>
. Почему так — расскажем чуть позже.
Посмотрим на конструктор класса AnimationController:
AnimationController({
double? value,
this.duration,
this.reverseDuration,
this.debugLabel,
this.lowerBound = 0.0,
this.upperBound = 1.0,
this.animationBehavior = AnimationBehavior.normal,
required TickerProvider vsync,
}){
//...
}
Можем выделить его основные особенности:
-
Обязательно необходимо указать параметр vsync. Для простоты на данном этапе можем всегда считать, что это экземпляр класса
SingleTickerProviderMixin
. Что это такое — мы разберём в следующих разделах. Сейчас надо лишь запомнить, что это миксин на State виджета. Отсюда следует еще один вывод — для того, чтобы создать контроллер нам нужен State. -
Можно задать параметры
lowerBound
/upperBound
(нижняя и верхняя границы анимации) для контроллера. Зачастую значения этих параметров остаются по умолчанию — 0 и 1, так как их достаточно легко интерполировать. То есть, эти значения лишь обозначают начало и конец анимации — попробуйте как-нибудь поиграть с отрицательными числами. Также есть конструкторunbounded()
— он применяется в случае, если мы строим анимацию на основе Simulation — еще одного способа задания функции, описывающей анимацию. Но обзор этого способа выходит за рамки текущей статьи. -
параметры duration и reverseDuration — задают время анимации на один цикл (от нижней до верхней границы). Это важный параметр, особенно если мы говорим о Staggered Animations, когда у нас есть несколько последовательных анимаций, завязанных на один контроллер.
❓ Можно заметить, что
duration
— это не обязательный параметр. Но при этом, при запуске анимаций есть проверка на null, которая может привести к ошибке. Рекомендуем задавать длительность либо в конструкторе, либо через сеттер до того, как анимация запущена.
Остальные параметры важны, но либо достаточно просты для самостоятельного рассмотрения, либо используются сильно реже (например, AnimationBehavior
используется при очень тонкой работе с Accessibility).
late final _controller = AnimationController(vsync: this, duration: Duration(seconds: 1),);
late 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
.
class AnimExample extends StatefulWidget {
const AnimExample({super.key});
@override
State<AnimExample> createState() => _AnimExampleState();
}
class _AnimExampleState extends State<AnimExample> {
@override
Widget build(BuildContext context) {
//.. — здесь и далее этот знак обозначает существующий, либо ещё недописанный код.
}
@override
void dispose() {
super.dispose();
}
}
Создадим контроллер. Для этого подключим SingleTickerProviderMixin
. Не забудем вызвать метод dispose()
у контроллера.
class AnimExample extends StatefulWidget {
const AnimExample({super.key});
@override
State<AnimExample> createState() => _AnimExampleState();
}
class _AnimExampleState extends State<AnimExample>
with SingleTickerProviderStateMixin<AnimExample> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.long4, // 600ms
);
//2
late final _anim = _controller.view;
// ...
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Добавим вёрстку.
class AnimExample extends StatefulWidget {
const AnimExample({super.key});
@override
State<AnimExample> createState() => _AnimExampleState();
}
class _AnimExampleState extends State<AnimExample>
with SingleTickerProviderStateMixin<AnimExample> {
// ...
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Opacity(
opacity: _anim.value,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.deepPurpleAccent,
),
),
),
),
);
}
// ...
}
Добавим старт анимации. Запускать будем при нажатии на цветной контейнер (изначально на центр экрана), при повторном — запускать в обратную сторону.
class AnimExample extends StatefulWidget {
const AnimExample({super.key});
@override
State<AnimExample> createState() => _AnimExampleState();
}
class _AnimExampleState extends State<AnimExample>
with SingleTickerProviderStateMixin<AnimExample> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.long4,
)
// Тут мы завязываемся на статус анимации, для переключения режима кнопки.
..addStatusListener(**_checkStatus**);
late final _anim = _controller.view;
bool _needToggleDirection = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Opacity(
opacity: _anim.value,
child: GestureDetector(
onTap: **_needToggleDirection
? _controller.reverse
: _controller.forward,**
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.deepPurpleAccent,
),
),
),
),
),
);
}
// ...
**void _checkStatus(AnimationStatus status) {
_needToggleDirection = status == AnimationStatus.completed;
}**
}
Если сейчас запустить приложение с этим виджетом, то… ничего не будет происходить. Виджет будет статичной кнопкой. В чем же дело?
Мы упустили один важный нюанс — не связали изменение анимации и обновление стейта. Ведь как мы знаем, основной метод оповещения стейта об изменении — это вызов метода setState
.
Допишем:
class AnimExample extends StatefulWidget {
const AnimExample({super.key});
@override
State<AnimExample> createState() => _AnimExampleState();
}
class _AnimExampleState extends State<AnimExample>
with SingleTickerProviderStateMixin<AnimExample> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.long4,
)
**..addListener(_update)**
// Тут мы завязываемся на статус анимации, для переключения режима кнопки.
..addStatusListener(**_checkStatus**);
// ...
@override
void dispose() {
**_controller.removeListener(_update);
_controller.removeStatusListener(_checkStatus);**
_controller.dispose();
super.dispose();
}
**void _update() => setState(() {});
void _checkStatus(AnimationStatus status) {
_needToggleDirection = status == AnimationStatus.completed;
}**
}
Здесь мы использовали функцию-обёртку над setState
. Вы можете удивиться — зачем? Тут надо вспомнить, что Function
— это такой же класс в Dart, как и всё остальное.
💡 Мы не можем передать setState
по ссылке в addListener
напрямую, только обернув в анонимную функцию. Но в этом случае, мы не сможем убрать ее из листенера, так как нам придётся в листенер передавать новую анонимную функцию, и это будет два разных объекта. С ипользованием обертки мы можем просто передать ссылку на объект функции _update
.
Соберём этот код в DartPad и запустим его. Как видите — получилась рабочая анимация, которая запускается при нажатии на кнопку.
Хорошо, теперь мы знаем, как создавать простейшую анимацию и её запускать. Но как быть, если нам нужно изменить значение не с 0 до 1, а, например изменить цвет от одного края спектра до другого? Или от 10 до 1000 пикселей, если мы хотим сделать перемещение элемента?
Для этих целей, а именно маппинга (или интерполяции) одной шкалы на [0;1], нам пригодиться еще одна составная часть явных анимаций — Tween<T>
.
Cлон третий — Tween<T>
Мы уже касались Tween в предыдущей главе Хендбука. Здесь мы немного вспомним и расширим информацию оттуда.
Итак, Tween — это линейная интерполяция значений, определенных в свойствахbegin
и end
. И это единственная задача этого класса.
Когда мы объединяем Tween
и AnimationController
— начинается вся «магия». Мы как бы переносим значения Tween на шкалу AnimationController
. Можно сказать, происходит построение функции , где a — это текущее значение контроллера. Если дело касается только double
, то можно обойтись без Tween, так как контроллер уже реализует класс Animation<double>
.
(график зависимости Tween со значениями -100;1000 от t_ac, где t_ac - шкала времени AnimationController со значениями 0;1)
Вся мощь Tween проявляется, когда мы хотим смаппить на шкалу контроллера значения, которые не являются числовыми (или не представлены одним числом).
В предыдущий статье был упомянут ColorTween. Это лишь один из множества готовых Tween — как видите, на изображении ниже даже не помещаются все публичные наследники класса Tween.
Мы не будем рассматривать их все, далее рассмотрим только один пример и сосредоточимся на том, что из себя представляет Tween, и как он связан с контроллером.
Мы уже упомянули, что Tween — это модели линейной интерполяции между двумя значениями. Данный класс расширяет интерфейс Animatable
.
class Tween<T extends Object?> extends Animatable\<T\>
Тут заострим внимание: Animatable
— это важный компонент анимаций, хотя мы и не используем его напрямую, чаще всего на первы план выступает его конкретная реализация — Tween. Данный интерфейс дает возможность превратить входные данные типа Animation<double>
в T
. По сути Animatable
— это сущность, которая может анимироваться.
Его суть описана методом evaluate()
.
T evaluate(Animation<double> animation) => transform(animation.value);
Вернёмся к 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>
.
Animation<T> animate(Animation<double> parent) {
return _AnimatedEvaluation\<T\>(parent, this);
}
Тут можно увидеть (как мы и говорили ранее), что технически мы склеиваем Animation<double>
и Animatable\<T\>
и в результате получаем новый Animation<T>
. Как это использовать, мы покажем ниже при рассмотрении кривых (Curves).
final _color = ColorTweeen(begin: Colors.white, end: Colors.black).animate(_controller);
Вы могли заметить еще одну сущность — _AnimatedEvaluation
. Это внутренний класс, наследник Animation, который по сути делегирует вычисление value анимации тому Animatable, который был передан ему в конструктор.
class _AnimatedEvaluation\<T\> extends Animation<T> with AnimationWithParentMixin<double> {
_AnimatedEvaluation(this.parent, this._evaluatable);
@override
final Animation<double> parent;
final Animatable\<T\> _evaluatable;
@override
T get value => _evaluatable.evaluate(parent);
@override
String toString() {
return '$parent\u27A9$_evaluatable\u27A9$value';
}
@override
String toStringDetails() {
return '${super.toStringDetails()} $_evaluatable';
}
}
А вот и черепаха — Ticker
Раньше мы говорили, что AnimationController - это «дирижёр» анимации. И да, для внешнего пользователя, для программиста, который прописывает анимацию и хочет ею управлять — это действительно дирижер и главный инструмент.
Но, есть у нас серый кардинал, «черепаха» нашего мира анимации — это та самая сущность, которая позволяет вычислять новое значение анимации с течением «времени», да и вообще задаёт это «время». Без неё анимация просто не будет работать. Это сущность — Ticker
.
Помните параметр vsync? Он принимает в себя некоторый TickerProvider
, который (как видно из названия) поставляет нам Ticker. Таких провайдеров существует несколько, и об их различии мы поговорим чуть позже.
Что такое Ticker
Здесь нам надо задаться вопросом: а вообще что позволяет анимации расчитаться? Что позволяет ей изменяться с течением времени? Ведь контроллер лишь запускает или останавливает её.
Ответ прост — анимация пересчитывается на «тик». А «тиком» по сути является новый фрейм. Как только у нас появился новый фрейм, вызывается внутренний метод Ticker._tick()
, который заставляет контроллер пересчитать внутреннее значение анимации и сообщить об изменении листенерам. Таким образом Ticker генерирует сигналы для отсчета времени анимации.
Если говорить чуть более технически, то Ticker добавляет коллбек к SchedulerBinding.scheduleFrameCallback().
@protected
void scheduleTick({ bool rescheduling = false }) {
assert(!scheduled);
assert(shouldScheduleTick);
_animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick, rescheduling: rescheduling);
}
В этом и заключается основная задача 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.
Мстители, общий сбор!
Рассмотрим следующий пример: у нас в приложении есть функция оценки заказа. Мы заметили, что пользователь редко нажимает на оценку и хотим провести эксперимент по увеличению процента нажатий.
Для этого дизайнер предложил анимировать кнопку — сделать ее дрожащей с изменением сдвига от центра на ±5 пикселей. Наша задача — реализовать её. Что ж, приступим.
Для начала проведём небольшое проектирование.
-
Что у нас является анимацией? По факту — изменение сдвига, который можно представить числовым значением типа
double
. Это одно ****изменяемое значение, нам хватит одного контроллера, а значит мы можем использоватьSingleTickerProviderMixin
. -
Сколько анимация занимает по времени? Для начала возьмем 250 мс.
☝ В нашем примере дизайнер не сообщил параметры анимации, и мы выбрали их произвольно. На практике так делать не стоит: перед началом разработки лучше ещё раз связаться с дизайнером и уточнить технические детали.
-
Как мы будем применять нашу анимацию? Значение анимации — это число пикселей, на которые нам необходимо сдвинуть наш виджет кнопки. Для таких целей может подойти
Transform.translate
— но это зависит от вёрстки вокруг: если кнопка лежит внутри Stack, то можно попробовать с помощьюPositioned
. Если выберемTransform
, то основной параметр этого виджета —Offset
. Так что анимацию мы можем строить уже на основе этого класса. -
Как вообще нам сделать анимацию сдвига? Можно увидеть, что у нас сдвиг изменяется на ±5 пикселей от центра. Можно допустить, что в момент времени t=0 анимации значение оффсета по X будет -5, а в момент времени t=1 будет +5.
// псевдокод offset(0) = -5; offset(1) = 5;
Тогда, вспоминая конструктор
AnimationController
, мы можем задать начальное значение контроллера равным 0.5.И еще один момент: у нашей анимации нет конца, она бесконечная. А значит, нам необходимо использовать
repeat(reverse:true)
для запуска. Параметр reverse будет true, чтобы анимация не началась с резкого рывка при повторном запуске, а пошла бы в обратном направлении. -
На текущей стадии наша анимация будет изменяться линейно и без пауз, это будет постоянное монотонное движение кнопки(для демонстрации, далее мы немного изменим это поведение).
Как это в итоге может выглядеть:
Соберем все вместе.
Как мы уже сказали ранее, анимация будет внутри State
виджета. Допустим, у нас есть следующий стейт:
class _AnimatedButtonState extends State<_AnimatedButton> {
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
);
}
}
Создадим основу нашей анимации — AnimationController
— и не забудем добавить SingleTickerProviderMixin
.
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.medium1, // 250ms
value: 0.5,
)..repeat(reverse: true);
// ...
}
Далее создадим Tween<Offset>
и применим его к _controller
для получения Animation:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.medium1,
value: 0.5,
)..repeat(reverse: true);
late final _offset = Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(5, 0),
).animate(
_controller,
);
// ...
}
Не забываем вызвать dispose() нашего контроллера:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
И теперь вставим наше значение анимации в верстку:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
late final **_offset** = Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(5, 0),
).animate(
_controller,
);
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: **_offset**.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
),
);
}
}
Также не забудем добавить слушатель на контроллер:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
@override
void initState() {
super.initState();
**_controller.addListener(_update);**
}
@override
void dispose() {
**_controller.removeListener(_update);**
_controller.dispose();
super.dispose();
}
void **_update**() {
setState(() {});
}
}
Вот теперь, запустив приложение, мы увидим перемещающуюся по экрану кнопку.
Добавляем натуральности перемещению
На данный момент, анимация достаточно проста. Она монотонно перемещается от одного края к другому. Но это не цепляет глаз, не хватает некоторой паузы между «вздрагиваниями».
Для реализации такого поведения нам необходимо познакомиться со специальным инструментом — TweenSequence
.
Этот класс позволяет описывать последовательность «этапов» анимации, указывая их продолжительность в процентах от общего времени — weight
.
💡 Важно: вся последовательность работает с единым типом T
.
Есть ещё один похожий инструмент — Interval
. Это специальная реализация Curve
, с помощью которой можно задать работу Tween
на определённом отрезке шкалы контроллера. Но её применение мы рассмотрим чуть позже.
Нашу анимацию можно разделить на четыре этапа:
- Пауза (чтобы пользователь сперва рассмотрел элементы на экране)
- Перемещение с 0 до 5
- Перемещение 5 → -5
- Перемещение -5 → 0
Четвёртый этап можно поставить в начале или в конце, да и всю цепочку можно развернуть, если хочется сначала перемещать кнопку влево, а не вправо. Но для демонстрации мы возьмем именно такую структуру.
Как ее представить в коде:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
late final _offsetSeq = TweenSequence<Offset>(
[
TweenSequenceItem(
tween: ConstantTween(Offset.zero),
weight: 70, // так как дизайнер нам не дал спеку, подбираем на глаз
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(5, 0),
end: const Offset(-5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(0, 0),
),
weight: 10,
),
],
).animate(_controller);
// ...
}
Веса мы подобрали на глаз, чтобы сумма была 100%. То есть 70% времени будет длиться пауза, и по 10% занимают все движения.
Ещё одно изменение по сравнению с прошлой версией: нам теперь надо запускать контроллер с начальным значением 0, так как мы изменили последовательность наших перемещений:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.medium4,
**value: 0, // можно стереть, так как это значение по умолчанию**
)..repeat(reverse: true);
// ...
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: **_offsetSeq**.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
),
);
}
// ...
}
Полный интерактивный вариант:
Добавляем нелинейность
В предыдущем параграфе мы рассказывал про Curve — кривые, которые описывают «темп» анимации.
К явным анимациям мы также можем применять различные кривые, представленные классом Curve. Для этого есть специальный класс — CurvedAnimation.
Помните, когда мы писали о методе animate()
у Tween
, мы упоминали, что он связывает не только AnimationController
, но и в целом Animation<double>
с Animatable
. Так вот, CurvedAnimation
— это ещё один наследник Animation<double>
. Чаще всего мы используем его для применения тех или иных кривых к нашей анимации.
Вот как мы можем наложить ту или иную кривую к нашему TweenSequence
— в качестве примера возьмем Curve.bounceIn
:
// ...
late final _offsetSeq = TweenSequence<Offset>(
[
TweenSequenceItem(
tween: ConstantTween(Offset.zero),
weight: 70,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(5, 0),
end: const Offset(-5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(0, 0),
).,
weight: 10,
),
],
).animate(
**CurvedAnimation(
parent: _controller,
curve: Curves.bounceIn,
),**
);
//...
Аналогично мы бы применили Curve
и к Tween
. В данном случае, TweenSequence приведен для примера.
Как видите, достаточно просто подать экземпляр данного класса с двумя обязательными параметрами:
- parent — аналогично одноименному у метода
animate()
принимает другойAnimation<double>
, который будет ею управлять; - curve — собственно, сама кривая из набора готовых
Curves
или реализованная собственноручно. Как реализовать её собственноручно мы уже рассказывали раньше.
Предлагаем вам самим попробовать различные кривые на живой демонстрации:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.light().copyWith(
scaffoldBackgroundColor: Colors.white,
),
debugShowCheckedModeBanner: false,
home: const RateScreen(),
);
}
}
class RateScreen extends StatefulWidget {
const RateScreen({super.key});
@override
State<RateScreen> createState() => _RateScreenState();
}
class _RateScreenState extends State<RateScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Positioned(
top: 250,
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
color: Colors.black87,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
height: 100,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.white54,
),
),
),
const SizedBox(height: 10),
SizedBox(
height: 25,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.white54,
),
),
),
const SizedBox(height: 50),
const _AnimatedButton(),
const SizedBox(height: 24),
],
),
),
)
],
),
);
}
}
class _AnimatedButton extends StatefulWidget {
const _AnimatedButton();
@override
State<_AnimatedButton> createState() => _AnimatedButtonState();
}
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
late final _controller = AnimationController(
vsync: this,
duration: Durations.medium4,
value: 0,
)..repeat(reverse: true);
late final _offsetSeq = TweenSequence<Offset>(
[
TweenSequenceItem(
tween: ConstantTween(Offset.zero),
weight: 70,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(5, 0),
end: const Offset(-5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(0, 0),
),
weight: 10,
),
],
).animate(_controller);
@override
void initState() {
super.initState();
_controller.addListener(_update);
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: _offsetSeq.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: const Text('Оценить заказ'),
),
);
}
@override
void dispose() {
_controller.removeListener(_update);
_controller.dispose();
super.dispose();
}
void _update() {
setState(() {});
}
}
Но мы можем сделать наш пример ещё интереснее. Мы можем применить кривую к каждому Tween
в нашем TweenSequence
.
☝ Здесь обратим внимание: способ применения кривой к Tween
внутри TweenSequence
отличается от «классического» через animate()
— потому что TweenSequence
принимает список Animatable
, а не Animation
.
Для этого нам надо вспомнить об упомянутом методе chain()
у Animatable
.
Animatable\<T\> chain(Animatable<double> parent) {
return _ChainedEvaluation\<T\>(parent, this);
}
Этот метод позволяет объединять различные Animatable
для создания новых. Типичное использование — смешать различные Tween
, например подмешать CurveTween
.
Важный нюанс: CurveTween
— наследник не Tween
, а именно Animatable
. Его классическое использование — применение кривых.
Добавим к одному из Tween в нашей последовательности еще одну кривую:
//...
late final _offsetSeq = TweenSequence<Offset>(
[
TweenSequenceItem(
tween: ConstantTween(Offset.zero),
weight: 70,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(5, 0),
)**.chain(
CurveTween(
curve: Curves.elasticIn, //взяли для примера
),
)**,
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(5, 0),
end: const Offset(-5, 0),
),
weight: 10,
),
TweenSequenceItem(
tween: Tween<Offset>(
begin: const Offset(-5, 0),
end: const Offset(0, 0),
),
weight: 10,
),
],
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.bounceOut,
),
);
//...
Предлагаем поэкспериментировать самим в последнем дартпаде выше с различными кривыми и их результатом.
Итак, мы познакомились с основным способом создания явных анимаций. Но вы уже наверняка заметили, что он слишком хлопотный: описать контроллер, не забыть поставить setState…
И если от описания контроллеров и Tween
нам не уйти, то вот связать контроллер со стейтом мы можем и другими, более приятными способами.
Упрощаем себе жизнь
AnimatedWidget
Если наш виджет кроме анимации не содержит никакого другого стейта, то, чтобы уменьшить количество кода для написания, Flutter предлагает следующий инструмент — AnimatedWidget
.
Это абстрактный виджет, который инкапсулирует в себе подписку на Listenable
и вызов setState. По сути ровно то, что в примере выше мы писали руками.
Как это будет выглядеть:
class _SimplifiedAnimatedButton extends AnimatedWidget {
const _SimplifiedAnimatedButton({required super.listenable});
Animation<Offset> get anim => listenable as Animation<Offset>;
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: anim.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
),
);
}
}
Заметим, что Animation придётся поставлять через конструктор, а следовательно, — создавать извне, так что от StatefulWidget
с миксином и декларацией нашей анимации мы не уйдем.
Этот класс мы можем использовать в методе build()
класса _AnimatedButtonState
:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ... описания анимации и Tween'ов как и ранее
@override
Widget build(BuildContext context) {
return _SimplifiedAnimatedButton(
listenable: _offsetSeq,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
Как видим, мы убрали методы подписки на контроллер и управления этим листенером.
Такой подход позволяет вынести похожие трансформации (если они не имеют состояния, кроме состояния анимации) и управлять конкретными параметрами анимации извне. Но в большинстве случаев, всё равно, достаточно многословен.
AnimatedBuilder
И здесь на сцену вступает он — AnimatedBuilder
. По своей сути - это виджет, основной целью которого является построение анимаций. И он наиболее часто используется в рамках разработки. По большому счету он делает всё то, что умеет AnimatedWidget
, но только без необходимости выделять отдельный класс.
Также он позволяет переиспользовать часть вёрстки, не перестраивая то, что остаётся неизменным. Ну и в добавок, его можно использовать внутри виджета со сложным стейтом (который состоит не только из анимации, но еще из других параметров).
Вот как будет выглядеть наш пример с использованием этого виджета:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
@override
Widget build(BuildContext context) {
return **AnimatedBuilder(
animation: _offsetSeq,
builder: (ctx, _) => Transform.translate(
offset: _offsetSeq.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
),
),
);**
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
И вот — мы спрятали все подписки на листенеры и ненужный код в билдер, при этом не создавая новый класс с лишним бойлерплейтом. Но это еще не всё!
Как мы говорили — у AnimationBuilder
есть возможность оптимизации: он умеет переиспользовать статичные, не изменяющиеся части верстки. В нашем случае это внешний вид кнопки. Она никак не изменяется, а к ней просто применяется некоторая трансформация. Вы должны были обратить внимание на неиспользуемый параметр _
в лямбде билдера — это и есть тот самый переиспользуемый child
.
Давайте посмотрим:
class _AnimatedButtonState extends State<_AnimatedButton>
with SingleTickerProviderStateMixin<_AnimatedButton> {
// ...
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _offsetSeq,
**child**: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: () {},
child: Text('Оценить заказ'),
),
builder: (ctx, **child**) => Transform.translate(
offset: _offsetSeq.value,
child: **child**,
),
);
}
// ...
}
Как можно увидеть, мы использовали параметр child
у виджета, вынесли туда кнопку, а потом использовали этот child
при построении анимации.
Таким образом, можно оптимизировать «тяжёлые» места с версткой.
💡 Наблюдение.
Можно заметить, что и AnimatedWidget
и AnimatedBuilder
используют не Animation
, а Listenable
в качестве параметра.
Да, Animation
расширяет класс Listenable
, это верно. Но такое решение позволяет использовать AnimatedBuilder
для подписки части верстки на тот или иной ChangeNotifier
, ValueNotifier
и так далее. Это неявно из именования, но команда фреймворка дала нам эту возможность, о чём явно указала в документации.
Вот мы и разобрали основы работы с явными анимациями в Flutter. Теперь рассмотрим более сложный кейс применения анимаций.
Интервальные анимации (Staggered Animations)
Не всегда анимации — это одновременное изменение тех или иных значений. Бывают случаи, когда нам надо изменять несколько разных значений последовательно или с накладыванием друг на друга (а можем быть и с паузами между) — управляя их интервалами.
Например, нам может понадобится:
- При клике на кнопку плавно сменить цвет
- Через 50мс после клика начать менять форму расширяя ее по ширине
- После окончания расширения повернуть текст на кнопке.
Этот пример «из головы», но он демонстрирует, что у нас есть три анимации (цвет, ширина, поворот) для трёх виджетов, и эти анимации распределены по шкале времени.
Это и называется в терминах Flutter — StaggeredAnimations. Данный термин был взят из официальной документации. Мы в тексте будем использовать термин «интервальные анимации» как наиболее близкий по смыслу и применению перевод.
Давайте сразу рассмотрим пример (он будет проще, чем описанный выше, чтобы можно было сконцентрироваться на основном подходе): у нас есть кнопка, при нажатии она должна свернуться в течении 300мс, после этого должен «развернуться» лоадер, так же в течении 300мс. При нажатии на лоадер происходит смена в обратном порядке. Под сворачиванием/разворачиванием мы тут будем понимать изменение масштаба — scale.
Как нам реализовать подобный кейс? Помните, когда говорили про TweenSequence
, мы упоминали инструмент под названием Interval
? Вот здесь как раз он нам и поможет.
Но сперва коротко расскажем, что такое Interval
.
Interval — это специальная реализация Curve. Она которая позволяет задать отрезок времени, на котором значение данной кривой будет изменяться по заданному закону.
Сам интервал мы задаем в долях. То есть, если у нас общее время анимации — 1000мс, а мы хотим запустить конкретную анимацию на интервале с 200мс до 800мс, то интервал будет определен значениями (0.2; 0.8).
Любой Interval
также принимает в свой конструктор Curve
, что позволяет комбинировать Curve и Interval.
💡 Почему мы предлагаем использовать именно Interval
, а не TweenSequence
? TweenSequence
удобно использовать, когда у нас есть последовательные изменения одного свойства или одного объекта (виджета). В нашем случае их два, поэтому тут в дело врывается Interval
.
Прежде чем перейти к практике, осталось решить — а сколько у нас будет контроллеров? Хотя у нас и два объекта анимации, нам удобно представить всю анимацию как единую (управляемую одной шкалой времени). Поэтому нам удобнее будет использовать один контроллер. И как раз в этом случае мы сможем эффективно использовать Interval
.
💡 Мы могли бы использовать и два контроллера, но будет больше кода, надо будет завязываться на конец анимации одного контроллера и запускать другой. Это менее красивое с точки зрения кода решение, плюс оно гораздо менее читаемо.
Всё, переходим к практике.
Для начала опишем контроллер. Основной момент, который надо учесть: продолжительность анимации в контроллере — это общая продолжительность анимации. В нашем случае 300+300 = 600.
class _ButtonState extends State<_Button>
with SingleTickerProviderStateMixin<_Button> {
late final AnimationController _scaleController = AnimationController(
vsync: this,
duration: const Duration(
milliseconds: **600**,
),
);
// здесь будут Tween
@override
void dispose() {
_scaleController.dispose();
super.dispose();
}
// TODO: дальше будем описывать логику и билд
}
Далее опишем два Tween — две наши анимации: scaleOut и scaleIn. Так как виджеты друг друга заменяют, то изменение будут от единицы до нуля, и от нуля до единицы соответственно.
Начнём описывать наши анимации. У нас их две:
- hide — когда виджет пропадает (scale изменяется от 1 до 0);
- show — когда виджет появляется (scale от 0 до 1).
Они будут описаны очень похоже, так что одну разберём более подробно, а другую приведём в готовом виде.
С begin/end для Tween мы определились. Теперь нам необходимо на шкалу времени контроллера навесить обе анимации. Если при вызове animate()
мы просто передадим контроллер, то обе анимации будут запускаться одновременно, перекрывая друг друга (эффект интересный, но не тот) и будут длиться 600мс.
Мы же хотим их разнести их по времени. Здесь и вступает в игру Interval
. Наши интервалы будут достаточно просты: нам необходимо, чтобы каждая из анимаций занимала половину времени, а следовательно будут отрезки (0; 0.5) и (0.5; 1).
Как уже говорилось, Interval
расширяет Curve
. А чтобы присоединить Curve
к Tween
, мы обычно используем CurvedAnimation
. Давайте напишем это в коде.
☝ Добавим также, что Interval
также имеет параметр curve
, который позволяет добавить к нему кривую на конкретное значение интервала. Это отличный инструмент, который позволяет разнообразит поведение. Опробуйте его сами в дартпаде ниже.
class _ButtonState extends State<_Button>
with SingleTickerProviderStateMixin<_Button> {
late final AnimationController _scaleController = AnimationController(
vsync: this,
duration: const Duration(
milliseconds: 600,
),
);
late final Animation<double> **_hide** = Tween<double>(
begin: 1,
end: 0,
).animate(
**CurvedAnimation(
parent: _scaleController,
curve: const Interval(
0, // точка старта анимации
0.5, // конец анимации == 300мс
),
),**
);
// ...
}
Собственно, для анимации показа будет примерно так же, только интервал будет уже от 0.5 до 1.
class _ButtonState extends State<_Button>
with SingleTickerProviderStateMixin<_Button> {
// ...
late final Animation<double> _hide = Tween<double>(
begin: 1,
end: 0,
).animate(
CurvedAnimation(
parent: _scaleController,
curve: const Interval(
0,
0.5,
),
),
);
late final Animation<double> _show = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _scaleController,
curve: const Interval(
0.5,
1,
),
),
);
// ...
}
Отлично, мы описали наши анимации! Но теперь (до того как мы опишем саму верстку) нам надо учесть один момент из условия: по нажатию на кнопку анимация запускается, по нажатию на лоадер — запускается в обратном направлении. Для этого мы введём дополнительный стейт _isLoading
(равен true
, когда видим индикатор загрузки), а также напишем метод _handleTap
, который будет вызвать forward
или reverse
в зависимости от значения _isLoading
.
class _ButtonState extends State<_Button>
with SingleTickerProviderStateMixin<_Button> {
// ...
late final Animation<double> _show = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _scaleController,
curve: const Interval(
0.5,
1,
),
),
);
**bool _isLoading = false;**
// ...
**void _handleTap() {
if (_isLoading) {
_scaleController.reverse();
} else {
_scaleController.forward();
}
setState(() {
_isLoading = !_isLoading;
});
}**
}
Здесь также видим, что при нажатии мы изменяем флаг _isLoading
на противоположное значение.
Всё! Осталось собрать всё вместе. И тут вопрос: а какой способ для соединения анимации и виджетов мы будем использовать? У нас их три, мы можем:
- Добавить слушателя на контроллер.
- Сделать наследника
AnimatedWidget
и вынести туда часть вёрстки. - Использовать
AnimatedBuilder
внутри текущего класса.
В нашем случае самым удобным будет AnimatedBuilder
, потому что у нашего виджета есть не только состояние анимации, но и состояние загрузки, и AnimatedBuilder
позволяет не делать избыточных классов в данном случае.
class _ButtonState extends State<_Button>
with SingleTickerProviderStateMixin<_Button> {
// ...
@override
Widget build(BuildContext context) {
return Stack(
children: [
Center(
child: **AnimatedBuilder(
animation: _show,
child: Center(
child: GestureDetector(
onTap: _handleTap,
child: const _Loader(),
),
),
builder: (context, child) {
return Transform.scale(
scale: _show.value,
child: child,
);
},
),**
),
Center(
child: SizedBox(
width: 250,
height: 56,
child: **AnimatedBuilder(
animation: _hide,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.amber,
),
onPressed: _handleTap,
child: const Center(child: Text('TAP ME')),
),
builder: (context, child) {
return Transform.scale(
scale: _hide.value,
child: child,
);
},
),**
),
),
],
);
}
// ...
}
Вот так мы собрали последовательность двух анимаций.
Таких последовательностей может сколь угодно много, интервалы могут перекрывать друг друга, запускаться одновременно и так далее. Если вам интересно попрактиковаться самому — попробуйте поиграться со значениями интервалов и посмотрите, как это отразится на самих анимациях.
Например, сделать отрезки (0; 0,75) и (0,25; 1) или (0; 0,25) и (0,75; 1). Попробуйте также начать не с нуля или закончить не единицей, а значением меньше. Это интересное небольшое самостоятельное исследование, которое поможет вам лучше проникнуться данным инструментом.
А пока, давайте вспомним, что мы узнали.
В этом разделе мы познакомились с тем, как устроены явные анимации, научились работать с ними и собрали несколько различных вариантов анимаций.
Также мы не рассмотрели несколько интересных, но реже используемых вещей, таких как: Simulation или управление анимаций на основе движения пальца. Предлагаем изучить их самостоятельно.
Напоследок скажем ещё одну вещь: работа с анимациями — это творчество, и различных эффектов можно добиться различными способами. Не бойтесь экспериментировать, подстраивать, настраивать — результат того стоит.
А в следующем параграфе вы научитесь работать с графикой во Flutter и рисовать различные фигуры — с помощью инструментов Canvas и CustomPainter.
Задача на самостоятельное выполнение
Для закрепления материала и практики, рекомендуется выполнить следующее задание.
Есть анимация (ссылка), которая представляет собой эффект перелистывания карточек, когда мы нажимаем на экран.
Реализуйте ее.
Advanced: вместо нажатия, попробуйте завязать перелистывание на движение пальцем.