In a Flutter StatefulWidget, how do you create, start, stop and dispose an AnimationController for a pulsing record button?
answer
- ticker mixin on the State
- create in initState
- repeat(reverse: true) to pulse
- react to the flag in didUpdateWidget
- dispose before super.dispose()
basics
~20 sMix 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 sThe 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 linesimport '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
Recall the pieces: ticker mixin, controller in initState with vsync and duration, repeat to loop, dispose before super.dispose().
Explain why the controller must live in State, how didUpdateWidget reacts to new flags, and stop versus animateBack versus reset.
Avoid per-frame rebuilds by rendering through transitions, and make start and stop idempotent so rapid toggles never stack or stutter.
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.