Animations & Motion
AnimationController, Ticker, Tween, Curves, AnimatedBuilder, staggered motion, Lottie and Rive
- 01
Why does an AnimationController insist on a vsync, and when does SingleTickerProviderStateMixin stop being enough?
EasyThe
vsynchands the controller a Ticker — an object that gets a callback on every frame the engine schedules — and ties that ticker's lifetime and muting to a widget in the tree.class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin { // vsync: this -> createTicker(): one Ticker, driven by the frame clock late final AnimationController _pulse = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(reverse: true); … - 02
Your pulse runs with repeat(reverse: true) and the "animation finished" branch never fires — what do forward, reverse, repeat and stop do to the controller's value and status?
EasyA controller is one double walking between
lowerBoundandupperBound(0.0 and 1.0 by default) on the frame clock, andAnimationStatusonly reachescompletedordismissedwhen it stops at an end —repeatnever stops.late final AnimationController c = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), reverseDuration: const Duration(milliseconds: 250), // separate way back )..addStatusListener((s) => debugPrint('$s @ ${c.value.toStringAsFixed(2)}')); … - 03
A widget test fails with "was disposed with an active Ticker", and a new duration coming from the parent changes nothing — what does a State owe its AnimationController?
EasyA controller owns a Ticker, a standing request for frames, and a reference back to your
Statethrough the vsync — so it must be created once, updated in place, and disposed by the same class that built it.class _FaderState extends State<Fader> with SingleTickerProviderStateMixin { // ✅ built once, for the life of the State late final AnimationController _c = AnimationController(vsync: this, duration: widget.duration); @override … - 04
How does a Tween turn a controller's 0..1 into a Color, an Offset or a TextStyle, and what does Tween.animate(controller) actually hand back?
MediumA Tween is a stateless function of
t, not an animation: it is anAnimatable<T>whose whole job istransform(double t), andanimate(parent)wraps it around anAnimation<double>to produce a newAnimation<T>that recomputes on demand.class _CardState extends State<Card> with SingleTickerProviderStateMixin { late final _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 350)); // Types with arithmetic ride the base Tween late final Animation<Offset> _slide = … - 05
You animate a card in with Curves.easeOutBack and an Opacity assert fires — what does an overshooting curve do downstream, and how is CurvedAnimation different from an implicit widget's curve argument?
MediumThe "back" and "elastic" curves deliberately return values below 0 and above 1, so every Tween downstream of them is asked to extrapolate past its
end— which is the whole point for a scale and a crash for an opacity.late final _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 420)); // ❌ easeOutBack peaks above 1.0 -> Opacity asserts // 'opacity >= 0.0 && opacity <= 1.0' is not true late final _bad = CurvedAnimation(parent: _c, curve: Curves.easeOutBack); … - 06
You need to pop a screen exactly when its exit animation ends — status listener or await controller.forward(), and what happens if the user interrupts it?
Mediumforward()returns aTickerFuturethat completes only if the animation actually reaches the end; cancel it and that future never completes at all, so a bareawaithangs forever.// ❌ hangs forever if anything cancels the ticker Future<void> closeNaive() async { await _c.reverse(); // stop() elsewhere -> this never returns Navigator.of(context).pop(); // and the lint flags the context use } … - 07
A card fades in, then its title slides up, then the button pops — one controller with Intervals or three controllers, and how do you keep the pieces in sync?
MediumOne controller, because a stagger is a single timeline: the controller is the timeline, and each
Intervalsays which slice of it a property owns.class _RevealState extends State<Reveal> with SingleTickerProviderStateMixin { // One clock for the whole sequence: 300 + 300 + 300 late final _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 900)); // Slices are FRACTIONS of that duration, not durations of their own … - 08
Your AnimatedSwitcher swaps the text but never animates — what makes it decide the child is new, and what do transitionBuilder and layoutBuilder each control?
MediumAnimatedSwitcher compares the old and new child with
Widget.canUpdate— same runtime type and same key — so twoTextwidgets with different strings and no key look like the same widget to it, and it just updates in place.// ❌ nothing animates: both children are Text with no key, // so Widget.canUpdate says "same widget, new data" AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: Text(_label), ); … - 09
You delete a row from your data and AnimatedList still asks you to build that same row — why does it need it, and what has to happen in the same step?
MediumBecause the deleted row has to stay on screen for the length of its exit animation, and by then your data no longer holds it, so the list makes you hand the widget back yourself.
class _InboxState extends State<Inbox> { final _listKey = GlobalKey<AnimatedListState>(); final List<Mail> _mails = List<Mail>.of(seedMails); Widget _row(Mail mail, Animation<double> animation) => SizeTransition( sizeFactor: animation.drive(CurveTween(curve: Curves.easeOut)), … - 10
A progress badge animates toward whatever value its parent passes down, and a new value lands mid-flight — what does TweenAnimationBuilder do with the tween you gave it?
MediumIt throws away the
beginyou passed and rewrites it: on every update the framework setsbeginto the value currently on screen,endto the new target, and callscontroller.forward(from: 0.0).class UploadBadge extends StatelessWidget { const UploadBadge({super.key, required this.progress}); final double progress; // the parent may hand down a new target any time @override … - 11
A designer hands you a four-second celebration animation and wants it to react to the tap that triggers it — Lottie, Rive, or rebuild it in Flutter?
MediumLottie replays a timeline, Rive runs a state machine you drive from Dart, and hand-built motion is the only one that is code you can theme and test — "reacts to the tap" is exactly where Lottie runs out.
import 'package:lottie/lottie.dart'; import 'package:rive/rive.dart'; // ── Lottie: a timeline you can only seek ── class Confetti extends StatefulWidget { const Confetti({super.key}); … - 12
A bottom sheet has to follow the user's thumb and then finish the motion on its own when they let go — how do you wire an AnimationController to a drag?
HardStop treating the controller as something you start: while the finger is down you write
controller.valueyourself, and when it lifts you hand the gesture's velocity tofling, so the animation carries on at the speed the thumb left behind.class _SheetState extends State<Sheet> with SingleTickerProviderStateMixin { static const double _extent = 320; // sheet height in logical pixels late final _c = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), … - 13
The card snaps back with easeOut and testers keep saying the motion feels dead — when does a physics simulation beat any curve you could have picked?
HardA curve is a fixed-duration map from 0..1 onto 0..1, so it can only ever start at rest; a
Simulationcomputes position from an initial velocity and physical constants, and the duration falls out of the physics instead of being declared.class _CardState extends State<DraggableCard> with SingleTickerProviderStateMixin { // unbounded: an underdamped spring overshoots past the target and // a clamped controller would eat the overshoot late final _c = AnimationController.unbounded(vsync: this); … - 14
A card grows when a details section is revealed and its height is unknown until layout runs — how do you animate a size you cannot write a Tween for?
HardYou let layout produce the numbers and animate the container instead of the value:
AnimatedSizemeasures its child, and when that measurement changes it tweens its own size from the old one to the new one, so you never write a begin or an end.// 1 — the height comes out of layout, so animate the box, not a number AnimatedSize( duration: const Duration(milliseconds: 220), curve: Curves.easeOut, alignment: Alignment.topCenter, // no vsync: that parameter is gone, the widget owns its ticker … - 15
You need an AnimatedX for a property Flutter has no implicit widget for — what does ImplicitlyAnimatedWidget make you implement, and what is forEachTween's contract?
HardExtend
ImplicitlyAnimatedWidget, put its State onAnimatedWidgetBaseState, and implement one method —forEachTween, where you hand the framework each tween you keep and get back the one it will drive.class AnimatedRing extends ImplicitlyAnimatedWidget { const AnimatedRing({ super.key, required this.color, required this.thickness, required super.duration, … - 16
A page's header should fade and slide in as its route arrives, without the route handing anything down — where does that widget get the animation from?
HardFrom the route it is sitting in:
ModalRoute.of(context)?.animationis the veryAnimation<double>the route's transition is driven by, so any widget inside the page can react to its own entrance.class PageHeader extends StatelessWidget { const PageHeader({super.key, required this.title}); final String title; @override …