Skip to content

Animations

Flutter animations split into two families, and picking the right one is most of the skill:

  • Implicit — you change a value, Flutter animates to it automatically. Zero controllers, no lifecycle to manage.
  • Explicit — you drive the animation yourself with an AnimationController. Full control (repeat, reverse, sync several values, respond to gestures), at the cost of a ticker and disposal.

Start implicit; go explicit only when implicit can’t express what you need.

TweenAnimationBuilder takes a Tween and a target, and animates the value on every rebuild where the target changes — this is the general form behind every AnimatedFoo widget (AnimatedContainer, AnimatedOpacity, …).

lib/animation_demo.dart
import 'package:flutter/material.dart';
class ImplicitValue extends StatelessWidget {
const ImplicitValue({super.key, required this.target});
final double target;
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: target),
duration: const Duration(milliseconds: 1000),
curve: Curves.linear,
builder: (context, value, child) => Text(value.toStringAsFixed(2)),
);
}
}
class ExplicitPulse extends StatefulWidget {
const ExplicitPulse({super.key});
@override
State<ExplicitPulse> createState() => _ExplicitPulseState();
}
class _ExplicitPulseState extends State<ExplicitPulse>
with SingleTickerProviderStateMixin {
late final AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
)..forward();
@override
void dispose() {
controller.dispose(); // ALWAYS dispose the controller
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) => Text(controller.value.toStringAsFixed(2)),
);
}
}
class LeakyPulse extends StatefulWidget {
const LeakyPulse({super.key, this.onControllerReady});
final ValueChanged<AnimationController>? onControllerReady;
@override
State<LeakyPulse> createState() => _LeakyPulseState();
}
class _LeakyPulseState extends State<LeakyPulse>
with SingleTickerProviderStateMixin {
late final AnimationController controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
@override
void initState() {
super.initState();
widget.onControllerReady?.call(controller);
}
// BUG: no dispose() override -- the controller (and its Ticker) leaks.
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}
test/animation_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/async-and-performance__animations/animation_demo.dart';
void main() {
testWidgets('TweenAnimationBuilder animates linearly to the target value',
(tester) async {
await tester
.pumpWidget(const MaterialApp(home: ImplicitValue(target: 100)));
expect(find.text('0.00'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('50.00'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('100.00'), findsOneWidget);
});
testWidgets('AnimationController.value walks 0 -> 1 over the duration',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: ExplicitPulse()));
expect(find.text('0.00'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('0.50'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('1.00'), findsOneWidget);
});
testWidgets(
'an AnimationController that is never disposed throws the real Ticker leak error on removal',
(tester) async {
late AnimationController leaked;
await tester.pumpWidget(MaterialApp(
home: LeakyPulse(onControllerReady: (c) => leaked = c),
));
await tester.pump(const Duration(milliseconds: 10));
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
final error = tester.takeException();
expect(error, isA<FlutterError>());
expect(error.toString(), contains('was disposed with an active Ticker'));
// Clean up the leaked ticker so it doesn't ALSO fail the test a second
// way — see the real "animation still running" error quoted below,
// which TestWidgetsFlutterBinding raises separately at end-of-test if
// a ticker is still scheduling frames when the test finishes.
leaked.dispose();
});
}
00:00 +0: TweenAnimationBuilder animates linearly to the target value
00:00 +1: AnimationController.value walks 0 -> 1 over the duration
00:00 +2: an AnimationController that is never disposed throws the real Ticker leak error on removal
00:00 +3: All tests passed!

ExplicitPulse (with dispose()) and LeakyPulse (without it) are otherwise identical — the intermediate-value test above proves an AnimationController’s .value really is a linear 0→1 ramp you can assert against at any point via tester.pump(Duration), and the leak test proves what happens when the one line that matters (controller.dispose()) is missing.

A Curve (Curves.easeInOut, Curves.elasticOut, Curves.bounceOut, …) reshapes linear time into natural motion — both TweenAnimationBuilder and an AnimationController-driven CurvedAnimation take one. The test above deliberately used Curves.linear so the intermediate values (50.00 at the halfway point) are simple to assert exactly; any other curve is still fully testable the same way — tester.pump(Duration) — the intermediate value just won’t be a simple linear interpolation, so you’d assert against the curve’s own transform (Curves.easeInOut.transform(0.5)) instead of the raw 0.5.

AnimationController disposal: two different real errors

Section titled “AnimationController disposal: two different real errors”

Forgetting controller.dispose() doesn’t just risk “a leak” in the abstract — it is proven above to throw a real, specific error the moment the widget is removed while its ticker is still active:

FlutterError: _LeakyPulseState#c6bd8(ticker active) was disposed with an active Ticker.
_LeakyPulseState created a Ticker via its SingleTickerProviderStateMixin, but at the time dispose() was called on the mixin, that Ticker was still active. The Ticker must be disposed before calling super.dispose().
Tickers used by AnimationControllers should be disposed by calling dispose() on the AnimationController itself. Otherwise, the ticker will leak.
The offending ticker was:
Ticker(created by _LeakyPulseState#c6bd8)

That text (captured via tester.takeException() above) comes from a real assert in the installed Flutter 3.47.5 SDK’s SingleTickerProviderStateMixin.dispose() (~/flutter/packages/flutter/lib/src/widgets/ticker_provider.dart, around line 365) — which means it is a debug-mode-only check (it literally will not run in a --release build; see the production-concerns lesson for what assert stripping means for release builds).

If you don’t catch that first error (e.g., the leaked controller’s state object is disposed silently some other way), there’s a second, independent real error that fires later, from a completely different part of the framework — TestWidgetsFlutterBinding’s own end-of-test check for orphaned scheduled callbacks:

EXCEPTION CAUGHT BY SCHEDULER LIBRARY
The following message was thrown:
An animation is still running even after the widget tree was disposed.
There was one transient callback left. The stack trace for when it was registered is as follows:
── callback 3 ──
#3 Ticker.scheduleTick (package:flutter/src/scheduler/ticker.dart:299:46)
#4 Ticker.start (package:flutter/src/scheduler/ticker.dart:201:7)
#5 AnimationController._startSimulation (package:flutter/src/animation/animation_controller.dart:861:42)
#6 AnimationController.repeat (package:flutter/src/animation/animation_controller.dart:739:12)

This second error is exactly why the test above ends with leaked.dispose() — without it, the already-proven leak from LeakyPulse keeps ticking in the background for the rest of the test run and fails the test a second, different way once the test binding notices a scheduled frame callback nobody stopped.

LeakTesting/leak_tracker_flutter_testing: real, but not what this lesson uses

Section titled “LeakTesting/leak_tracker_flutter_testing: real, but not what this lesson uses”

This exact installed SDK ships a more automated option: testWidgets(..., experimentalLeakTesting: LeakTesting.settings) (~/flutter/packages/flutter_test/lib/src/widget_tester.dart, the experimentalLeakTesting parameter), backed by the real leak_tracker_flutter_testing package (confirmed present in this machine’s pub cache at version 3.0.10, pulled in transitively by flutter_test). It genuinely exists and genuinely can catch a leaked AnimationController automatically, without writing a dedicated test like the one above. The SDK’s own doc comment on that parameter is direct about its status, though: “The argument experimentalLeakTesting is experimental and is not recommended for use outside of the Flutter framework.” This lesson proves the leak the way most real app test suites actually do today — asserting on tester.takeException() around a deliberate removal — precisely because that’s the supported, non-experimental path; reach for LeakTesting only if you’ve read the linked docs and accept its experimental status.

Hero: shared-element transitions, proven both ways

Section titled “Hero: shared-element transitions, proven both ways”

Hero flies a widget between two routes when both sides use the same tag. Proven with a real two-screen Navigator.push:

lib/hero_demo.dart
import 'package:flutter/material.dart';
class ScreenA extends StatelessWidget {
const ScreenA({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onTap: () => Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => const ScreenB())),
child: const Hero(
tag: 'logo',
child: FlutterLogo(key: ValueKey('logoA'), size: 50),
),
),
),
);
}
}
class ScreenB extends StatelessWidget {
const ScreenB({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Hero(
tag: 'logo',
child: FlutterLogo(key: ValueKey('logoB'), size: 200),
),
),
);
}
}
// A destination route with TWO Heroes sharing the same tag -- proven below
// to throw a real assertion during the flight, not a silent mismatch.
class ScreenADuplicate extends StatelessWidget {
const ScreenADuplicate({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const ScreenBDuplicate())),
child: const Hero(
tag: 'logo',
child: FlutterLogo(key: ValueKey('logoA'), size: 50),
),
),
),
);
}
}
class ScreenBDuplicate extends StatelessWidget {
const ScreenBDuplicate({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(
children: [
Hero(tag: 'logo', child: FlutterLogo(key: ValueKey('dup1'), size: 50)),
Hero(tag: 'logo', child: Icon(Icons.star, key: ValueKey('dup2'))),
],
),
);
}
}
test/hero_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/async-and-performance__animations/hero_demo.dart';
void main() {
testWidgets('matching Hero tags fly between routes without error', (tester) async {
await tester.pumpWidget(const MaterialApp(home: ScreenA()));
await tester.tap(find.byType(GestureDetector));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('logoB')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets(
'two Heroes sharing a tag on the destination route throw a real assertion during the flight',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: ScreenADuplicate()));
await tester.tap(find.byType(GestureDetector));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final error = tester.takeException();
expect(error, isA<FlutterError>());
expect(
error.toString(),
contains('There are multiple heroes that share the same tag within a subtree'),
);
});
}
00:00 +0: matching Hero tags fly between routes without error
00:00 +1: two Heroes sharing a tag on the destination route throw a real assertion during the flight
00:00 +2: All tests passed!

