Steven's Knowledge

Animations & Motion

Flutter animation engineering — implicit vs explicit, AnimationController lifecycle, Hero, physics, performance, reduce-motion

Animations & Motion

A practical guide to motion in Flutter. The guiding principle: animate the cheapest property on the smallest subtree, dispose every controller, and respect the user's reduce-motion setting.

For RenderObject-level custom drawing that animation can drive, see Advanced Usage; for the render pipeline and rebuild-cost model, see Performance.

Implicit vs Explicit

Flutter offers two animation families. Choosing the wrong one is the most common source of either bloated code or janky motion.

DimensionImplicitExplicit
TriggerSet a new value, Flutter animates the deltaYou drive a controller manually
ControlNone — fire and forgetFull: play, reverse, repeat, stop, seek
BoilerplateMinimal (one Widget)Controller + Tween + dispose
State neededOften noneStatefulWidget + vsync
Best forOne-shot transitions between two statesLooping, staggered, gesture-driven, coordinated
ExamplesAnimatedContainer, AnimatedOpacityAnimationController + AnimatedBuilder

Rule of thumb: reach for an implicit Animated* Widget first. Drop to an explicit AnimationController only when you need to control timing — repeat, reverse on demand, coordinate multiple animations, or drive from a gesture.

Implicit Animations

Every implicit animation follows the same model: a duration, an optional curve, and a target value. When the value changes between builds, Flutter interpolates from old to new.

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: _expanded ? 200 : 100,
  height: _expanded ? 200 : 100,
  color: _expanded ? Colors.blue : Colors.grey,
  // setState(() => _expanded = !_expanded) triggers the animation
)

The whole family shares this contract:

WidgetAnimates
AnimatedContainersize, color, padding, alignment, decoration
AnimatedOpacityopacity (cheap — compositor-only)
AnimatedPositionedposition inside a Stack
AnimatedAlignalignment
AnimatedPaddingpadding
AnimatedDefaultTextStyletext style
AnimatedSwitcherswaps child with a transition (needs a Key)

TweenAnimationBuilder

When you want implicit behavior for a property no Animated* Widget covers, TweenAnimationBuilder animates any Tween toward a new end without a controller:

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: progress),
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeOut,
  builder: (_, value, child) => LinearProgressIndicator(value: value),
)

Each time end changes, it animates from the current value. Pass a static subtree as child to keep it out of the rebuild (see Rebuild Scoping).

Explicit Animations

When you need control, the building blocks are AnimationController, Animation<T>, Tween, and CurvedAnimation.

AnimationController Lifecycle

The controller produces values from 0.0 to 1.0 over its duration, ticking once per frame. It needs a vsync (a TickerProvider) so off-screen animations are paused automatically.

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 1),
  )..repeat(reverse: true);

  @override
  void dispose() {
    _controller.dispose();   // MANDATORY — leaks a Ticker otherwise
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => FadeTransition(
        opacity: _controller,
        child: const Icon(Icons.favorite),
      );
}
  • Use SingleTickerProviderStateMixin for one controller, TickerProviderStateMixin for several.
  • Drive playback with forward(), reverse(), repeat(), stop(), reset(), or jump with value / animateTo().
  • dispose() is non-negotiable — a leaked Ticker keeps firing and is flagged by the framework in debug mode.

Never create an AnimationController inside build. It must live in State and be created once (initState or a late final field). Creating one per build leaks controllers every frame and never animates.

Tween, CurvedAnimation, and .drive()

The controller emits 0..1. A Tween maps that range onto your target type; a CurvedAnimation reshapes the timing.

late final Animation<double> _size = _controller.drive(
  Tween(begin: 0.0, end: 300.0).chain(
    CurveTween(curve: Curves.elasticOut),
  ),
);

// Equivalent, more explicit form:
late final Animation<Color?> _color = ColorTween(
  begin: Colors.red,
  end: Colors.blue,
).animate(
  CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);

Tween works for any lerp-able type: Tween<double>, ColorTween, AlignmentTween, EdgeInsetsTween, Size, Offset, even TweenSequence for multi-stage paths.

Rebuild Scoping

An animation that runs setState (or rebuilds a large builder) every frame is the single biggest perf trap. Confine rebuilds to the animating leaf with AnimatedBuilder/AnimatedWidget, and hoist anything static into the child parameter.

