Skip to content

Shell branches keep their tickers running while inactive #92

Description

@Mastersam07

Summary

Shell branches are kept mounted in an IndexedStack, but nothing disables their tickers. Every AnimationController in every inactive branch keeps ticking for the life of the shell, so an app with a shimmer, a spinner or any looping animation on one tab never lets the scheduler idle, no matter which tab the user is on.

Flutter's own Overlay does disable tickers for this exact case. Routes kept alive under an opaque route get tickerEnabled: false:

// packages/flutter/lib/src/widgets/overlay.dart, _OverlayState.build
for (final OverlayEntry entry in _entries.reversed) {
  if (onstage) {
    children.add(_OverlayEntryWidget(key: entry._key, overlayState: this, entry: entry));
    if (entry.opaque) {
      onstage = false;
    }
  } else if (entry.maintainState) {
    children.add(
      _OverlayEntryWidget(key: entry._key, overlayState: this, entry: entry, tickerEnabled: false),
    );
  }
}

which reaches the subtree via _OverlayEntryWidgetState.build:

return TickerMode(
  enabled: widget.tickerEnabled,
  child: _RenderTheaterMarker(...),
);

ModalRoute.maintainState defaults to true and MaterialPageRoute.opaque is true, so pushing a full-screen route already stops animations on the page beneath while preserving its state. A mounted-but-hidden shell branch is the same situation, and kaisel currently leaves those tickers running.

Where

No TickerMode anywhere in lib/. Three stacks keep branches mounted:

  • lib/src/kaisel_shell.dart:258
  • lib/src/kaisel_branched_shell.dart:822 — the eager IndexedStack (lazy: false, the default)
  • lib/src/kaisel_branched_shell.dart:881 — _LazyKeepAliveStack

What it costs

Flutter only schedules frames while something is animating. One looping animation on any mounted branch keeps the vsync callback, the tick and the markNeedsPaint bookkeeping running at display rate for the whole session.

The raster cost is already avoided: a hidden IndexedStack child's layer is unattached, so PipelineOwner.flushPaint takes the _skippedPaintingOnLayer() path rather than repainting. So this is a "the app never goes idle" problem rather than a dropped-frames one, which makes it a battery and thermal issue rather than a jank one.

Found it on a five-branch shell where the first branch has a permanently looping CustomPainter on its landing screen. The tickers run on all five tabs.

Suggested fix

Wrap each child in TickerMode keyed on the active index, at all three sites:

IndexedStack(
  index: _shell.activeBranch,
  children: [
    for (final (i, branch) in restorableBranches.indexed)
      TickerMode(enabled: i == _shell.activeBranch, child: branch),
  ],
)

Worth considering whether this should be opt-out. Arguments for defaulting it on: it matches Overlay, TickerMode gates only Tickers (timers, streams and in-flight futures are untouched), and an animation that keeps running where nobody can see it is almost never intended. An app that genuinely needs one could opt out per branch.

Workaround

None that is clean from app code. KaiselBranchSpec.scope wraps a branch regardless of whether it is active, and a branch cannot learn its own index from the spec, so honouring this app-side means hardcoding indices against context.shell().activeBranch.

Versions

kaisel 1.0.0, Flutter 3.47.4 stable.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions