3.7. Анимации: практика

В предыдущем параграфе мы рассмотрели явные анимации и их составные части:

  • 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 на определённом отрезке шкалы контроллера. Но её применение мы рассмотрим чуть позже.

Нашу анимацию можно разделить на четыре этапа:

  1. Пауза (чтобы пользователь сперва рассмотрел элементы на экране)
  2. Перемещение с 0 до 5
  3. Перемещение 5 → -5
  4. Перемещение -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 для подписки части верстки на тот или иной ChangeNotifierValueNotifier и так далее. Это неявно из именования, но команда фреймворка дала нам эту возможность, о чём явно указала в документации.

Вот мы и разобрали основы работы с явными анимациями в Flutter. Теперь рассмотрим более сложный кейс применения анимаций.

Интервальные анимации

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

Например, нам может понадобится:

  1. При клике на кнопку плавно сменить цвет.
  2. Через 50мс после клика начать менять форму расширяя ее по ширине.
  3. После окончания расширения повернуть текст на кнопке.

Этот пример «из головы», но он демонстрирует, что у нас есть три анимации (цвет, ширина, поворот) для трёх виджетов, и эти анимации распределены по шкале времени.

Это и называется в терминах Flutter — Staggered Animations. Данный термин был взят из официальной документации. Мы в тексте будем использовать термин «интервальные анимации» как наиболее близкий по смыслу и применению перевод.

Давайте сразу рассмотрим пример.

Анализ требований

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

Итак, у нас есть кнопка, при нажатии она должна свернуться в течении 300мс, после этого должен «развернуться» лоадер, так же в течении 300мс. При нажатии на лоадер происходит смена в обратном порядке. Под сворачиванием/разворачиванием мы тут будем понимать изменение масштаба — scale.

Как нам реализовать подобный кейс? Помните, когда говорили про TweenSequence, мы упоминали инструмент под названием Interval? Вот здесь как раз он нам и поможет.

Но сперва коротко расскажем, что такое Interval.

Interval — это специальная реализация Curve. Она которая позволяет задать отрезок времени, на котором значение данной кривой будет изменяться по заданному закону.

Сам интервал мы задаем в долях. То есть, если у нас общее время анимации — 1000мс, а мы хотим запустить конкретную анимацию на интервале с 200мс до 800мс, то интервал будет определен значениями (0.2; 0.8).

Любой Interval также принимает в свой конструктор Curve, что позволяет комбинировать Curve и Interval.

Почему мы предлагаем использовать именно Interval, а не TweenSequenceTweenSequence удобно использовать, когда у нас есть последовательные изменения одного свойства или одного объекта (виджета). В нашем случае их два, поэтому тут в дело врывается 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).

flutter

Как уже говорилось, 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 на противоположное значение.

Осталось собрать всё вместе. И тут вопрос: а какой способ для соединения анимации и виджетов мы будем использовать? У нас их три, мы можем:

  1. Добавить слушателя на контроллер.
  2. Сделать наследника AnimatedWidget и вынести туда часть вёрстки.
  3. Использовать 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.

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

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

Предыдущий параграф3.6. Анимации: явные анимации
Следующий параграф3.8. CustomPainter: работа с графикой