В этом параграфе мы поговорим об анимациях: как они создаются во Flutter, какие характеристики у них бывают и как ими управлять.
Но прежде чем перейти к практике — немного теории: поговорим о том, для чего вообще нужны анимации в приложении.
Роли анимации
Вот базовые сценарии, где стоит применять анимацию:
Визуальная обратная связь. Пользователь получает ожидаемый визуальный отклик после взаимодействия с элементом приложения. Например, кнопка при нажатии немного меняет свой размер, чтобы дать понять пользователю, что его нажатие зафиксировано.
Функциональное изменение. Элемент меняется после действия пользователя. Например, при добавлении товара в корзину анимируем корзину, чтобы пользователь обратил на неё внимание и знал, куда нужно зайти для оформления заказа.
Анимация состояния системы. Используется для длительных процессов, чтобы пользователь понимал, что система в этот момент не зависла. Один из популярных примеров — прогресс-бар при загрузке файлов.
Большую часть этих эффектов можно реализовать без анимации, но именно хорошо продуманная анимация может не только сделать приложение более красивым, но и облегчить восприятие происходящих изменений на экране.
Теперь поговорим, как создаются анимации во Flutter.
Типы анимации в Flutter
Во Flutter существует два основных способа создания анимаций:
- Implicit (неявные анимации) — для плавных переходов между различными состояниями виджета. Эти анимации воспроизводятся автоматически, требуя минимальной настройки и поддержки. Всё реализовано за нас, нам остаётся только использовать их.
- Explicit (явные анимации) — те, которыми разработчики управляют самостоятельно, задавая поведение и свойства. У таких анимаций нужно определять жизненный цикл, начальные и конечные точки. У нас есть полный контроль над ними.
Выбор способа зависит от задачи. В большинстве случаев вы будете работать с неявными анимациями. И только когда столкнётесь с более комплексными и сложными сценариями (например, нужна возможность проиграть вашу анимацию назад), вам пригодятся явные: они дают больше гибкости и контроля.
Вот подробная шпаргалка по выбору анимации от команды Flutter:
💡 Важно: Далее мы будем рассматривать только неявные анимации и их виджеты (выделены жёлтым на графике). О явных анимациях — в следующем параграфе.
Какие виджеты для анимации Flutter предоставляет нам из коробки
Как уже упоминали выше, Flutter из коробки предоставляет ряд неявно анимированных виджетов. Обычно они называются AnimatedFoo, где Foo — это имя неанимированной версии этого виджета. Вот часть из тех, которые вам могут пригодиться:
- AnimatedAlign — версия Align;
- AnimatedContainer — версия Container;
- AnimatedDefaultTextStyle — версия DefaultTextStyle;
- AnimatedTheme — версия Theme;
- больше примеров — в официальной документации.
Анимирование готовых виджетов происходит так:
- Выбираем виджет по свойству, которое хотим анимировать. Например, если хотим анимировать позиционирование элемента на экране, то нужно выбрать виджет AnimatedAlign.
- Выбираем значения, по которым пройдёт анимация. Например, элемент был сверху экрана, а мы хотим, чтобы он оказался внизу. Значит, нужно выбрать смену значения свойства
alignment
сtop
наbottom
. - Запускаем анимацию, изменяя это свойство. Например, по клику на кнопку.
Все эти шаги рассмотрим далее на примере. А пока давайте посмотрим, чем AnimatedAlign отличается от «обычного» Align:
Align({
Key? key,
AlignmentGeometry alignment = Alignment.center,
double? widthFactor,
double? heightFactor,
Widget? child,
})
AnimatedAlign({
Key? key,
required AlignmentGeometry alignment,
double? widthFactor,
double? heightFactor,
Widget? child,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
})
В AnimatedAlign появились несколько новых полей, Curve curve = Curves.linear, required Duration duration, VoidCallback? onEnd. В данном примере рассмотрим только обязательные поля, а остальные — чуть позже.
Чтобы элемент мог плавно двигаться по экрану, необходимо определить всего два обязательных поля:
- duration — определяет, сколько будет длиться наша анимация;
- alignment — расположение виджета. Мы будем двигать виджет по вертикали.
С полями разобрались, но каким образом мы можем подставлять разные значения, между которыми будет анимироваться alignment? Для этого добавим переменную selected и будем менять в ней значение при клике на кнопку с помощью setState.
В итоге получим следующий код:
Вот так, меняя всего один параметр, мы смогли добиться плавного перехода виджета из одного состояния в другое. Но можем ли мы как-то ещё повлиять на этот переход?
Конечно можем, для этого нам необходимо погрузиться чуть глубже в устройство AnimatedFoo-виджетов.
Из чего состоят неявно анимируемые виджеты
Во Flutter подавляющее большинство неявных анимаций реализовано с помощью ImplicitlyAnimatedWidget
. Это тип виджетов, которые автоматически анимируют изменения в своих свойствах. Рассмотрим его поближе.
Этот виджет — абстрактный класс, который принимает три параметра:
const ImplicitlyAnimatedWidget({
super.key,
this.curve = Curves.linear,
required this.duration,
this.onEnd,
});
Как видите, это именно те параметры, которые добавляются в AnimatedFoo-виджеты. С duration мы уже знакомы, но за что отвечают остальные?
- onEnd — функция, которая сработает в момент, когда анимация завершится. Она может быть полезна для запуска действия (например, другой анимации) в конце текущей анимации.
- curve — используются для регулировки скорости изменения анимации с течением времени, позволяя ей ускоряться и замедляться, а не меняться с постоянной скоростью.
Curve как раз и поможет нам изменить поведение анимации, поэтому рассмотрим её подробней.
Curve — добавим немного математики
Значение Curve определяет тип кривой, которая отражает зависимость прогресса анимации от времени.
Да, это звучит сложно, но сейчас всё станет понятно — тут пригодятся школьные знания математики.
Представьте систему координат с интервалом [0,0; 1,1], где ось X отражает время, ось Y — прогресс анимации. Если провести линию из точки [0; 0] в точку [1; 1], то получится линейная анимация.
Вот так:
Во Flutter по умолчанию всегда ставится Curves.linear
. Но такая анимация выглядит неестественно, поскольку в жизни все объекты двигаются нелинейно, у них есть отрезки замедления и ускорения.
Как можно это изменить? Правильно, «искривить» линию на графике (отсюда и название Curves — «кривая»).
Посмотрите, как оживляется анимация с другим значением Curves — например, easeIn
. Данная кривая позволяет нашей анимации начинаться медленно и ускоряться к завершению.
Если сравнить две эти кривые рядом, то можно сразу заметить разницу в работе анимации.
Вот так, меняя всего один параметр, мы получаем совершенно другой визуальный эффект для нашей анимации.
Мы разобрали только два значения Curves, полный список вы сможете найти в документации.
Кастомные Curves
Хоть Flutter и предоставляет множество уже реализованных Curve, но не всегда они отвечают требованиям заказчиков. Поэтому у нас есть возможность реализовать свои вариации Curve.
Так мы и сделаем — только, чтобы не углубляться в сложные математические вычисления, напишем аналог уже знакомой вам Curves.linear
.
Как мы уже упомянули ранее, кривой во Flutter может быть любое отображение функции за период времени t
от 0.0
до 1.0
— это функция, f(t)
которая занимает некоторое время t
и выводит значение.
Однако мы должны соблюдать условия, согласно которым, когда t = 0.0
тогда функция должна выводить 0.0
, и когда t = 1.0
функция должна выводить 1.0
. В качестве примера предположим, что у нас есть обычная линейная функция:
Это прямая линия с константами k и b, а функция определяется вводом t
. Давайте сделаем эту функцию ещё более простой и определим значение 0
для b и 1
для k.
Давайте теперь реализуем её в Flutter. Для этого необходимо расширить класс Curve и переопределить его метод transformInternal
. В него мы переносим нашу функцию и получаем желаемый результат.
@override
double transformInternal(double t) => t;
Если в вашем приложении нужны анимации, требующие особого поведения, то знание того, как создать свою собственную кривую, будет очень полезно.
Когда бывает полезен onEnd
Выше мы сказали, что метод onEnd
полезен для запуска действия в конце текущей анимации.
В данном примере мы можем видеть, как один блок будто толкает другой. Такого эффекта мы смогли добиться благодаря тому, что поставили разные Curves блокам.
Синий блок ускоряется под конец анимации с помощью Curves.easeIn
, а красный, наоборот, замедляется с помощью Curves.easeOut
. Создаётся эффект, будто один блок толкает другой.
Когда onEnd лучше не применять
Часто нашу анимацию необходимо воспроизвести несколько раз, и у вас может возникнуть соблазн воспользоваться onEnd
, чтобы на завершение анимации снова менять состояние виджета и повторно анимировать его свойства.
Это сработает, но как теперь завершить эту анимацию? Добавлять новые флаги в функцию? Придумывать какие-то костыли? Этого делать не стоит, такой подход может принести много вреда.
Для подобных сценариев используйте явные анимации, над которыми вы имеете полный контроль. Рассмотрим их в следующем параграфе.
Какие ещё ImplicitlyAnimatedWidget
предоставляет Flutter
Есть ряд виджетов, которые имеют отличающуюся от ImplicitlyAnimatedWidget
реализацию, но также относятся к неявным анимациям.
Например, AnimatedCrossFade: он плавно переходит между двумя заданными дочерними элементами и анимируется между их размерами. Этот тип анимации полезен, когда при изменении состояния приложения необходимо менять один виджет на другой.
Но что делать, если у нас может быть больше, чем два виджета? В таком случае на помощь приходит виджет AnimatedSwitcher. Он может анимироваться между любым количеством элементов, но есть нюанс.
Его главное ограничение в том, что если вы осуществляете переход между списком виджетов с одинаковым типом, то им обязательно нужно указать Keys, иначе анимация не заработает. Так происходит, потому что при воспроизведении анимации виджеты сначала сравниваются по типу, а потом по ключам. И если не проставить ключи, то Flutter посчитает, что ваш виджет не изменился и его не нужно анимировать. Подробнее о ключах мы рассказывали в параграфе Widgets: keys.
Как быть, если ни один из уже реализованных виджетов не подошёл
Круто, что Flutter предоставляет так много разных реализованных анимационных виджетов, которые могут пригодиться в разных сценариях. Но что делать, если ни один из них нам не подходит? На помощь может прийти TweenAnimationBuilder.
TweenAnimationBuilder позволяет анимировать любое свойство виджета.
const TweenAnimationBuilder({
Key? key,
required this.tween,
required Duration duration,
Curve curve = Curves.linear,
required this.builder,
VoidCallback? onEnd,
this.child,
})
Данный виджет также наследуется от ImplicitlyAnimatedWidget
, но можно заметить, что здесь добавились два новых основных параметра tween
и builder
.
Builder — создадим наш виджет
Необходим для перерисовки нашего виджета на каждый тик анимации. Именно здесь мы будем создавать нужный нам виджет и анимировать его.
Билдер принимает функцию с параметрами, которая в итоге должна вернуть нам Widget.
typedef ValueWidgetBuilder<T> = Widget Function(BuildContext context, T value, Widget? child);
Рассмотрим параметры подробнее:
- Value — текущее значение анимации из параметра
tween
классаTween
, с которым познакомимся ниже. - child — необходим для оптимизации. Если нам на каждый кадр необходимо будет создавать какой-то сложный виджет, например, картинку с множеством фильтров, то нам в этом поможет параметр child. Если он передаётся в TweenAnimationBuilder, то он создаётся один раз и потом его закешированная версия используется на каждый тик анимации. Поэтому порой этот параметр может сильно улучшить производительность вашей анимации.
Tween — расширяем границы
Tween — это одна из наиболее важных концепций в анимации Flutter, которая используется во всех анимациях наравне с Curves. В уже реализованных виджетах точно так же используется Tween, только он скрыт внутри виджета, поэтому при использовании мы о нём не задумывались.
Так что же это такое? Если коротко, то Tween
— это интерполяция между двумя значениями одного типа. Например, мы можем использовать Tween<double>
для интерполяции между двумя значениями типа double. Tween определяет начальное и конечное значения анимации, а фреймворк обеспечивает интерполяцию между этими значениями с течением времени.
Проще говоря, Tween дает нам промежуточные значения между двумя значениями, такими как цвета, целые числа, выравнивания и почти всё, что вы можете придумать. Сейчас мы познакомимся лишь с некоторыми его функциями, а подробней рассмотрим Tween в следующей главе.
Давайте рассмотрим работу Tween на примере. Представьте, что вам необходимо изменить цвет контейнера с одного на другой. Но если мы просто сделаем резкий переход между двумя цветами, то он может неприятно броситься в глаза:
Исправить такое поведение мы можем с помощью плавного перехода. Однако самостоятельно мы не сможем подставлять все оттенки, которые находятся между двумя выбранными цветами.
В таких случаях мы создаём ColorTween — он и даст все промежуточные значения между синим и красным цветом, чтобы мы могли их отобразить.
Поскольку TweenAnimationBuilder также наследуется от ImplicitlyAnimatedWidget
, то и его использование очень схоже с AnimatedFoo-виджетами. Только теперь вместо каких-то значений в виджетах мы меняем значение в Tween, которое передаём в TweenAnimationBuilder.
Важно отметить, что нам необходимо менять именно параметр end
у Tween, поскольку анимация проходит между текущим состоянием и конечным. Если мы меняем значение в start
, то ничего не произойдёт.
Теперь анимация выглядит гораздо лучше! При этом мы не задумывались о том, как получить все промежуточные значения между двумя разными цветами. За нас всё сделал Tween.
Сделаем пример поинтересней: немного усложним нашу анимацию с использованием TweenAnimationBuilder. Реализуем анимацию, которая переворачивает наш виджет обратной стороной, при этом стороны у него будут разными.
Первое, что необходимо реализовать, — это поворот виджета по горизонтальной оси Y. Сделаем для этого виджет RotationY, который будет поворачивать переданный в него child на заданное количество градусов. Для непосредственно поворота будем использовать виджет Transform()
.
class RotationY extends StatelessWidget {
static const double _degrees2Radians = pi / 180;
final Widget child;
final double rotationY;
const RotationY({
required this.child,
this.rotationY = 0,
super.key,
});
@override
Widget build(BuildContext context) {
return Transform(
alignment: FractionalOffset.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001) // Магические цифры
..rotateY(rotationY * _degrees2Radians),
child: child,
);
}
}
Теперь мы можем легко поворачивать что угодно, просто обернув это в RotationY()
:
Теперь, когда мы научились вращать виджет, можно добавлять анимацию. Для этого создадим Tween, который будет принимать значения от 0 до 180 — количество градусов, на которое необходимо повернуть наш виджет.
Чтобы мы могли развернуть виджет обратно, необходимо в Tween менять значение end между 0 и 180. Это позволит сначала повернуть виджет на 180 градусов, а потом вернуть его в начальное состояние. Для переключения между двумя значениями заведём переменную _isFlipped, в которой будем указывать, перевёрнут наш виджет или нет.
Наша анимация переворота готова. Теперь необходимо сделать разные стороны для карточки. Для этого мы можем делать подмену вращаемого виджета в момент, когда он поворачивается на 90 градусов.
Теперь стороны разные, но виджет на обратной стороне повёрнут к нам зеркально. Чтобы это исправить, необходимо дополнительно разворачивать в обратную сторону виджет, который будет у нас сзади.
import 'package:flutter/material.dart';
import 'dart:math';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const Scaffold(
backgroundColor: Color(0xFFd9e2fc),
body: Center(
child: AnimatedFlipWrapper(),
),
),
);
}
}
class AnimatedFlip extends StatelessWidget {
final Widget front;
final Widget back;
final VoidCallback onTap;
final bool isFlipped;
const AnimatedFlip({
required this.front,
required this.back,
required this.onTap,
required this.isFlipped,
super.key,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: TweenAnimationBuilder(
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
tween: Tween<double>(
begin: 0,
end: isFlipped ? 180 : 0,
),
builder: (context, value, child) {
final content = value < 90
? front
: RotationY(
rotationY: 180,
child: back,
);
return RotationY(
rotationY: value,
child: content,
);
},
),
);
}
}
class AnimatedFlipWrapper extends StatefulWidget {
const AnimatedFlipWrapper({super.key});
@override
State<AnimatedFlipWrapper> createState() => _AnimatedFlipWrapperState();
}
class _AnimatedFlipWrapperState extends State<AnimatedFlipWrapper> {
bool isFlipped = false;
@override
Widget build(BuildContext context) {
return AnimatedFlip(
front: const Card(
color: Colors.red,
child: SizedBox(
width: 150,
height: 150,
),
),
back: const Card(
color: Colors.blue,
child: SizedBox(
width: 150,
height: 150,
child: Center(
child: Text('Яндекс Образование'),
),
),
),
isFlipped: isFlipped,
onTap: () {
setState(() {
isFlipped = !isFlipped;
});
},
);
}
}
class RotationY extends StatelessWidget {
static const double _degrees2Radians = pi / 180;
final Widget child;
final double rotationY;
const RotationY({
required this.child,
this.rotationY = 0,
super.key,
});
@override
Widget build(BuildContext context) {
return Transform(
alignment: FractionalOffset.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(rotationY * _degrees2Radians),
child: child,
);
}
}
Теперь всё выглядит так, как мы и задумывали.
Вот и всё! В этой главе мы познакомились с основными виджетами анимации Flutter, а также коснулись устройства Curves и Tween. Рассмотрели, как можно делать свои анимации с помощью TweenAnimationBuilder.
В следующей главе мы разберём анимации глубже: разберёмся в нюансах, скрытых от нас за уже реализованными виджетами, а также узнаем о трёх слонах и черепахе в мире анимации.
И эти знания помогут нам не только пользоваться готовыми виджетами, но и создавать свои уникальные анимации!