skip to content

In a Flutter StatefulWidget, how do you create, start, stop and dispose an AnimationController for a pulsing record button?

level: juniorimportance: must knowfreq 60%

answer

  1. ticker mixin on the State
  2. create in initState
  3. repeat(reverse: true) to pulse
  4. react to the flag in didUpdateWidget
  5. dispose before super.dispose()

basics

~20 s

Mix a ticker provider into the State, create the controller in initState with vsync: this and a duration, call repeat(reverse: true) while recording, stop or animate it back when recording ends, and dispose it in dispose before super.dispose().

solid answer

~40 s

The controller lives in the `State`, because it must survive rebuilds: mix in `SingleTickerProviderStateMixin`, then in `initState` create `AnimationController(vsync: this, duration: const Duration(milliseconds: 900))`. To pulse, call `repeat(reverse: true)`, which runs from 0 to 1 and back indefinitely. The button's `recording` flag arrives as a widget property, so compare it in `didUpdateWidget` and start the pulse or stop it; `animateBack(0.0)` eases the button back to rest instead of freezing it mid-pulse. Feed the controller to a transition such as `ScaleTransition` through a `Tween` so only that widget repaints. Finally, `dispose()` the controller in `dispose` before `super.dispose()`; otherwise the mixin reports that the State was disposed with an active Ticker.

code

dart · 54 lines
dart
import 'package:flutter/material.dart';

class RecordButton extends StatefulWidget {
  const RecordButton({super.key, required this.recording, required this.onPressed});

  final bool recording;
  final VoidCallback onPressed;

  @override
  State<RecordButton> createState() => _RecordButtonState();
}

class _RecordButtonState extends State<RecordButton> with SingleTickerProviderStateMixin {
  late final AnimationController _pulse;

  @override
  void initState() {
    super.initState();
    _pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 900));
    if (widget.recording) {
      _pulse.repeat(reverse: true);
    }
  }

  @override
  void didUpdateWidget(RecordButton oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.recording == oldWidget.recording) {
      return;
    }
    if (widget.recording) {
      _pulse.repeat(reverse: true);
    } else {
      _pulse.animateBack(0.0, duration: const Duration(milliseconds: 200));
    }
  }

  @override
  void dispose() {
    _pulse.dispose(); // before super.dispose()
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: Tween<double>(begin: 1.0, end: 1.15).animate(_pulse),
      child: FloatingActionButton(
        onPressed: widget.onPressed,
        child: Icon(widget.recording ? Icons.stop : Icons.mic),
      ),
    );
  }
}

go deeper

for a junior

Recall the pieces: ticker mixin, controller in initState with vsync and duration, repeat to loop, dispose before super.dispose().

for a middle

Explain why the controller must live in State, how didUpdateWidget reacts to new flags, and stop versus animateBack versus reset.

for a senior

Avoid per-frame rebuilds by rendering through transitions, and make start and stop idempotent so rapid toggles never stack or stutter.

for a principal

Wrap recurring motion like this pulse in a reusable widget so teams share one correct lifecycle instead of re-deriving it.

