When a Flutter dashboard's rotating sync indicator janks on low-end phones and profiling shows the whole page rebuilding every frame, how do you trace and fix it?
answer
- find who listens to the controller
- setState high in the tree
- builder wrapping the whole Scaffold
- animations created inside build
- shrink the listener to the icon
basics
~20 sFind what listens to the controller: usually a page-level setState listener or an AnimatedBuilder wrapped around the whole Scaffold. Move the listening down to the indicator with RotationTransition or a small AnimatedBuilder with a pre-built child, and build animations once in initState.
solid answer
~40 sI profile a profile-mode build and check which widgets rebuild each frame; if the page's own widget is among them, something at page level is listening to the ticking controller. The usual culprits: `addListener(() => setState(() {}))` in the page `State`; an `AnimatedBuilder` wrapped around the whole `Scaffold` whose builder returns the page; a subtree built inside a builder instead of passed as `child`; and `drive(...)` or `CurvedAnimation` created in `build`, which hands the transition a new `Animation` each rebuild. The fix is to move the animation to the leaf: `RotationTransition(turns: _controller, child: icon)`, or a small `SyncIndicator` widget that owns its own controller. Then I re-profile to confirm only the indicator's transition rebuilds per tick and the frame times recover.
code
dart · 51 linesimport 'package:flutter/material.dart';
/// Owns its own controller, so the dashboard never rebuilds per tick.
class SyncIndicator extends StatefulWidget {
const SyncIndicator({super.key, required this.syncing});
final bool syncing;
@override
State<SyncIndicator> createState() => _SyncIndicatorState();
}
class _SyncIndicatorState extends State<SyncIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
if (widget.syncing) _controller.repeat();
}
@override
void didUpdateWidget(SyncIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.syncing != oldWidget.syncing) {
if (widget.syncing) {
_controller.repeat();
} else {
_controller.stop();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _controller,
child: const Icon(Icons.sync, size: 20),
);
}
}go deeper
Recall that a ticking controller notifies once per frame, so whatever listens to it rebuilds once per frame.
Explain how setState listeners, oversized AnimatedBuilders and builder-built subtrees each widen the rebuild, and how transitions and the child parameter narrow it.
Drive the investigation with profiling, move the controller into a small owning widget, build animation objects once, and prove the fix by re-measuring.
Treat per-frame rebuild scope as a reviewable property of animated UI and decide where animation state may live so low-end devices are protected by default.
## The symptom A dashboard shows a rotating *sync* icon while data refreshes. On a mid-range phone it is smooth; on low-end phones the whole screen stutters while it spins. Profiling shows the page widget - and everything under it - rebuilding once per frame. A rotating icon should cost almost nothing per frame, so the finding means the **rebuild scope** is wrong: something high in the tree is listening to the ticking animation. ## Tracing the listener Work from the controller outwards and ask *who is subscribed to it?* 1. **Search for `addListener` on the controller.** A listener that calls `setState` in the page's `State` rebuilds the page's whole `build` on every tick. 2. **Look for an `AnimatedBuilder` (or `ListenableBuilder`) high in the tree.** One wrapped around a `Scaffold` whose `builder` returns the whole page is the same bug in builder form. 3. **Inspect the builder body.** Even a well-placed `AnimatedBuilder` rebuilds everything constructed inside its `builder`; a card or list built there instead of passed as `child` is rebuilt every frame. 4. **Check for animation objects created in `build`.** `controller.drive(...)` or `CurvedAnimation(...)` inside `build` produces a new `Animation` on every rebuild. A transition widget then sees a different `listenable` in `didUpdateWidget` and moves its subscription each time, and a `CurvedAnimation` built this way also leaves a status listener on the controller. 5. **Check the indicator itself.** An `Opacity` or `Transform` driven from a builder is rebuilt per tick; `FadeTransition` needs no rebuild at all. Confirm each suspicion by re-profiling rather than by reading code alone; a rebuild count that drops from *the whole page* to *one widget* is the proof. ## The fix, in order of preference | Fix | Per-tick rebuild after the fix | |---|---| | Replace the page-level listener with `RotationTransition(turns: _controller, child: icon)` | the `RotationTransition` only | | Move the controller into a dedicated `SyncIndicator` widget with its own `State` | that small widget's transition only | | Keep an `AnimatedBuilder`, but wrap only the icon and pass the icon as `child` | the `Transform` returned by the builder | | Build `drive(...)` pipelines and `CurvedAnimation`s once in `initState` | removes resubscription churn on page rebuilds | The dedicated widget is usually best: the page stops owning animation state entirely, the indicator can be reused, and its controller's lifecycle - start when syncing begins, stop and dispose when it ends - lives in one place. ## Things that do not fix it - **Lengthening the controller's `duration`.** A running controller still ticks once per frame; a slower spin rebuilds just as often. - **Wrapping the page in a `RepaintBoundary`.** That can limit what is repainted, but it does nothing about the build phase that is actually over budget here. - **Throttling `setState` with a timer.** It trades jank for a choppy animation and keeps the page-level rebuild. - **Moving work to another isolate.** Widget builds run on the UI isolate by design; the cure is building less, not building elsewhere. ## What to say in the interview Name the chain: *profile, find the listener, shrink the scope, verify*. Then show you know the tools the framework gives for the shrinking - transition widgets, `AnimatedBuilder` with `child`, and `FadeTransition` for opacity at the render-object level - and that animation objects are built once, not per `build`. ## Preventing the regression Once fixed, keep it fixed. Two habits help: animation controllers live in the **smallest widget that needs them**, never in a page or screen `State` unless the page itself animates; and every `drive(...)` pipeline or `CurvedAnimation` is created in `initState` or as a `late final` field, never in `build`. A widget test that pumps a few frames of the spinning indicator and asserts the page's build method ran once - for example with a counter in a test double - turns the rule into something CI can check.
- Why does creating controller.drive(...) inside build() cause extra work even with a transition widget?Each `build` returns a new `Animation` object, and `Animation`s compare by identity. The transition's hidden `State` sees a different `listenable` in `didUpdateWidget` and moves its listener from the old object to the new one on every rebuild. Build the pipeline once in `initState` so the same `Animation` is passed each time.
- The fix is in, but frames still drop while the icon spins. Where do you look next?If only the indicator's transition rebuilds per tick, the remaining cost is no longer build scope. Check whether the frame is over budget in the raster phase rather than the UI phase, and whether something else is animating at the same time. Those investigations belong to raster and general performance tooling rather than to the animation widgets.
saying these in an interview costs you the question
- A longer controller duration means fewer rebuilds per second.
- A RepaintBoundary around the page stops it from rebuilding.
- The page must rebuild each frame because the icon is part of it.
- Creating drive() pipelines inside build() is free because Animations are lightweight.
- Moving the animation to another isolate will remove the jank.