В этом параграфе мы поговорим об анимациях: как они создаются во 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.
В итоге получим следующий код:
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const AnimatedAlignmentExampleWrapper(),
);
}
}
class AnimatedAlignmentExample extends StatelessWidget {
final bool selected;
const AnimatedAlignmentExample({required this.selected, super.key});
@override
Widget build(BuildContext context) {
return AnimatedAlign(
alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
duration: const Duration(seconds: 1),
child: GestureDetector(
child: const Card(
color: Colors.blue,
child: SizedBox(
width: 100.0,
height: 100.0,
)),
),
);
}
}
class AnimatedAlignmentExampleWrapper extends StatefulWidget {
const AnimatedAlignmentExampleWrapper({super.key});
@override
State<AnimatedAlignmentExampleWrapper> createState() =>
_AnimatedAlignmentExampleWrapperState();
}
class _AnimatedAlignmentExampleWrapperState
extends State<AnimatedAlignmentExampleWrapper> {
bool selected = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(
height: 24,
),
Expanded(
child: Stack(
children: [
AnimatedAlignmentExample(
selected: selected,
)
],
),
),
const SizedBox(
height: 24,
),
ElevatedButton(
onPressed: () {
setState(() {
selected = !selected;
});
},
child: const Text('Анимировать'),
),
const SizedBox(
height: 24,
),
],
),
),
);
}
}
Вот так, меняя всего один параметр, мы смогли добиться плавного перехода виджета из одного состояния в другое. Но можем ли мы как-то ещё повлиять на этот переход?
Конечно можем, для этого нам необходимо погрузиться чуть глубже в устройство 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
. Данная кривая позволяет нашей анимации начинаться медленно и ускоряться к завершению.
Если сравнить две эти кривые рядом, то можно сразу заметить разницу в работе анимации.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const CurvesExampleWrapper(),
);
}
}
class CurvesExample extends StatelessWidget {
final bool selected;
final Color color;
final Curve curve;
const CurvesExample({
required this.selected,
required this.color,
required this.curve,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedAlign(
alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
duration: const Duration(seconds: 1),
curve: curve,
child: GestureDetector(
child: Card(
color: color,
child: const SizedBox(
width: 100.0,
height: 100.0,
)),
),
);
}
}
class CurvesExampleWrapper extends StatefulWidget {
const CurvesExampleWrapper({super.key});
@override
State<CurvesExampleWrapper> createState() => _CurvesExampleWrapperState();
}
class _CurvesExampleWrapperState extends State<CurvesExampleWrapper> {
bool selected = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(
height: 24,
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 116.0),
child: CurvesExample(
selected: selected,
curve: Curves.linear,
color: Colors.blue,
),
),
Padding(
padding: const EdgeInsets.only(left: 116.0),
child: CurvesExample(
selected: selected,
curve: Curves.easeIn,
color: Colors.red,
),
),
],
),
),
const SizedBox(
height: 24,
),
ElevatedButton(
onPressed: () {
setState(() {
selected = !selected;
});
},
child: const Text('Анимировать'),
),
const SizedBox(
height: 24,
),
],
),
),
);
}
}
Вот так, меняя всего один параметр, мы получаем совершенно другой визуальный эффект для нашей анимации.
Мы разобрали только два значения 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
. В качестве примера предположим, что у нас есть обычная линейная функция:
$$f (t) = kt + b$$
Это прямая линия с константами k и b, а функция определяется вводом t
. Давайте сделаем эту функцию ещё более простой и определим значение 0
для b и 1
для k.
$$f(t) = 1t + 0 = t$$
Давайте теперь реализуем её в Flutter. Для этого необходимо расширить класс Curve и переопределить его метод transformInternal
. В него мы переносим нашу функцию и получаем желаемый результат.
@override
double transformInternal(double t) => t;
Если в вашем приложении нужны анимации, требующие особого поведения, то знание того, как создать свою собственную кривую, будет очень полезно.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const CurvesExampleWrapper(),
);
}
}
class CurvesExample extends StatelessWidget {
final bool selected;
final Color color;
final Curve curve;
const CurvesExample({
required this.selected,
required this.color,
required this.curve,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedAlign(
alignment: selected ? Alignment.bottomCenter : Alignment.topCenter,
duration: const Duration(seconds: 1),
curve: curve,
child: GestureDetector(
child: Card(
color: color,
child: const SizedBox(
width: 100.0,
height: 100.0,
),
),
),
);
}
}
class CurvesExampleWrapper extends StatefulWidget {
const CurvesExampleWrapper({super.key});
@override
State<CurvesExampleWrapper> createState() => _CurvesExampleWrapperState();
}
class _CurvesExampleWrapperState extends State<CurvesExampleWrapper> {
bool selected = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(
height: 24,
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(right: 116.0),
child: CurvesExample(
selected: selected,
curve: Curves.linear, // Платформенная реализация Curves.linear
color: Colors.blue,
),
),
Padding(
padding: const EdgeInsets.only(left: 116.0),
child: CurvesExample(
selected: selected,
curve: MyLinearCurve(), // Наша реализация Curves.linear
color: Colors.red,
),
),
],
),
),
const SizedBox(
height: 24,
),
ElevatedButton(
onPressed: () {
setState(() {
selected = !selected;
});
},
child: const Text('Анимировать'),
),
const SizedBox(
height: 24,
),
],
),
),
);
}
}
// Созданная нами Curves.linear
class MyLinearCurve extends Curve {
@override
double transformInternal(double t) => t;
}
Когда бывает полезен onEnd
Выше мы сказали, что метод onEnd
полезен для запуска действия в конце текущей анимации.
В данном примере мы можем видеть, как один блок будто толкает другой. Такого эффекта мы смогли добиться благодаря тому, что поставили разные Curves блокам.
Синий блок ускоряется под конец анимации с помощью Curves.easeIn
, а красный, наоборот, замедляется с помощью Curves.easeOut
. Создаётся эффект, будто один блок толкает другой.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const OnEndExampleWrapper(),
);
}
}
class OnEndExample extends StatelessWidget {
final Color color;
final Curve curve;
final Alignment alignment;
final VoidCallback onEnd;
const OnEndExample({
required this.color,
required this.curve,
required this.onEnd,
required this.alignment,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedAlign(
alignment: alignment,
duration: const Duration(seconds: 1),
curve: curve,
onEnd: onEnd,
child: GestureDetector(
child: Card(
color: color,
child: const SizedBox(
width: 100.0,
height: 100.0,
)),
),
);
}
}
class OnEndExampleWrapper extends StatefulWidget {
const OnEndExampleWrapper({super.key});
@override
State<OnEndExampleWrapper> createState() => _OnEndExampleWrapperState();
}
class _OnEndExampleWrapperState extends State<OnEndExampleWrapper> {
bool firstAnimate = false;
bool secondAnimate = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(
height: 24,
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 100.0),
child: OnEndExample(
curve: Curves.easeIn,
color: Colors.blue,
alignment: firstAnimate
? Alignment.center
: Alignment.bottomCenter,
onEnd: () {
setState(() {
if (firstAnimate == true) {
secondAnimate = true;
}
});
},
),
),
Padding(
padding: const EdgeInsets.only(bottom: 100.0),
child: OnEndExample(
curve: Curves.easeOut,
color: Colors.red,
alignment: secondAnimate
? Alignment.topCenter
: Alignment.center,
onEnd: () {},
),
),
],
),
),
const SizedBox(
height: 24,
),
ElevatedButton(
onPressed: () {
setState(() {
firstAnimate = !firstAnimate;
if (secondAnimate == true) {
secondAnimate = false;
}
});
},
child: const Text('Анимировать'),
),
const SizedBox(
height: 24,
),
],
),
),
);
}
}
Когда onEnd лучше не применять
Часто нашу анимацию необходимо воспроизвести несколько раз, и у вас может возникнуть соблазн воспользоваться onEnd
, чтобы на завершение анимации снова менять состояние виджета и повторно анимировать его свойства.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const OnEndExampleWrapper(),
);
}
}
class OnEndExample extends StatelessWidget {
final Color color;
final Curve curve;
final Alignment alignment;
final VoidCallback onEnd;
const OnEndExample({
required this.color,
required this.curve,
required this.onEnd,
required this.alignment,
super.key,
});
@override
Widget build(BuildContext context) {
return AnimatedAlign(
alignment: alignment,
duration: const Duration(seconds: 1),
curve: curve,
onEnd: onEnd,
child: GestureDetector(
child: Card(
color: color,
child: const SizedBox(
width: 100.0,
height: 100.0,
),
),
),
);
}
}
class OnEndExampleWrapper extends StatefulWidget {
const OnEndExampleWrapper({super.key});
@override
State<OnEndExampleWrapper> createState() => _OnEndExampleWrapperState();
}
class _OnEndExampleWrapperState extends State<OnEndExampleWrapper> {
bool firstAnimate = false;
bool secondAnimate = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
const SizedBox(
height: 24,
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 100.0),
child: OnEndExample(
curve: Curves.linear,
color: Colors.blue,
alignment: firstAnimate
? Alignment.center
: Alignment.bottomCenter,
onEnd: () {
setState(() {
if (firstAnimate == true) {
secondAnimate = true;
return;
}
firstAnimate = true;
});
},
),
),
Padding(
padding: const EdgeInsets.only(bottom: 100.0),
child: OnEndExample(
curve: Curves.linear,
color: Colors.red,
alignment: secondAnimate
? Alignment.topCenter
: Alignment.center,
onEnd: () {
setState(() {
if (secondAnimate == false) {
firstAnimate = false;
return;
}
secondAnimate = false;
});
},
),
),
],
),
),
const SizedBox(
height: 24,
),
ElevatedButton(
onPressed: () {
setState(() {
firstAnimate = !firstAnimate;
});
},
child: const Text('Анимировать'),
),
const SizedBox(
height: 24,
),
],
),
),
);
}
}
Это сработает, но как теперь завершить эту анимацию? Добавлять новые флаги в функцию? Придумывать какие-то костыли? Этого делать не стоит, такой подход может принести много вреда.
Для подобных сценариев используйте явные анимации, над которыми вы имеете полный контроль. Рассмотрим их в следующем параграфе.
Какие ещё 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
, то ничего не произойдёт.
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: const Scaffold(
body: Center(
child: ColorTweenExample(),
),
),
);
}
}
class ColorTweenExample extends StatefulWidget {
const ColorTweenExample({super.key});
@override
State<ColorTweenExample> createState() => _ColorTweenExampleState();
}
class _ColorTweenExampleState extends State<ColorTweenExample> {
bool isSelected = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
isSelected = !isSelected;
});
},
child: TweenAnimationBuilder(
tween: ColorTween(
begin: Colors.blue,
end: isSelected ? Colors.blue : Colors.red,
),
duration: const Duration(milliseconds: 700),
builder: (context, value, child) => Card(
color: value,
child: const SizedBox(
height: 150,
width: 150,
child: Center(
child: Text('Анимировать'),
),
),
),
),
);
}
}
Теперь анимация выглядит гораздо лучше! При этом мы не задумывались о том, как получить все промежуточные значения между двумя разными цветами. За нас всё сделал 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()
:
RotationY(
rotationY: 45, // поворот на 45 градусов относительно оси Y
child: Card(
child: SizedBox(
width: 250,
height: 250,
),
),
);
Теперь, когда мы научились вращать виджет, можно добавлять анимацию. Для этого создадим Tween, который будет принимать значения от 0 до 180 — количество градусов, на которое необходимо повернуть наш виджет.
Чтобы мы могли развернуть виджет обратно, необходимо в Tween менять значение end между 0 и 180. Это позволит сначала повернуть виджет на 180 градусов, а потом вернуть его в начальное состояние. Для переключения между двумя значениями заведём переменную _isFlipped, в которой будем указывать, перевёрнут наш виджет или нет.
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: AnimatedFlip(),
),
),
);
}
}
class AnimatedFlip extends StatefulWidget {
const AnimatedFlip({super.key});
@override
State<AnimatedFlip> createState() => _AnimatedFlipState();
}
class _AnimatedFlipState extends State<AnimatedFlip> {
bool _isFlipped = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_isFlipped = !_isFlipped;
});
},
child: TweenAnimationBuilder(
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
tween: Tween<double>(begin: 0, end: _isFlipped ? 180 : 0),
builder: (context, value, child) {
return RotationY(
rotationY: value,
child: const Card(
child: SizedBox(
width: 150,
height: 150,
),
),
);
},
),
);
}
}
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,
);
}
}
Наша анимация переворота готова. Теперь необходимо сделать разные стороны для карточки. Для этого мы можем делать подмену вращаемого виджета в момент, когда он поворачивается на 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 : 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,
);
}
}
Теперь стороны разные, но виджет на обратной стороне повёрнут к нам зеркально. Чтобы это исправить, необходимо дополнительно разворачивать в обратную сторону виджет, который будет у нас сзади.
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.
В следующей главе мы разберём анимации глубже: разберёмся в нюансах, скрытых от нас за уже реализованными виджетами, а также узнаем о трёх слонах и черепахе в мире анимации.
И эти знания помогут нам не только пользоваться готовыми виджетами, но и создавать свои уникальные анимации!