## Why the controller belongs to the State A **`StatefulWidget`** is an immutable description; its **`State`** object persists across rebuilds. An **`AnimationController`** holds a running animation — its value, direction and ticker — so it must live in the `State`. Creating it in `build` would start a new animation, and leak a ticker, every time the widget rebuilds. ## Step by step 1. **Mix in a ticker provider.** `class _RecordButtonState extends State<RecordButton> with SingleTickerProviderStateMixin` — one controller, so the single mixin. 2. **Create in `initState`.** After `super.initState()`, build the controller with `vsync: this` and a `duration`. A `late final` field initialiser also works, since it runs on first access after the State is mounted. 3. **Start it.** For a pulse, `repeat(reverse: true)`: 0 to 1, then 1 to 0, forever. Without `reverse: true` it would jump from 1 back to 0 each cycle. 4. **Render it.** Pass the controller, or an `Animation` derived from it with a `Tween`, to a transition widget such as `ScaleTransition`. The transition listens to the controller and repaints each frame; the rest of the button does not rebuild. 5. **React to new configuration.** When the parent flips `recording`, `didUpdateWidget(oldWidget)` runs. Compare `widget.recording` with `oldWidget.recording` and call `repeat` or wind the pulse down. 6. **Stop it gracefully.** `stop()` freezes the value where it is, which can leave the button mid-pulse at 1.1× its size. `animateBack(0.0, duration: …)` eases it back to rest; `reset()` jumps straight to 0. 7. **Dispose.** In `dispose`, call `_pulse.dispose()` and then `super.dispose()`. ## Key controller facts used here | Member | Behaviour | |---|---| | `AnimationController(vsync:, duration:)` | `value` starts at `lowerBound` (0.0) unless you pass `value` | | `repeat(reverse: true)` | runs indefinitely; `period` defaults to `duration`; `count` limits iterations | | `stop()` | halts without changing `value` or status | | `animateBack(target, duration:)` | runs towards `target` with status `reverse` | | `reset()` | sets `value` to `lowerBound`, status `dismissed` | | `dispose()` | releases the ticker; the controller cannot be used afterwards | ## What goes wrong without dispose When a `State` using a ticker mixin is disposed, the mixin checks its tickers. If the controller is still animating — a `repeat` pulse always is — the debug build throws a `FlutterError` whose summary says the State **"was disposed with an active Ticker"**, with the hint that tickers used by controllers should be disposed by calling `dispose()` on the controller. The order matters for the same reason: calling `super.dispose()` first runs that check while the ticker is still active. ## A dictation-app flow - The user taps record; the parent sets `recording: true`; `didUpdateWidget` calls `_pulse.repeat(reverse: true)` and the button breathes. - The user taps stop; the parent sets `recording: false`; `didUpdateWidget` calls `_pulse.animateBack(0.0, duration: const Duration(milliseconds: 200))` and the button settles. - The user leaves the screen; the State is disposed and so is the controller. ## Common mistakes - **Creating the controller in `build`** — a new controller and ticker on every rebuild. - **Starting `repeat` in `build`** — every rebuild restarts the cycle from the current value, so the pulse stutters. - **Reading `widget.recording` only in `initState`** — the State outlives configuration changes, so later flips are missed without `didUpdateWidget`. - **Driving the scale with `setState` from a listener** — it rebuilds the whole button every frame; a transition widget repaints only what moves. - **Awaiting the `TickerFuture` from `repeat()`** — it never completes unless `count` is given, so code after the `await` never runs. ## Making start and stop robust - **Compare before acting.** `didUpdateWidget` runs on every parent rebuild; act only when `recording` actually flipped, or each unrelated rebuild restarts the pulse. - **Calling `repeat()` again** stops the current run and starts a new repeating one from the current value, so a duplicate call is harmless but wasteful. - **Winding down twice** is harmless too: `animateBack(0.0, duration: …)` at a value already 0.0 completes immediately without animating. - **Leaving the screen mid-pulse** needs nothing extra — `dispose` stops and releases the ticker.

  • Why not call stop() when recording ends?
    `stop()` halts the ticker without changing `value`, so the button can freeze at any point of the pulse, for example 15% larger than normal. `animateBack(0.0)` runs it back to rest, and `reset()` jumps to 0 at once if an instant return is acceptable.
  • What does repeat() without reverse: true look like for a pulse?
    Each cycle runs from `lowerBound` to `upperBound` and then restarts at `lowerBound`, so the button grows smoothly and then snaps back to its small size. For a breathing pulse, `reverse: true` alternates direction so it grows and shrinks smoothly.

saying these in an interview costs you the question

  • Create the controller in build so it picks up the latest duration.
  • The ticker mixin disposes the controller when the State is disposed.
  • stop() returns the animation to its starting value.
  • Call super.dispose() first, then dispose the controller.
  • Await repeat() to run code after the pulse finishes.