The real, captured error text for the duplicate case: “There are multiple heroes that share the same tag within a subtree. Within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), each Hero must have a unique non-null tag. In this case, multiple heroes had the following tag: logo” — proving Hero tag collisions are a loud, specific failure, not a silently-wrong transition.

AnimatedSwitcher: the key decides whether it animates at all

Section titled “AnimatedSwitcher: the key decides whether it animates at all”

AnimatedSwitcher cross-fades between its child whenever that child changes identity — proven below, “changes identity” means a different runtimeType or a different Key, exactly the same Widget.canUpdate rule that governs every widget update in Flutter, not something special to AnimatedSwitcher.

lib/switcher_demo.dart
import 'package:flutter/material.dart';
class SwitcherDemo extends StatelessWidget {
const SwitcherDemo({super.key, required this.value});
final int value;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text('$value', key: ValueKey(value)), // distinct key per value
);
}
}
class SwitcherNoKeyDemo extends StatelessWidget {
const SwitcherNoKeyDemo({super.key, required this.value});
final int value;
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text('$value'), // NO key -- same identity to AnimatedSwitcher
);
}
}
test/switcher_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/async-and-performance__animations/switcher_demo.dart';
void main() {
testWidgets('AnimatedSwitcher with a distinct ValueKey per child cross-fades',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: SwitcherDemo(value: 1)));
expect(find.text('1'), findsOneWidget);
await tester.pumpWidget(const MaterialApp(home: SwitcherDemo(value: 2)));
await tester.pump(const Duration(milliseconds: 100)); // mid-transition
expect(find.text('1'), findsOneWidget); // old child still fading out
expect(find.text('2'), findsOneWidget); // new child fading in
await tester.pump(const Duration(milliseconds: 200)); // finish
expect(find.text('1'), findsNothing);
expect(find.text('2'), findsOneWidget);
});
testWidgets('AnimatedSwitcher with NO key on same-type children does not animate at all',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: SwitcherNoKeyDemo(value: 1)));
expect(find.text('1'), findsOneWidget);
await tester.pumpWidget(const MaterialApp(home: SwitcherNoKeyDemo(value: 2)));
await tester.pump(const Duration(milliseconds: 1)); // barely any time passed
expect(find.text('1'), findsNothing); // already gone -- no cross-fade happened
expect(find.text('2'), findsOneWidget);
});
}
00:00 +0: AnimatedSwitcher with a distinct ValueKey per child cross-fades
00:00 +1: AnimatedSwitcher with NO key on same-type children does not animate at all
00:00 +2: All tests passed!