AnimatedBuilder(
  animation: _controller,
  child: const ExpensiveSubtree(),   // built ONCE, passed through untouched
  builder: (_, child) => Transform.rotate(
    angle: _controller.value * 2 * pi,
    child: child,                    // reused every frame
  ),
)

The builder runs each frame but only rebuilds the Transform; ExpensiveSubtree is constructed once. The same child-passthrough applies to TweenAnimationBuilder and the built-in *Transition Widgets (FadeTransition, ScaleTransition, SlideTransition) — prefer these over AnimatedBuilder when one exists, since they are already scoped optimally.

AnimatedWidget is the class-based equivalent: subclass it, read the animation in build, and the framework subscribes for you.

Staggered Animations

To choreograph several animations from one controller, give each its own Interval curve carving out a slice of the 0..1 timeline.

class _StaggerState extends State<Stagger> with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 1200),
  );

  late final Animation<double> _opacity = CurvedAnimation(
    parent: _c,
    curve: const Interval(0.0, 0.4, curve: Curves.easeIn),
  );
  late final Animation<double> _width = Tween(begin: 50.0, end: 200.0).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.4, 0.7)),
  );
  late final Animation<double> _height = Tween(begin: 50.0, end: 200.0).animate(
    CurvedAnimation(parent: _c, curve: const Interval(0.7, 1.0)),
  );
  // opacity fades 0–40%, width grows 40–70%, height grows 70–100%
}

One controller keeps the stages perfectly in sync and is cheaper than juggling several controllers.

Hero & Shared-Element Transitions

A Hero flies a Widget from one route to the next when both pages contain a Hero with the same tag.

// Source page
Hero(tag: 'product-$id', child: Image.network(thumbUrl))

// Destination page
Hero(tag: 'product-$id', child: Image.network(fullUrl))

flightShuttleBuilder

By default the source Widget is reparented mid-flight. When source and destination differ structurally (e.g. an icon morphing into a title), supply a flightShuttleBuilder to render the in-between:

Hero(
  tag: 'product-$id',
  flightShuttleBuilder: (_, animation, __, ___, ____) => FadeTransition(
    opacity: animation,
    child: Material(color: Colors.transparent, child: ...),
  ),
  child: ...,
)

Common Hero pitfalls. Tags must be unique per page — two Heroes sharing a tag on one screen throws. Wrapping the flying child's text in plain Text without a Material ancestor produces yellow underline glitches mid-flight; wrap with Material(type: MaterialType.transparency). And a Hero inside a lazily-built list only animates if it is actually built on both routes.

Page Route Transitions

Customize how a whole page enters with PageRouteBuilder:

