В предыдущем параграфе мы рассмотрели явные анимации и их составные части:
Animation
.AnimationController
.Tween
.Ticker
.
В этом параграфе мы сфокусируемся на практике: соберём вместе эти сущности и создадим собственную явную анимацию, а затем упростим и добавим несколько последовательных анимаций.
Плюс в конце у нас будет самостоятельная работа.
Анимируем кнопку
Представьте, вы разрабатываете приложение интернет-магазина. В нём есть функция оценки заказа. И вот продакт вместе с аналитиком заметили, что пользователи редко нажимают на эту кнопку. Они хотят провести эксперимент по увеличению процента нажатий.
Для этого дизайнер предложил анимировать кнопку — сделать ее дрожащей с изменением сдвига от центра на ±5 пикселей. Наша задача — реализовать такую анимацию. Что же, приступим.
Анализ требований
Для начала проведём небольшое проектирование.
- Что у нас будет анимацией? По факту — изменение сдвига, который можно представить числовым значением типа
double
. Это одно изменяемое значение, нам хватит одного контроллера, а значит мы можем использовать*SingleTickerProviderMixin*
. - Сколько анимация занимает по времени? Для начала возьмем 250 мс.
Важно: В нашем примере дизайнер не сообщил параметры анимации, и мы выбрали их произвольно. На практике так делать не стоит: перед началом разработки лучше ещё раз связаться с дизайнером и уточнить технические детали.
-
Как мы будем применять нашу анимацию? Значение анимации — это число пикселей, на которые нам необходимо сдвинуть наш виджет кнопки. Для таких целей может подойти
Transform.translate
— но это зависит от вёрстки вокруг: если кнопка лежит внутриStack
, то можно попробовать с помощьюPositioned
. Если выберемTransform
, то основной параметр этого виджета —Offset
. Так что анимацию мы можем строить уже на основе этого класса. -
Как вообще нам сделать анимацию сдвига? Можно увидеть, что у нас сдвиг изменяется на ±5 пикселей от центра. Можно допустить, что в момент времени t=0 анимации значение оффсета по X будет -5, а в момент времени t=1 будет +5.
1// псевдокод 2offset(0) = -5; 3offset(1) = 5;
Тогда, вспоминая конструктор AnimationController
, мы можем задать начальное значение контроллера равным 0.5.
И еще один момент: у нашей анимации нет конца, она бесконечная. А значит, нам необходимо использовать repeat(reverse:true)
для запуска. Параметр reverse
будет true, чтобы анимация не началась с резкого рывка при повторном запуске, а пошла бы в обратном направлении.
На текущей стадии наша анимация будет изменяться линейно и без пауз, это будет постоянное монотонное движение кнопки(для демонстрации, далее мы немного изменим это поведение).
Как это в итоге может выглядеть:
Реализация
Соберём все вместе. Как и в предыдущем параграфе, мы начнём писать код и будем последовательно его расширять и дорабатывать. Для экономии места доработки спрятаны под катом. А полная версия — в дартпаде ниже.
Как мы уже сказали ранее, анимация будет внутри State
виджета. Допустим, у нас есть следующий стейт.
Код
1class _AnimatedButtonState extends State<_AnimatedButton> {
2
3 @override
4 Widget build(BuildContext context) {
5 return ElevatedButton(
6 style: ElevatedButton.styleFrom(
7 backgroundColor: Colors.amber,
8 ),
9 onPressed: () {},
10 child: Text('Оценить заказ'),
11 );
12 }
13}
Создадим основу нашей анимации — AnimationController
— и не забудем добавить SingleTickerProviderMixin
.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 late final _controller = AnimationController(
4 vsync: this,
5 duration: Durations.medium1, // 250ms
6 value: 0.5,
7 )..repeat(reverse: true);
8
9 // ...
10}
Далее создадим Tween<Offset>
и применим его к _controller
для получения Animation.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 late final _controller = AnimationController(
4 vsync: this,
5 duration: Durations.medium1,
6 value: 0.5,
7 )..repeat(reverse: true);
8
9 late final _offset = Tween<Offset>(
10 begin: const Offset(-5, 0),
11 end: const Offset(5, 0),
12 ).animate(
13 _controller,
14 );
15
16 // ...
17}
Не забываем вызвать dispose()
нашего контроллера.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ...
4
5 @override
6 void dispose() {
7 _controller.dispose();
8 super.dispose();
9 }
10}
И теперь вставим наше значение анимации в верстку.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3
4 // ...
5
6 late final **_offset** = Tween<Offset>(
7 begin: const Offset(-5, 0),
8 end: const Offset(5, 0),
9 ).animate(
10 _controller,
11 );
12
13 @override
14 Widget build(BuildContext context) {
15 return Transform.translate(
16 offset: **_offset**.value,
17 child: ElevatedButton(
18 style: ElevatedButton.styleFrom(
19 backgroundColor: Colors.amber,
20 ),
21 onPressed: () {},
22 child: Text('Оценить заказ'),
23 ),
24 );
25 }
26}
Также не забудем добавить слушатель на контроллер.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ...
4
5 @override
6 void initState() {
7 super.initState();
8
9 **_controller.addListener(_update);**
10 }
11
12 @override
13 void dispose() {
14 **_controller.removeListener(_update);**
15 _controller.dispose();
16 super.dispose();
17 }
18
19 void **_update**() {
20 setState(() {});
21 }
22}
Вот теперь, запустив приложение, мы увидим перемещающуюся по экрану кнопку.
Базово анимация готова — далее мы доработаем её и сделаем более привлекательной.
Добавляем натуральности перемещению
На данный момент, анимация достаточно проста. Она монотонно перемещается от одного края к другому. Но это не цепляет глаз, не хватает некоторой паузы между «вздрагиваниями».
Для реализации такого поведения нам необходимо познакомиться со специальным инструментом — TweenSequence
.
Этот класс позволяет описывать последовательность «этапов» анимации, указывая их продолжительность в процентах от общего времени — weight
.
Важно: вся последовательность работает с единым типом
T
.
Есть ещё один похожий инструмент — Interval
. Это специальная реализация Curve
, с помощью которой можно задать работу Tween
на определённом отрезке шкалы контроллера. Но её применение мы рассмотрим чуть позже.
Нашу анимацию можно разделить на четыре этапа:
- Пауза (чтобы пользователь сперва рассмотрел элементы на экране)
- Перемещение с 0 до 5
- Перемещение 5 → -5
- Перемещение -5 → 0
Четвёртый этап можно поставить в начале или в конце, да и всю цепочку можно развернуть, если хочется сначала перемещать кнопку влево, а не вправо. Но для демонстрации мы возьмем именно такую структуру.
Далее мы будем дорабатывать наш код, а полную версию можно посмотреть в дартпаде ниже.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ...
4
5 late final _offsetSeq = TweenSequence<Offset>(
6 [
7 TweenSequenceItem(
8 tween: ConstantTween(Offset.zero),
9 weight: 70, // так как дизайнер нам не дал спеку, подбираем на глаз
10 ),
11 TweenSequenceItem(
12 tween: Tween<Offset>(
13 begin: const Offset(0, 0),
14 end: const Offset(5, 0),
15 ),
16 weight: 10,
17 ),
18 TweenSequenceItem(
19 tween: Tween<Offset>(
20 begin: const Offset(5, 0),
21 end: const Offset(-5, 0),
22 ),
23 weight: 10,
24 ),
25 TweenSequenceItem(
26 tween: Tween<Offset>(
27 begin: const Offset(-5, 0),
28 end: const Offset(0, 0),
29 ),
30 weight: 10,
31 ),
32 ],
33 ).animate(_controller);
34
35 // ...
36}
Веса мы подобрали на глаз, чтобы сумма была 100%. То есть 70% времени будет длиться пауза, и по 10% занимают все движения.
Ещё одно изменение по сравнению с прошлой версией: нам теперь надо запускать контроллер с начальным значением 0, так как мы изменили последовательность наших перемещений.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 late final _controller = AnimationController(
4 vsync: this,
5 duration: Durations.medium4,
6 **value: 0, // можно стереть, так как это значение по умолчанию**
7 )..repeat(reverse: true);
8
9 // ...
10
11 @override
12 Widget build(BuildContext context) {
13 return Transform.translate(
14 offset: **_offsetSeq**.value,
15 child: ElevatedButton(
16 style: ElevatedButton.styleFrom(
17 backgroundColor: Colors.amber,
18 ),
19 onPressed: () {},
20 child: Text('Оценить заказ'),
21 ),
22 );
23 }
24
25 // ...
26}
27
Полный интерактивный вариант:
Добавляем нелинейность
В предыдущем параграфе мы рассказывал про Curves
— кривые, которые описывают «темп» анимации.
К явным анимациям мы также можем применять различные кривые, представленные классом Curve
. Для этого есть специальный класс — CurvedAnimation
.
Помните, когда мы писали о методе animate()
у Tween
, мы упоминали, что он связывает не только AnimationController
, но и в целом Animation<double>
с Animatable
. Так вот, CurvedAnimation
— это ещё один наследник Animation<double>
. Чаще всего мы используем его для применения тех или иных кривых к нашей анимации.
Вот как мы можем наложить ту или иную кривую к нашему TweenSequence
— в качестве примера возьмем Curve.bounceIn
.
Код
1// ...
2
3late final _offsetSeq = TweenSequence<Offset>(
4 [
5 TweenSequenceItem(
6 tween: ConstantTween(Offset.zero),
7 weight: 70,
8 ),
9 TweenSequenceItem(
10 tween: Tween<Offset>(
11 begin: const Offset(0, 0),
12 end: const Offset(5, 0),
13 ),
14 weight: 10,
15 ),
16 TweenSequenceItem(
17 tween: Tween<Offset>(
18 begin: const Offset(5, 0),
19 end: const Offset(-5, 0),
20 ),
21 weight: 10,
22 ),
23 TweenSequenceItem(
24 tween: Tween<Offset>(
25 begin: const Offset(-5, 0),
26 end: const Offset(0, 0),
27 ).,
28 weight: 10,
29 ),
30 ],
31 ).animate(
32 **CurvedAnimation(
33 parent: _controller,
34 curve: Curves.bounceIn,
35 ),**
36 );
37//...
Аналогично мы бы применили Curve
и к Tween
. В данном случае, TweenSequence
приведён для примера.
Как видите, достаточно просто подать экземпляр данного класса с двумя обязательными параметрами:
- parent — аналогично одноименному у метода
animate()
принимает другойAnimation<double>
, который будет ею управлять; - curve — собственно, сама кривая из набора готовых
Curves
или реализованная собственноручно. Как реализовать её собственноручно мы уже рассказывали раньше.
Предлагаем вам самим попробовать различные кривые на живой демонстрации.
Код
1 import 'package:flutter/material.dart';
2
3 void main() {
4 runApp(const MyApp());
5 }
6
7 class MyApp extends StatelessWidget {
8 const MyApp({super.key});
9
10 @override
11 Widget build(BuildContext context) {
12 return MaterialApp(
13 theme: ThemeData.light().copyWith(
14 scaffoldBackgroundColor: Colors.white,
15 ),
16 debugShowCheckedModeBanner: false,
17 home: const RateScreen(),
18 );
19 }
20 }
21
22 class RateScreen extends StatefulWidget {
23 const RateScreen({super.key});
24
25 @override
26 State<RateScreen> createState() => _RateScreenState();
27 }
28
29 class _RateScreenState extends State<RateScreen> {
30 @override
31 Widget build(BuildContext context) {
32 return Scaffold(
33 body: Stack(
34 children: [
35 Positioned(
36 top: 250,
37 left: 0,
38 right: 0,
39 bottom: 0,
40 child: Container(
41 decoration: const BoxDecoration(
42 borderRadius: BorderRadius.only(
43 topLeft: Radius.circular(16),
44 topRight: Radius.circular(16),
45 ),
46 color: Colors.black87,
47 ),
48 padding: const EdgeInsets.symmetric(horizontal: 16),
49 child: Column(
50 mainAxisSize: MainAxisSize.max,
51 mainAxisAlignment: MainAxisAlignment.end,
52 children: [
53 SizedBox(
54 height: 100,
55 child: Container(
56 decoration: BoxDecoration(
57 borderRadius: BorderRadius.circular(16),
58 color: Colors.white54,
59 ),
60 ),
61 ),
62 const SizedBox(height: 10),
63 SizedBox(
64 height: 25,
65 child: Container(
66 decoration: BoxDecoration(
67 borderRadius: BorderRadius.circular(16),
68 color: Colors.white54,
69 ),
70 ),
71 ),
72 const SizedBox(height: 50),
73 const _AnimatedButton(),
74 const SizedBox(height: 24),
75 ],
76 ),
77 ),
78 )
79 ],
80 ),
81 );
82 }
83 }
84
85 class _AnimatedButton extends StatefulWidget {
86 const _AnimatedButton();
87
88 @override
89 State<_AnimatedButton> createState() => _AnimatedButtonState();
90 }
91
92 class _AnimatedButtonState extends State<_AnimatedButton>
93 with SingleTickerProviderStateMixin<_AnimatedButton> {
94 late final _controller = AnimationController(
95 vsync: this,
96 duration: Durations.medium4,
97 value: 0,
98 )..repeat(reverse: true);
99
100 late final _offsetSeq = TweenSequence<Offset>(
101 [
102 TweenSequenceItem(
103 tween: ConstantTween(Offset.zero),
104 weight: 70,
105 ),
106 TweenSequenceItem(
107 tween: Tween<Offset>(
108 begin: const Offset(0, 0),
109 end: const Offset(5, 0),
110 ),
111 weight: 10,
112 ),
113 TweenSequenceItem(
114 tween: Tween<Offset>(
115 begin: const Offset(5, 0),
116 end: const Offset(-5, 0),
117 ),
118 weight: 10,
119 ),
120 TweenSequenceItem(
121 tween: Tween<Offset>(
122 begin: const Offset(-5, 0),
123 end: const Offset(0, 0),
124 ),
125 weight: 10,
126 ),
127 ],
128 ).animate(_controller);
129
130 @override
131 void initState() {
132 super.initState();
133
134 _controller.addListener(_update);
135 }
136
137 @override
138 Widget build(BuildContext context) {
139 return Transform.translate(
140 offset: _offsetSeq.value,
141 child: ElevatedButton(
142 style: ElevatedButton.styleFrom(
143 backgroundColor: Colors.amber,
144 ),
145 onPressed: () {},
146 child: const Text('Оценить заказ'),
147 ),
148 );
149 }
150
151 @override
152 void dispose() {
153 _controller.removeListener(_update);
154 _controller.dispose();
155 super.dispose();
156 }
157
158 void _update() {
159 setState(() {});
160 }
161 }
Но мы можем сделать наш пример ещё интереснее. Мы можем применить кривую к каждому Tween
в нашем TweenSequence
.
Здесь обратим внимание: способ применения кривой к
Tween
внутриTweenSequence
отличается от «классического» черезanimate()
— потому чтоTweenSequence
принимает списокAnimatable
, а неAnimation
.
Для этого нам надо вспомнить об упомянутом методе chain()
у Animatable
.
1Animatable\<T\> chain(Animatable<double> parent) {
2 return _ChainedEvaluation\<T\>(parent, this);
3}
Этот метод позволяет объединять различные Animatable
для создания новых. Типичное использование — смешать различные Tween
, например подмешать CurveTween
.
Важный нюанс: CurveTween
— наследник не Tween
, а именно Animatable
. Его классическое использование — применение кривых.
Добавим к одному из Tween
в нашей последовательности еще одну кривую.
Код
1//...
2late final _offsetSeq = TweenSequence<Offset>(
3 [
4 TweenSequenceItem(
5 tween: ConstantTween(Offset.zero),
6 weight: 70,
7 ),
8 TweenSequenceItem(
9 tween: Tween<Offset>(
10 begin: const Offset(0, 0),
11 end: const Offset(5, 0),
12 )**.chain(
13 CurveTween(
14 curve: Curves.elasticIn, //взяли для примера
15 ),
16 )**,
17 weight: 10,
18 ),
19 TweenSequenceItem(
20 tween: Tween<Offset>(
21 begin: const Offset(5, 0),
22 end: const Offset(-5, 0),
23 ),
24 weight: 10,
25 ),
26 TweenSequenceItem(
27 tween: Tween<Offset>(
28 begin: const Offset(-5, 0),
29 end: const Offset(0, 0),
30 ),
31 weight: 10,
32 ),
33 ],
34 ).animate(
35 CurvedAnimation(
36 parent: _controller,
37 curve: Curves.bounceOut,
38 ),
39 );
40//...
Предлагаем поэкспериментировать самим в последнем дартпаде выше с различными кривыми и их результатом.
Упрощаем себе жизнь
Итак, мы познакомились с основным способом создания явных анимаций. Но вы уже наверняка заметили, что он слишком хлопотный: описать контроллер, не забыть поставить setState…
И если от описания контроллеров и Tween
нам не уйти, то вот связать контроллер со стейтом мы можем и другими, более приятными способами.
Их два:
- Использовать
AnimatedWidget
. - Применить
AnimatedBuilder
.
Рассмотрим оба, а потом применим на практике.
AnimatedWidget
Если наш виджет кроме анимации не содержит никакого другого стейта, то чтобы уменьшить количество кода для написания, Flutter предлагает следующий инструмент — AnimatedWidget
.
Это абстрактный виджет, который инкапсулирует в себе подписку на Listenable
и вызов setState. По сути ровно то, что в примере выше мы писали руками.
Код
1class _SimplifiedAnimatedButton extends AnimatedWidget {
2 const _SimplifiedAnimatedButton({required super.listenable});
3
4 Animation<Offset> get anim => listenable as Animation<Offset>;
5
6 @override
7 Widget build(BuildContext context) {
8 return Transform.translate(
9 offset: anim.value,
10 child: ElevatedButton(
11 style: ElevatedButton.styleFrom(
12 backgroundColor: Colors.amber,
13 ),
14 onPressed: () {},
15 child: Text('Оценить заказ'),
16 ),
17 );
18 }
19}
Заметим, что Animation придётся поставлять через конструктор, а следовательно, — создавать извне, так что от StatefulWidget
с миксином и декларацией нашей анимации мы не уйдем.
Этот класс мы можем использовать в методе build()
класса _AnimatedButtonState
.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ... описания анимации и Tween'ов как и ранее
4
5 @override
6 Widget build(BuildContext context) {
7 return _SimplifiedAnimatedButton(
8 listenable: _offsetSeq,
9 );
10 }
11
12 @override
13 void dispose() {
14 _controller.dispose();
15 super.dispose();
16 }
17}
Как видим, мы убрали методы подписки на контроллер и управления этим листенером.
Такой подход позволяет вынести похожие трансформации (если они не имеют состояния, кроме состояния анимации) и управлять конкретными параметрами анимации извне. Но в большинстве случаев, всё равно, достаточно многословен.
AnimatedBuilder
И здесь на сцену вступает он — AnimatedBuilder
. По своей сути — это виджет, основной целью которого является построение анимаций. И он наиболее часто используется в рамках разработки. По большому счету он делает всё то, что умеет AnimatedWidget
, но только без необходимости выделять отдельный класс.
Также он позволяет переиспользовать часть вёрстки, не перестраивая то, что остаётся неизменным. Ну и в добавок, его можно использовать внутри виджета со сложным стейтом (который состоит не только из анимации, но еще из других параметров).
Вот как будет выглядеть наш пример с использованием этого виджета.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ...
4
5 @override
6 Widget build(BuildContext context) {
7 return **AnimatedBuilder(
8 animation: _offsetSeq,
9 builder: (ctx, _) => Transform.translate(
10 offset: _offsetSeq.value,
11 child: ElevatedButton(
12 style: ElevatedButton.styleFrom(
13 backgroundColor: Colors.amber,
14 ),
15 onPressed: () {},
16 child: Text('Оценить заказ'),
17 ),
18 ),
19 );**
20 }
21
22 @override
23 void dispose() {
24 _controller.dispose();
25 super.dispose();
26 }
27}
И вот — мы спрятали все подписки на листенеры и ненужный код в билдер, при этом не создавая новый класс с лишним бойлерплейтом. Но это еще не всё!
Как мы говорили — у AnimationBuilder
есть возможность оптимизации: он умеет переиспользовать статичные, не изменяющиеся части верстки. В нашем случае это внешний вид кнопки. Она никак не изменяется, а к ней просто применяется некоторая трансформация. Вы должны были обратить внимание на неиспользуемый параметр _
в лямбде билдера — это и есть тот самый переиспользуемый child
.
Код
1class _AnimatedButtonState extends State<_AnimatedButton>
2 with SingleTickerProviderStateMixin<_AnimatedButton> {
3 // ...
4
5 @override
6 Widget build(BuildContext context) {
7 return AnimatedBuilder(
8 animation: _offsetSeq,
9 **child**: ElevatedButton(
10 style: ElevatedButton.styleFrom(
11 backgroundColor: Colors.amber,
12 ),
13 onPressed: () {},
14 child: Text('Оценить заказ'),
15 ),
16 builder: (ctx, **child**) => Transform.translate(
17 offset: _offsetSeq.value,
18 child: **child**,
19 ),
20 );
21 }
22
23 // ...
24}
Как можно увидеть, мы использовали параметр child
у виджета, вынесли туда кнопку, а потом использовали этот child
при построении анимации.
Таким образом, можно оптимизировать «тяжёлые» места с версткой.
Наблюдение.
Можно заметить, что и
AnimatedWidget
иAnimatedBuilder
используют неAnimation
, аListenable
в качестве параметра.Да,
Animation
расширяет классListenable
, это верно. Но такое решение позволяет использоватьAnimatedBuilder
для подписки части верстки на тот или инойChangeNotifier
,ValueNotifier
и так далее. Это неявно из именования, но команда фреймворка дала нам эту возможность, о чём явно указала в документации.
Вот мы и разобрали основы работы с явными анимациями в Flutter. Теперь рассмотрим более сложный кейс применения анимаций.
Интервальные анимации
Не всегда анимации — это одновременное изменение тех или иных значений. Бывают случаи, когда нам надо изменять несколько разных значений последовательно или с накладыванием друг на друга (а можем быть и с паузами между) — управляя их интервалами.
Например, нам может понадобится:
- При клике на кнопку плавно сменить цвет.
- Через 50мс после клика начать менять форму расширяя ее по ширине.
- После окончания расширения повернуть текст на кнопке.
Этот пример «из головы», но он демонстрирует, что у нас есть три анимации (цвет, ширина, поворот) для трёх виджетов, и эти анимации распределены по шкале времени.
Это и называется в терминах Flutter — Staggered Animations
. Данный термин был взят из официальной документации. Мы в тексте будем использовать термин «интервальные анимации
» как наиболее близкий по смыслу и применению перевод.
Давайте сразу рассмотрим пример.
Анализ требований
Наш пример будет проще, чем тот, с которого мы начинали параграф — чтобы вы могли сконцентрироваться на основном подходе.
Итак, у нас есть кнопка, при нажатии она должна свернуться в течении 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.
1class _ButtonState extends State<_Button>
2 with SingleTickerProviderStateMixin<_Button> {
3 late final AnimationController _scaleController = AnimationController(
4 vsync: this,
5 duration: const Duration(
6 milliseconds: **600**,
7 ),
8 );
9
10 // здесь будут Tween
11
12 @override
13 void dispose() {
14 _scaleController.dispose();
15 super.dispose();
16 }
17
18 // TODO: дальше будем описывать логику и билд
19}
Далее опишем два 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
, который позволяет добавить к нему кривую на конкретное значение интервала. Это отличный инструмент, который позволяет разнообразит поведение. Опробуйте его сами в дартпаде ниже.
Код
1class _ButtonState extends State<_Button>
2 with SingleTickerProviderStateMixin<_Button> {
3 late final AnimationController _scaleController = AnimationController(
4 vsync: this,
5 duration: const Duration(
6 milliseconds: 600,
7 ),
8 );
9
10 late final Animation<double> **_hide** = Tween<double>(
11 begin: 1,
12 end: 0,
13 ).animate(
14 **CurvedAnimation(
15 parent: _scaleController,
16 curve: const Interval(
17 0, // точка старта анимации
18 0.5, // конец анимации == 300мс
19 ),
20 ),**
21 );
22
23 // ...
24}
Собственно, для анимации показа будет примерно так же, только интервал будет уже от 0.5 до 1.
Код
1class _ButtonState extends State<_Button>
2 with SingleTickerProviderStateMixin<_Button> {
3 // ...
4
5 late final Animation<double> _hide = Tween<double>(
6 begin: 1,
7 end: 0,
8 ).animate(
9 CurvedAnimation(
10 parent: _scaleController,
11 curve: const Interval(
12 0,
13 0.5,
14 ),
15 ),
16 );
17
18 late final Animation<double> _show = Tween<double>(
19 begin: 0,
20 end: 1,
21 ).animate(
22 CurvedAnimation(
23 parent: _scaleController,
24 curve: const Interval(
25 0.5,
26 1,
27 ),
28 ),
29 );
30
31 // ...
32}
Отлично, мы описали наши анимации! Но теперь (до того как мы опишем саму верстку) нам надо учесть один момент из условия: по нажатию на кнопку анимация запускается, по нажатию на лоадер — запускается в обратном направлении.
Для этого мы введём дополнительный стейт _isLoading
(равен true
, когда видим индикатор загрузки), а также напишем метод _handleTap
, который будет вызвать forward
или reverse
в зависимости от значения _isLoading
.
Код
1class _ButtonState extends State<_Button>
2 with SingleTickerProviderStateMixin<_Button> {
3 // ...
4 late final Animation<double> _show = Tween<double>(
5 begin: 0,
6 end: 1,
7 ).animate(
8 CurvedAnimation(
9 parent: _scaleController,
10 curve: const Interval(
11 0.5,
12 1,
13 ),
14 ),
15 );
16
17 **bool _isLoading = false;**
18 // ...
19
20 **void _handleTap() {
21 if (_isLoading) {
22 _scaleController.reverse();
23 } else {
24 _scaleController.forward();
25 }
26
27 setState(() {
28 _isLoading = !_isLoading;
29 });
30 }**
31}
Здесь также видим, что при нажатии мы изменяем флаг _isLoading
на противоположное значение.
Осталось собрать всё вместе. И тут вопрос: а какой способ для соединения анимации и виджетов мы будем использовать? У нас их три, мы можем:
- Добавить слушателя на контроллер.
- Сделать наследника
AnimatedWidget
и вынести туда часть вёрстки. - Использовать
AnimatedBuilder
внутри текущего класса.
В нашем случае самым удобным будет AnimatedBuilder
, потому что у нашего виджета есть не только состояние анимации, но и состояние загрузки, и AnimatedBuilder
позволяет не делать избыточных классов в данном случае.
Код
1class _ButtonState extends State<_Button>
2 with SingleTickerProviderStateMixin<_Button> {
3 // ...
4
5 @override
6 Widget build(BuildContext context) {
7 return Stack(
8 children: [
9 Center(
10 child: **AnimatedBuilder(
11 animation: _show,
12 child: Center(
13 child: GestureDetector(
14 onTap: _handleTap,
15 child: const _Loader(),
16 ),
17 ),
18 builder: (context, child) {
19 return Transform.scale(
20 scale: _show.value,
21 child: child,
22 );
23 },
24 ),**
25 ),
26 Center(
27 child: SizedBox(
28 width: 250,
29 height: 56,
30 child: **AnimatedBuilder(
31 animation: _hide,
32 child: ElevatedButton(
33 style: ElevatedButton.styleFrom(
34 backgroundColor: Colors.amber,
35 ),
36 onPressed: _handleTap,
37 child: const Center(child: Text('TAP ME')),
38 ),
39 builder: (context, child) {
40 return Transform.scale(
41 scale: _hide.value,
42 child: child,
43 );
44 },
45 ),**
46 ),
47 ),
48 ],
49 );
50 }
51
52 // ...
53}
Вот так мы собрали последовательность двух анимаций.
Таких последовательностей может сколь угодно много, интервалы могут перекрывать друг друга, запускаться одновременно и так далее. Если вам интересно попрактиковаться самому — попробуйте поиграться со значениями интервалов и посмотрите, как это отразится на самих анимациях.
Например, сделать отрезки (0; 0,75) и (0,25; 1) или (0; 0,25) и (0,75; 1). Попробуйте также начать не с нуля или закончить не единицей, а значением меньше. Это интересное небольшое самостоятельное исследование, которое поможет вам лучше проникнуться данным инструментом.
Самостоятельная работа
Чтобы закрепить материал и попрактиковаться, рекомендуем выполнить следующее задание.
Есть анимация, которая представляет собой эффект перелистывания карточек, когда мы нажимаем на экран.
Реализуйте ее.
Легко? Тогда попробуйте завязать перелистывание на движение пальцем.
Отлично, вот мы и разобрали анимации во Fluttrer на практике.
Конечно, охватить всё в одной теме невозможно, поэтому мы оставили несколько интересных вещей, таких как Simulation или управление анимаций на основе движения пальца. Предлагаем изучить их самостоятельно.
Напоследок скажем ещё кое-что: работа с анимациями — это творчество, и различных эффектов можно добиться различными способами. Не бойтесь экспериментировать, подстраивать, настраивать — результат того стоит.
А в следующем параграфе вы научитесь работать с графикой во Flutter и рисовать различные фигуры — с помощью инструментов Canvas
и CustomPainter
.