The second test is the one that surprises people: without a key, Text('1') and Text('2') are the same widget identity as far as AnimatedSwitcher (and Flutter’s element diffing generally) is concerned — same runtimeType, no key — so it just updates the existing Text element in place, with zero cross-fade, even though the visible text changed. A ValueKey (or any distinct key) is what tells AnimatedSwitcher “this is a genuinely different child,” which is the only thing that triggers the transition.

An actively-ticking animation repaints every frame — wrapping it in a RepaintBoundary contains that repainting to its own layer instead of forcing ancestors/siblings to repaint too. The mechanics of paint layers and why this matters are covered in depth in the render-pipeline lesson; the short version for this lesson is: any AnimatedBuilder/AnimationController-driven subtree that sits next to expensive, static content is a strong candidate for its own RepaintBoundary.

TickerMode(enabled: false, child: ...) mutes every ticker in its subtree — the ticker keeps existing, but stops actually scheduling frame callbacks, which is provably different from “not animating” (the AnimationController is still forward()-ing, its .value target is unchanged; it simply never advances):

// test/tickermode_test.dart (uses ExplicitPulse from lib/animation_demo.dart)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/async-and-performance__animations/animation_demo.dart';
void main() {
testWidgets('TickerMode(enabled: false) mutes the ticker so it never advances',
(tester) async {
await tester.pumpWidget(const MaterialApp(
home: TickerMode(enabled: false, child: ExplicitPulse()),
));
expect(find.text('0.00'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('0.00'), findsOneWidget); // still 0.00 -- muted, not advancing
});
}
00:00 +0: TickerMode(enabled: false) mutes the ticker so it never advances
00:00 +1: All tests passed!

This is exactly the mechanism Flutter itself relies on to pause offscreen animations automatically (a route that’s covered by another route, a tab that’s not currently visible) — TickerMode is set to false around inactive routes/tabs so their AnimationControllers stop burning frames without needing to be manually paused and resumed.

AI-generated animation code reliably gets the happy path (the animation itself) right and skips exactly the two things that only matter once the widget leaves the tree or the data changes shape: disposal, and keys.

// AI-generated: "pulse this icon while the upload is in progress"
class UploadIndicator extends StatefulWidget {
const UploadIndicator({super.key});
@override
State<UploadIndicator> createState() => _UploadIndicatorState();
}
class _UploadIndicatorState extends State<UploadIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: Tween(begin: 0.9, end: 1.1).animate(_controller),
child: const Icon(Icons.cloud_upload),
);
}
}

