Разберись во внутренней механике неявных анимаций и научись тонко управлять собственным AnimationController.
Открыть этот урок в KodokonAnimatedContainer принадлежит семейству неявных анимаций: ты меняешь целевое значение, а фреймворк анимирует переход. Под капотом каждый ImplicitlyAnimatedWidget держит AnimationController, спрятанный в своём state. Когда приходит новая конфигурация, didUpdateWidget обходит свойства через forEachTween и перенацеливает каждый tween: границей begin становится текущее интерполированное значение, границей end - новая цель, после чего контроллер стартует с нуля. Ценное следствие: смена цели посреди анимации не даёт никакого визуального скачка - анимация продолжается ровно оттуда, где была.
import 'package:flutter/material.dart';
class SelectableCard extends StatefulWidget {
const SelectableCard({super.key});
@override
State<SelectableCard> createState() =>
_SelectableCardState();
}
class _SelectableCardState extends State<SelectableCard> {
bool _selected = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () =>
setState(() => _selected = !_selected),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
width: _selected ? 220 : 160,
height: 96,
decoration: BoxDecoration(
color: _selected
? Colors.indigo
: Colors.indigo.shade200,
borderRadius: BorderRadius.circular(
_selected ? 28 : 12,
),
),
),
);
}
}Неявные анимации покрывают те свойства, которые фреймворк предвидел, а TweenAnimationBuilder обобщает подход на любое интерполируемое значение. Как только тебе нужно управлять анимацией - пауза, обратный ход, повтор, реакция на статус - переходи к явному AnimationController. Параметр vsync ждёт TickerProvider: миксин создаёт Ticker, колбэк, который SchedulerBinding вызывает на каждом кадре. Малоизвестная тонкость: TickerMode приглушает тикеры скрытого поддерева (например, под перекрытым маршрутом). Время идёт дальше, но ни один колбэк больше не выполняется - ноль процессора на невидимые анимации.
import 'package:flutter/material.dart';
class RevealPanel extends StatefulWidget {
const RevealPanel({super.key, required this.child});
final Widget child;
@override
State<RevealPanel> createState() => _RevealPanelState();
}
class _RevealPanelState extends State<RevealPanel>
with SingleTickerProviderStateMixin {
late final AnimationController _controller =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
reverseDuration: const Duration(milliseconds: 200),
);
late final Animation<double> _opacity = _controller
.drive(CurveTween(curve: Curves.easeOutCubic));
void toggle() {
switch (_controller.status) {
case AnimationStatus.forward:
case AnimationStatus.completed:
_controller.reverse();
case AnimationStatus.reverse:
case AnimationStatus.dismissed:
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: toggle,
child: FadeTransition(
opacity: _opacity,
child: widget.child,
),
);
}
}Контроллер - это всего лишь Animation<double>, идущая от 0 до 1: собирай из неё производные вместо того, чтобы использовать её сырой. controller.drive(CurveTween(...)) даёт производную анимацию без единого лишнего состояния. Чтобы применить значение к дереву, предпочитай виджеты Transition (FadeTransition, RotationTransition): они слушают анимацию напрямую на уровне render object, не перестраивая ни одного виджета за кадр. Когда преобразование произвольное, работу делает AnimatedBuilder - при условии, что статическое поддерево ты передаёшь через child: оно строится только один раз, а потом на каждом кадре просто подставляется в builder. Это самая выгодная оптимизация для явных анимаций.
import 'dart:math' as math;
import 'package:flutter/material.dart';
class Orbit extends StatefulWidget {
const Orbit({super.key});
@override
State<Orbit> createState() => _OrbitState();
}
class _OrbitState extends State<Orbit>
with SingleTickerProviderStateMixin {
late final AnimationController _controller =
AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: const Icon(Icons.satellite_alt, size: 32),
builder: (context, child) {
final angle = _controller.value * 2 * math.pi;
return Transform.translate(
offset: Offset(
math.cos(angle) * 80,
math.sin(angle) * 80,
),
child: child,
);
},
);
}
}