Why does a Flutter AnimationController stop ticking when its screen is covered by another route, and what does TickerMode control?
answer
- Overlay mutes covered entries
- TickerMode enabled false in a subtree
- muted, not paused: time elapses
- IndexedStack does not mute
- only widget-aware tickers obey
basics
~20 sThe Navigator's Overlay wraps routes hidden under an opaque route in TickerMode(enabled: false), and State ticker mixins mute their tickers there. Muting skips callbacks but time still elapses, so the animation jumps ahead when the route is uncovered.
solid answer
~50 s`TickerMode` is an inherited widget that enables or disables tickers in its subtree. The ticker provider mixins read it and set their tickers' `muted` flag, so controllers created with `vsync: this` stop receiving callbacks while an ancestor `TickerMode` is disabled. The framework inserts it for you: the Navigator's `Overlay` builds entries hidden under an opaque route but kept alive by `maintainState` with tickers disabled, and `Visibility` (with `maintainState` but not `maintainAnimation`), `AnimatedCrossFade`'s hidden child and `CupertinoTabScaffold`'s inactive tabs do the same. Muted is not paused: the ticker's clock keeps running, so a finite animation that should have ended finishes on the first frame after unmuting, and a loop resumes at its current phase. `IndexedStack` does not mute hidden children, so tab shells built on it keep animating off-screen unless each child gets its own `TickerMode`.
code
dart · 20 linesimport 'package:flutter/material.dart';
class DictationTabs extends StatelessWidget {
const DictationTabs({super.key, required this.current, required this.tabs});
final int current;
final List<Widget> tabs;
@override
Widget build(BuildContext context) {
// IndexedStack keeps hidden tabs alive but does not mute their tickers.
return IndexedStack(
index: current,
children: <Widget>[
for (int i = 0; i < tabs.length; i++)
TickerMode(enabled: i == current, child: tabs[i]),
],
);
}
}go deeper
Recall that animations on a covered route stop ticking and that TickerMode is the widget behind it.
Explain how the ticker mixins read TickerMode, which framework widgets insert it, and why muted is not paused.
Diagnose jumps on return and hidden-tab battery drain, and choose explicit stop and restart or a TickerMode around IndexedStack children.
Decide an app-wide policy for off-screen motion, balancing resume fidelity against frames and battery spent on invisible work.
## The mechanism A **`Ticker`** calls its callback once per frame. It has a **`muted`** flag: while muted, the ticker skips its callbacks, but **its clock keeps running**. By convention the flag belongs to whoever created the ticker. For controllers built with `vsync: this`, that is the State's **`SingleTickerProviderStateMixin`** or **`TickerProviderStateMixin`**, and those mixins set `muted` from the nearest **`TickerMode`** ancestor. **`TickerMode`** is a widget with `enabled` (required), `forceFrames` (default false) and `child`. When `enabled` is false, every widget-aware ticker in the subtree is muted, whatever ancestors say; when true, tickers run only if no ancestor disables them. The mixins subscribe to changes, and re-read the ancestor in `activate` when a widget moves, so muting follows the tree automatically. `forceFrames: true` makes tickers request frames even when frames would normally not be scheduled, such as with the screen off, at a real battery cost. ## Where the framework inserts it | Widget | Disables tickers for | |---|---| | `Overlay`, and so `Navigator` | entries hidden below an opaque entry and kept by `maintainState` — covered routes | | `Visibility` | a hidden child with `maintainState: true` and `maintainAnimation: false` | | `AnimatedCrossFade` | the child underneath, once the cross-fade stops | | `CupertinoTabScaffold` | inactive tabs | | `IndexedStack` | **nothing** — hidden children keep ticking | ## Muted is not paused Because the clock keeps running, a muted `AnimationController` behaves like one whose frames were simply not drawn: 1. The dictation screen's record button is mid-pulse when the user opens Settings, an opaque route. 2. The Overlay disables tickers for the dictation route; the pulse ticker is muted. The controller still reports `isAnimating` as true, because its ticker remains active. 3. Thirty seconds later the user returns. The ticker is unmuted, and its next tick reports thirty seconds of elapsed time. 4. A `repeat` pulse picks up at the phase thirty seconds would have reached. A finite `forward()` that should have ended long ago jumps to its end and completes on that first frame. If the animation must resume from where it visibly stopped, pause it yourself — `stop()` it when leaving and restart it when returning — rather than relying on muting. ## The IndexedStack gap Tab shells often keep every tab alive in an **`IndexedStack`**, which paints only the selected child but leaves the others in the tree. It does not insert `TickerMode`, so a looping animation in a hidden tab keeps requesting frames and running callbacks. Wrap each child yourself: ```dart IndexedStack( index: current, children: [ for (var i = 0; i < tabs.length; i++) TickerMode(enabled: i == current, child: tabs[i]), ], ) ``` ## When muting does not apply - **Tickers not created by a widget-aware provider** — a bare `Ticker`, or a controller given a custom `TickerProvider` — ignore `TickerMode` entirely. - **A backgrounded app** is a separate mechanism: when the app is hidden or paused, the scheduler stops producing frames, so no ticker fires, muted or not, until it resumes. ## Diagnosing a "frozen" or "jumping" animation - **Frozen on return, then leaps** — muting under a covered route or hidden `Visibility`; restart it explicitly if a smooth resume matters. - **Battery drain from a hidden tab** — an `IndexedStack` child still animating; add `TickerMode`. - **Still running when covered** — the controller was not built with a State ticker mixin, so nothing mutes it. ## Muting versus stopping | Goal | Approach | |---|---| | Save frames while hidden, accept a jump on return | rely on `TickerMode` muting | | Resume exactly where the user left it | `stop()` on leaving, `forward()` or `repeat()` on return | | Keep animating while hidden (rare) | a controller outside any disabled `TickerMode` subtree | | Hidden tab in an `IndexedStack` | wrap it in `TickerMode(enabled: isSelected)` | For the record button, a pulse that jumps phase on return is invisible to users, so muting is enough; a one-shot onboarding animation that should replay from the start is better stopped and restarted explicitly. ## API note `TickerMode.of` and `TickerMode.getNotifier` are deprecated (after v3.35.0-0.0.pre) in favour of `TickerMode.valuesOf` and `TickerMode.getValuesNotifier`, which return a `TickerModeData` carrying both `enabled` and `forceFrames`.
- Does a muted controller report isAnimating as false?No. `isAnimating` reflects whether the controller's ticker is active, and muting does not deactivate it. The controller documentation calls this out: a muted controller's callbacks stop firing while time continues to pass, and it still counts as animating.
- Why do the ticker mixins re-read TickerMode in activate?A State can be moved to a new place in the tree, for example with a GlobalKey, and the new location may have a different `TickerMode` ancestor. `activate` runs after such a move, so the mixins resubscribe and update `muted` for the new ancestor.
saying these in an interview costs you the question
- A muted ticker pauses its clock and resumes exactly where it stopped.
- IndexedStack automatically stops animations in hidden children.
- TickerMode affects every Ticker, including ones created by hand.
- A covered route's controllers keep ticking until the route is popped.
- forceFrames is free to leave on for smoother animations.