Before trusting generated animation code like this, check for:

  • A missing dispose() override — proven above, an AnimationController created via SingleTickerProviderStateMixin that’s never disposed throws a real, specific error the moment its State is removed while the ticker is still active (and, if that’s missed too, a second real error at end-of-test/end-of-app-lifecycle for the still-scheduling ticker).
  • An AnimatedSwitcher/ListView/any widget relying on child identity being fed a child with no key, or the same key regardless of the underlying data changing — proven above, this silently skips the animation (or, in a list, causes the wrong item’s state to move) rather than throwing anything.
  • Two Hero widgets under construction with a shared, hardcoded tag (a common AI pattern when generating a list of cards that should each fly to a detail screen) — proven above, this throws a real, specific assertion the moment a route transition actually happens, not at build time.
  • ..repeat(reverse: true) (or any never-ending animation) with no TickerMode/visibility awareness — proven above, TickerMode(enabled: false) is the mechanism for pausing this automatically when the widget is offscreen; without it, an infinitely-repeating controller keeps ticking (and costing frame budget) even while its widget is invisible.

🐛 Spot the bug

class NotificationBadge extends StatefulWidget {
const NotificationBadge({super.key, required this.count});
final int count;
@override
State<NotificationBadge> createState() => _NotificationBadgeState();
}
class _NotificationBadgeState extends State<NotificationBadge> {
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
widget.count.toString(),
style: Theme.of(context).textTheme.labelLarge,
),
);
}
}
Show the answer

