skip to content

Why does a Flutter AnimationController stop ticking when its screen is covered by another route, and what does TickerMode control?

level: seniorimportance: should knowfreq 25%

answer

  1. Overlay mutes covered entries
  2. TickerMode enabled false in a subtree
  3. muted, not paused: time elapses
  4. IndexedStack does not mute
  5. only widget-aware tickers obey

basics

~20 s

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

for a junior

Recall that animations on a covered route stop ticking and that TickerMode is the widget behind it.

for a middle

Explain how the ticker mixins read TickerMode, which framework widgets insert it, and why muted is not paused.

for a senior

Diagnose jumps on return and hidden-tab battery drain, and choose explicit stop and restart or a TickerMode around IndexedStack children.

for a principal

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.