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.
Summary
Shell branches are kept mounted in an
IndexedStack, but nothing disables their tickers. EveryAnimationControllerin 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
Overlaydoes disable tickers for this exact case. Routes kept alive under an opaque route gettickerEnabled: false:which reaches the subtree via
_OverlayEntryWidgetState.build:ModalRoute.maintainStatedefaults totrueandMaterialPageRoute.opaqueistrue, 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
TickerModeanywhere inlib/. Three stacks keep branches mounted:lib/src/kaisel_shell.dart:258lib/src/kaisel_branched_shell.dart:822— the eagerIndexedStack(lazy: false, the default)lib/src/kaisel_branched_shell.dart:881—_LazyKeepAliveStackWhat 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
markNeedsPaintbookkeeping running at display rate for the whole session.The raster cost is already avoided: a hidden
IndexedStackchild's layer is unattached, soPipelineOwner.flushPainttakes 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
CustomPainteron its landing screen. The tickers run on all five tabs.Suggested fix
Wrap each child in
TickerModekeyed on the active index, at all three sites:Worth considering whether this should be opt-out. Arguments for defaulting it on: it matches
Overlay,TickerModegates onlyTickers (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.scopewraps 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 againstcontext.shell().activeBranch.Versions
kaisel 1.0.0, Flutter 3.47.4 stable.