Animations & Motion
Irbisa · cheatsheetSeptember 13, 2026

Animations & Motion

AnimationController, Ticker, Tween, Curves, AnimatedBuilder, staggered motion, Lottie and Rive

Middle Developer16 itemscompressed for a skim
  1. 01

    Why does an AnimationController insist on a vsync, and when does SingleTickerProviderStateMixin stop being enough?

    Easy

    The vsync hands 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);
    …
  2. 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?

    Easy

    A controller is one double walking between lowerBound and upperBound (0.0 and 1.0 by default) on the frame clock, and AnimationStatus only reaches completed or dismissed when it stops at an end — repeat never 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)}'));
    …
  3. 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?

    Easy

    A controller owns a Ticker, a standing request for frames, and a reference back to your State through 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
    …
  4. 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?

    Medium

    A Tween is a stateless function of t, not an animation: it is an Animatable<T> whose whole job is transform(double t), and animate(parent) wraps it around an Animation<double> to produce a new Animation<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 =
    …
  5. 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?

    Medium

    The "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);
    …
  6. 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?

    Medium

    forward() returns a TickerFuture that completes only if the animation actually reaches the end; cancel it and that future never completes at all, so a bare await hangs 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
    }
    …
  7. 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?

    Medium

    One controller, because a stagger is a single timeline: the controller is the timeline, and each Interval says 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
    …
  8. 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?

    Medium

    AnimatedSwitcher compares the old and new child with Widget.canUpdate — same runtime type and same key — so two Text widgets 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),
    );
    …
  9. 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?

    Medium

    Because 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. 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?

    Medium

    It throws away the begin you passed and rewrites it: on every update the framework sets begin to the value currently on screen, end to the new target, and calls controller.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. 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?

    Medium

    Lottie 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. 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?

    Hard

    Stop treating the controller as something you start: while the finger is down you write controller.value yourself, and when it lifts you hand the gesture's velocity to fling, 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. 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?

    Hard

    A curve is a fixed-duration map from 0..1 onto 0..1, so it can only ever start at rest; a Simulation computes 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. 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?

    Hard

    You let layout produce the numbers and animate the container instead of the value: AnimatedSize measures 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. 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?

    Hard

    Extend ImplicitlyAnimatedWidget, put its State on AnimatedWidgetBaseState, 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. 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?

    Hard

    From the route it is sitting in: ModalRoute.of(context)?.animation is the very Animation<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
    …