skip to content

In Flutter, how do you run code when an AnimationController finishes, and why might a status listener or an awaited TickerFuture never fire?

level: middleimportance: should knowfreq 40%

answer

  1. addStatusListener, not addListener
  2. four AnimationStatus values
  3. repeat never completes
  4. stop cancels the TickerFuture
  5. orCancel and whenCompleteOrCancel

basics

~20 s

Add 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 s

An `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 lines
dart
import '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

for a junior

Recall the four AnimationStatus values and that addStatusListener, not addListener, reports completion.

for a middle

Explain why repeat never completes, why stop is silent, and how orCancel and whenCompleteOrCancel handle canceled runs.

for a senior

Write interruption-safe sequencing: orCancel with TickerCanceled handling and mounted checks, instead of bare awaits that can hang.

for a principal

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.