Proven above with the identical pattern: there is no key on the Text inside AnimatedSwitcher, so as widget.count changes (1, 2, 3, …), every new Text has the same runtimeType and no key as the old one — AnimatedSwitcher sees “the same child,” updates it in place, and the badge’s count changes with zero cross-fade animation, defeating the entire point of wrapping it in AnimatedSwitcher. The fix is a key that changes with the data: Text(widget.count.toString(), key: ValueKey(widget.count), ...).

Every animation primitive in this lesson answers the same question at a different layer: what produces the next value, and who is responsible for stopping it? An implicit animation (TweenAnimationBuilder) owns its own hidden AnimationController and disposes it for you — that’s the entire trade being made when you choose implicit over explicit. An explicit AnimationController hands you that responsibility directly, and — proven above, twice, with two different real errors — Flutter’s framework actively checks that you honored it, both at the moment your State is disposed and again at the end of the test/app lifecycle if the first check somehow didn’t catch it. Hero and AnimatedSwitcher are really the same “identity” question from a totally different angle: not “did you clean up a ticker” but “does the framework agree with you about which widget is which” — a Hero tag and a Key are both, fundamentally, answers to “is this the same thing as before, or something new?”

ChoiceBenefitCost
Implicit (TweenAnimationBuilder/AnimatedFoo)No controller, no dispose() to forget — proven above, the ramp is just as testable via pump(Duration)Only animates in response to an actual rebuild with a new target value; no repeat/reverse/manual control
Explicit (AnimationController)Full control — repeat, reverse, gesture-driven, multiple synced animationsProven above: a real, specific leak error if dispose() is forgotten — twice over, if the first check is missed
HeroA shared-element transition with almost no code — proven above, works cleanly when tags match one-to-oneProven above: a real, specific assertion the moment two Heroes on one route share a tag
AnimatedSwitcher + keyAutomatic cross-fade on genuine child changesProven above: silently does nothing if the child’s identity (type+key) doesn’t actually change
TickerMode(enabled: false)Pauses a whole subtree’s tickers without touching controller state — proven aboveOnly mutes; an actively-repeat()ing controller still exists and resumes instantly when re-enabled — it is not the same as disposing
  • “Forgetting to dispose an AnimationController is a soft, hard-to-notice leak.” Disproven above: it throws a real, specific FlutterError ("was disposed with an active Ticker") the moment the widget is removed while the ticker is active — in debug/test builds, this is loud, not silent.
  • “AnimatedSwitcher animates any time its child property’s value looks different.” Disproven above: it only animates when the child’s identity (type + key) changes — two Text widgets with different strings but no key and the same type are the same identity to it, and it shows zero animation.
  • “Two Hero widgets can share a tag as long as they’re not both currently visible.” Disproven above: the assertion fires from simply having two Heroes with the same tag within one route’s subtree during a flight, regardless of which one is momentarily visible.
  • “TickerMode(enabled: false) is equivalent to calling .stop() on every controller inside it.” Disproven above: the controller’s own state (.value, whether it’s forward()-ing) is completely unchanged — only frame scheduling is muted; re-enabling TickerMode resumes exactly where it left off, which .stop() would not do.
What is an implicit animation?
Proven above with tester.pump(Duration), what does an AnimationController's .value do over its duration with Curves.linear?
Proven above with a real FlutterError, what happens when an AnimationController created via SingleTickerProviderStateMixin is never disposed?
Proven above with a real assertion, what happens when two Hero widgets on the same destination route share a tag?
Proven above with two widget tests, what actually determines whether AnimatedSwitcher animates a child change?
Proven above, what does TickerMode(enabled: false) actually change about a controller inside it?