In Flutter, how do you run code when an AnimationController finishes, and why might a status listener or an awaited TickerFuture never fire?
answer
- addStatusListener, not addListener
- four AnimationStatus values
- repeat never completes
- stop cancels the TickerFuture
- orCancel and whenCompleteOrCancel
basics
~20 sAdd a status listener and check for completed or dismissed, or use the TickerFuture a run returns. repeat() never completes, and stop() or dispose() cancels a run, so its future never resolves; use orCancel or whenCompleteOrCancel to handle both outcomes.
solid answer
~40 sAn `AnimationController` has two listener kinds: `addListener` fires on every value change, and `addStatusListener` fires only when `status` changes among `dismissed`, `forward`, `reverse` and `completed`. For "run this when the pulse winds down", listen for `completed` or `dismissed`, or use the `TickerFuture` returned by `forward`, `reverse`, `animateTo` and friends. Pitfalls: `repeat()` never reaches `completed` — with `reverse: true` its status alternates between `forward` and `reverse` — and its future never completes unless you pass `count`. `stop()`, which cancels by default, and `dispose()` leave the run's future unresolved forever, so a bare `await` hangs; `orCancel` gives a future that errors with `TickerCanceled` instead, and `whenCompleteOrCancel` runs a callback either way. Status listeners fire only on a change, and `stop()` changes nothing.
code
dart · 61 linesimport 'package:flutter/material.dart';
class SavedPulse extends StatefulWidget {
const SavedPulse({super.key});
@override
State<SavedPulse> createState() => _SavedPulseState();
}
class _SavedPulseState extends State<SavedPulse> with SingleTickerProviderStateMixin {
late final AnimationController _pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
bool _showSaved = false;
@override
void initState() {
super.initState();
_pulse.addStatusListener((AnimationStatus status) {
debugPrint('pulse status: $status');
});
}
void start() {
setState(() => _showSaved = false);
_pulse.repeat(reverse: true); // status alternates forward/reverse, never completed
}
Future<void> finish() async {
try {
await _pulse.animateBack(0.0, duration: const Duration(milliseconds: 200)).orCancel;
if (mounted) {
setState(() => _showSaved = true);
}
} on TickerCanceled {
// A new start() or dispose() interrupted the wind-down.
}
}
@override
void dispose() {
_pulse.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ScaleTransition(
scale: Tween<double>(begin: 1.0, end: 1.15).animate(_pulse),
child: IconButton(onPressed: start, icon: const Icon(Icons.mic)),
),
TextButton(onPressed: finish, child: const Text('Stop')),
if (_showSaved) const Text('Saved'),
],
);
}
}go deeper
Recall the four AnimationStatus values and that addStatusListener, not addListener, reports completion.
Explain why repeat never completes, why stop is silent, and how orCancel and whenCompleteOrCancel handle canceled runs.
Write interruption-safe sequencing: orCancel with TickerCanceled handling and mounted checks, instead of bare awaits that can hang.
Standardise how components expose animation completion so callers never depend on fragile awaits of ticker futures.
## Two kinds of listener An **`AnimationController`** is both a `Listenable` and an `Animation<double>`, and exposes two notification streams: - **`addListener(callback)`** — called whenever `value` changes, which is every frame while it runs. - **`addStatusListener(callback)`** — called with the new **`AnimationStatus`** whenever the status changes. `AnimationStatus` has four values, plus convenience getters such as `isCompleted`, `isDismissed`, `isAnimating` and `isForwardOrCompleted`: | Status | Meaning | |---|---| | `dismissed` | stopped at the beginning (`lowerBound`, or the end of a reverse run) | | `forward` | running towards the end | | `reverse` | running towards the beginning | | `completed` | stopped at the end (`upperBound`, or the end of a forward run) | The controller reports a status only when it **differs** from the last one reported, so listeners never see duplicates. ## Reacting to the end of a run Two tools, each with a trap: 1. **A status listener** — `controller.addStatusListener((status) { if (status.isCompleted) … })`. Register it once, typically in `initState`; the controller clears its listeners when disposed. 2. **The `TickerFuture`** returned by `forward`, `reverse`, `toggle`, `animateTo`, `animateBack` and `repeat`. It completes when the run finishes normally. ## Why nothing fires - **`repeat()` never finishes.** Without `count`, its `TickerFuture` never completes and status never becomes `completed`. With `reverse: true` status alternates `forward`/`reverse`; without it, it stays `forward`. Code waiting for completion simply never runs. - **`stop()` is silent.** It halts the ticker without changing value or status, so no status listener fires. By default it **cancels**: the run's `TickerFuture` then never completes. - **`dispose()` mid-run** also leaves the future unresolved, and clears all listeners. - **A new run replaces the old one.** Calling `forward()` while a `reverse()` is in flight stops the first run, so its future never resolves. - **Setting `value`** notifies status listeners whenever the recomputed status differs, for example when the new value lands on a bound, which can surprise code that expected only natural completion. ## Handling both outcomes A **`TickerFuture`** has two helpers for exactly this: - **`orCancel`** — a derived `Future<void>` that completes normally, or **fails with `TickerCanceled`** if the run was stopped with `canceled: true` or the ticker was disposed. - **`whenCompleteOrCancel(callback)`** — runs `callback` in either case. The ticker's documentation warns that a `TickerFuture` should generally not be awaited directly, because a canceled or disposed run leaves the `await` pending forever. ## A dictation-app example When recording stops, the record button winds its pulse down and then shows a "Saved" label: ```dart Future<void> _finishRecording() async { try { await _pulse.animateBack(0.0, duration: const Duration(milliseconds: 200)).orCancel; if (mounted) setState(() => _showSaved = true); } on TickerCanceled { // The user started a new recording, or the screen closed. } } ``` If the user taps record again during the wind-down, `repeat` replaces the run and the `await` exits through `TickerCanceled` instead of hanging. The `mounted` check guards the case where the screen closed as the animation finished. ## Choosing the tool - **A status listener** suits behaviour that should follow every end of a run, such as chaining another animation whenever this one completes. - **`orCancel` with `await`** suits one-off sequencing in an `async` method, with a clean path for interruption. - **`addListener`** is for per-frame work; for UI, prefer a transition widget over a listener that calls `setState`. ## Common mistakes - **Adding a status listener in `build`** — every rebuild registers another one, so the callback runs several times per completion. - **Checking `status == AnimationStatus.completed` after `animateBack`** — a backward run ends in `dismissed`; use `isDismissed` there. - **Treating `completed` as "value is 1.0"** — `animateTo(0.3)` also ends `completed`, because the status follows the direction of the run. - **Calling `setState` after an awaited run without checking `mounted`** — the screen may have closed while the animation played.
- Where does TickerCanceled come from, and do you need an extra import to catch it?`TickerCanceled` is the exception `orCancel` completes with when the run is canceled or its ticker disposed. It is defined in `package:flutter/scheduler.dart`, but `package:flutter/animation.dart` re-exports it and the widgets and material libraries export that, so a normal `material.dart` import is enough.
- How would you make a repeat-based pulse finish after three beats and then run code?Pass `count: 3` to `repeat`. With a count, the returned `TickerFuture` completes after the last iteration and the controller settles in `completed` or `dismissed` depending on the final direction, so both the future and a status listener fire.
saying these in an interview costs you the question
- addListener fires only when the animation completes.
- A repeat(reverse: true) animation reports completed after each cycle.
- stop() moves the status to dismissed and notifies listeners.
- Awaiting forward() is always safe because it completes on stop.
- Status listeners fire every frame the status is forward.