Navigator.push(context, PageRouteBuilder(
  transitionDuration: const Duration(milliseconds: 300),
  pageBuilder: (_, __, ___) => const DetailPage(),
  transitionsBuilder: (_, animation, __, child) => SlideTransition(
    position: Tween(
      begin: const Offset(1, 0),
      end: Offset.zero,
    ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
    child: child,
  ),
))

With declarative routing, wrap the same transitionsBuilder in go_router's CustomTransitionPage instead of pushing manually — see Routing in Best Practices.

Physics-Based Animations

For motion that should feel natural after a gesture — springing back, flinging, settling — drive the controller with a physics Simulation rather than a fixed duration.

void _onPanEnd(DragEndDetails details, Size size) {
  final velocity = details.velocity.pixelsPerSecond.dx / size.width;
  const spring = SpringDescription(mass: 1, stiffness: 500, damping: 25);
  final simulation = SpringSimulation(spring, _controller.value, 0.0, velocity);
  _controller.animateWith(simulation);   // ignores `duration`, follows physics
}

animateWith(simulation) runs until the simulation reports it is done. fling() is the shorthand for a spring from the current value driven by a velocity. This is how dismissible cards, bottom sheets, and swipe-to-reorder get their feel. For tuning scroll physics specifically, see Custom ScrollPhysics in Advanced Usage.

Animated Lists

AnimatedList (and SliverAnimatedList for CustomScrollView) animates insertion and removal. You mutate a backing list and call the matching method so the framework can run the transition:

final _key = GlobalKey<AnimatedListState>();

void _insert(int index, Item item) {
  _items.insert(index, item);
  _key.currentState!.insertItem(index, duration: const Duration(milliseconds: 300));
}

AnimatedList(
  key: _key,
  initialItemCount: _items.length,
  itemBuilder: (_, i, animation) => SizeTransition(
    sizeFactor: animation,
    child: ListTile(title: Text(_items[i].name)),
  ),
)

For removal, the removeItem builder receives the old item to animate out. For a high-level declarative API, flutter_animate adds chainable effects (.fadeIn().slideY()) and flutter_staggered_animations wraps list children with staggered entrances — both reduce boilerplate for common cases.

Driving a CustomPainter

A CustomPainter can repaint directly off a Listenable (such as an Animation) via the repaint: argument — no AnimatedBuilder needed, and no Widget rebuild per frame:

class WavePainter extends CustomPainter {
  final Animation<double> animation;
  WavePainter(this.animation) : super(repaint: animation);  // repaint on tick

  @override
  void paint(Canvas canvas, Size size) {
    // read animation.value, draw
  }

  @override
  bool shouldRepaint(WavePainter old) => false;  // repaint is driven by `repaint:`
}

CustomPaint(painter: WavePainter(_controller), size: const Size(300, 100))

This is the most efficient path for canvas animation: only the paint phase runs each frame, skipping build and layout entirely. See Advanced Usage for the CustomPainter fundamentals.

Lottie and Rive

For designer-authored motion, embed exported assets rather than hand-coding:

  • lottie — plays After Effects animations (Bodymovin JSON). Best for non-interactive, fire-and-forget motion.
  • rive — interactive, state-machine-driven animations editable in the Rive editor; lighter runtime and supports runtime inputs.
Lottie.asset('assets/success.json', repeat: false)
RiveAnimation.asset('assets/button.riv', stateMachines: ['State Machine 1'])

Use these when motion is complex enough that authoring it in code would be unmaintainable.

Performance

Motion is where jank shows. Keep these rules in mind:

  • Prefer transform and opacity over layout. Animating width/height/padding re-runs layout every frame; animating Transform.scale or FadeTransition touches only paint/composite. Re-express layout animations as transforms where possible.
  • Wrap the animated subtree in RepaintBoundary so its repaints do not dirty siblings — it gets its own compositor layer.
  • Keep the animated subtree small. Scope rebuilds with the child parameter (see Rebuild Scoping); never animate near the root of a large tree.
  • Profile in the DevTools timeline. Run in profile mode, record the Performance view, and look for frames exceeding the 16 ms budget; the raster thread tells you whether the cost is in paint/composite.
  • Do nothing expensive in listeners. A controller fires every frame — see Anti-Patterns.

For the full render-pipeline mental model and image-cache tuning, see Performance.

Accessibility & Reduce Motion

Some users enable "reduce motion" at the OS level (vestibular disorders, motion sensitivity). Honor it.

final reduceMotion = MediaQuery.of(context).disableAnimations;

// Skip or shorten non-essential motion
final duration = reduceMotion
    ? Duration.zero
    : const Duration(milliseconds: 300);
  • MediaQuery.disableAnimations reflects the OS reduce-motion / remove-animations setting.
  • MediaQuery.accessibleNavigation is true when a screen reader is active — avoid auto-advancing carousels and time-based transitions that a reader cannot keep up with.
  • Replace large parallax/zoom transitions with a simple cross-fade when reduce-motion is on; never remove the state change, only the motion.

See Accessibility for the broader Semantics and focus story.

Anti-Patterns

  • Forgetting dispose() on a controller. Leaks a Ticker that keeps firing forever; the framework warns in debug. Always pair creation in State with disposal.
  • Creating or animating inside build. A controller (or Tween.animate chain) built per frame never animates and leaks. Create once in State.
  • Heavy work in animation listeners. A controller fires ~60 times/second; parsing, network, or setState on a large tree inside addListener/the builder tanks the frame rate. Keep per-frame work to a single cheap value read.
  • Over-using implicit animations on large trees. An AnimatedContainer wrapping a whole page re-lays-out everything each frame. Scope motion to a small leaf.
  • Ignoring reduce-motion. Aggressive parallax and auto-play motion are an accessibility failure and can cause physical discomfort. Check MediaQuery.disableAnimations.
  • Janky layout animations instead of transforms. Animating width/height/margin forces relayout; use Transform/scale/opacity which stay on the compositor.
  • Hero tag collisions. Duplicate tags on one screen throw at runtime; derive tags from a stable unique id.

On this page