Files
buzz/mobile/lib/shared/widgets/duo_toolbar.dart
Tom Brow 5f1cd67985 perf(mobile): stop rebuilding Home's channel list when a route covers it (#9459)
Opening any route or sheet above Home (search, a message's action menu,
a profile sheet, Activity, Settings) rebuilt Home's whole channel list
twice: once when the route started covering Home and once when it
finished. On a phone the open channel sits inside Home's conversation
pane, so a sheet over a channel (long-press menu, profile) did the same.
`HomePage` and `ChannelsPage` read `ModalRoute.of(context)` in their own
builds, which depends on every part of the route's state (whether it's
current, whether it's offstage, and so on). `ChannelsPage` also depended
on the whole `DuoToolbarScope`, whose `active` flag flips whenever a
route covers the pane, through `frostedAppBarHeight`. Every channel row
and the Solo section depended on `ConversationPaneScope` for the
selected channel, and that scope notified all of them when its
route-dependent `active` flag flipped. Each of those rebuilt a fresh
channel inventory, including Solo rows that had scrolled away, which
resubscribed their typing feeds for 30 seconds.

Now:

- Home's native controls (Activity bell, tab bar, Duo rail) read the
route in a small `_NativeControlsGate` leaf, so only they rebuild when
Home is covered.
- `ChannelsPage` mirrors route currency into its header suppression
notifier from a `_RouteCurrencySync` leaf above the header, so the
header still switches in the same frame. The two "wait until this route
is current again" loops read the same notifier.
- `DuoToolbarScope` is an `InheritedModel` with a strip-only lookup,
`maybeStripOf`. The frosted header's height and `ChannelsPage` use it,
so `active` changes no longer reach them. Every other reader keeps
`maybeOf` and its current behavior.
- `ConversationPaneScope`'s `active` flag lives in its own private
inherited widget, read only by `isActiveOf`. Rows that only need the
controller no longer rebuild when it flips.
- Opening a channel looks up the conversation pane instead of depending
on it.

### Validation

New test: opening and closing a sheet above Home rebuilds neither
`HomePage`, `ChannelsPage` nor any channel row. It fails without this
change.

Full mobile test suite, `flutter analyze` and the file-size check pass.

On the iPhone 12 mini (profile build, Builderlab):

Same scripted run on `main` and on this branch (search opened and closed
3 times, then a message's long-press menu and a profile sheet over an
open channel):

| | main | this branch |
| --- | --- | --- |
| Widget builds per search open | 126 to 350 | 56 to 60 |
| Build time per search open | 79 to 90 ms | 29 to 41 ms |
| Build time, long-press menu open | 64 ms | 17 ms |
| Typing subscriptions reopened by the sheets | 9 (about 80 KB of
messages downloaded) | 0 |

The menu, profile sheet, channel header and Home's tab bar and Activity
button behaved the same in both runs.

### Regressions

None expected. The native controls and header switch from the same route
state as before, in the same frame. A `DuoToolbarScope` strip change
still reaches the header and the channel list.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-13 14:40:22 -08:00

196 lines
6.5 KiB
Dart

import 'package:duo_kit/duo_kit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'ios_navigation_bar.dart';
/// Rail top clearance on Duo's inner display until UIKit reports its regions.
/// The package estimate describes the cover display's taller status cluster.
const double duoInnerDisplayEstimatedRailClearance = 110;
/// Whether Duo controls should use the compact cover arrangement.
bool isCompactDuoWindow(BuildContext context) {
// Subscribe to resizing even inside a pane whose MediaQuery is narrower.
MediaQuery.sizeOf(context);
final view = View.of(context);
return (view.physicalSize / view.devicePixelRatio).shortestSide < 600;
}
/// Carries window geometry through the independently sized conversation panes.
class DuoToolbarScope extends InheritedModel<Object> {
const DuoToolbarScope({
required this.strip,
required this.active,
this.contentInsets = EdgeInsets.zero,
this.leadingSlots = 0,
required super.child,
super.key,
});
final FoldStrip? strip;
final bool active;
final EdgeInsets contentInsets;
/// Space above detail actions for the workspace's persistent rail controls.
final int leadingSlots;
static const Object _stripAspect = #strip;
static DuoToolbarScope? maybeOf(BuildContext context) =>
InheritedModel.inheritFrom<DuoToolbarScope>(context);
/// The enclosing pane's [strip], depending on nothing else.
///
/// [active] flips whenever a route covers the pane, so geometry readers
/// such as headers and page lists use this to avoid rebuilding with it.
static FoldStrip? maybeStripOf(BuildContext context) =>
InheritedModel.inheritFrom<DuoToolbarScope>(
context,
aspect: _stripAspect,
)?.strip;
@override
bool updateShouldNotify(DuoToolbarScope oldWidget) =>
strip != oldWidget.strip ||
active != oldWidget.active ||
contentInsets != oldWidget.contentInsets ||
leadingSlots != oldWidget.leadingSlots;
@override
bool updateShouldNotifyDependent(
DuoToolbarScope oldWidget,
Set<Object> dependencies,
) => strip != oldWidget.strip;
}
/// Content clearance for a pane, or a full-window route outside a pane.
EdgeInsets duoContentInsetsOf(BuildContext context) {
final pane = DuoToolbarScope.maybeOf(context);
if (pane != null) return pane.contentInsets;
final strip = FoldGeometry.of(context).strip;
return EdgeInsets.only(
left: strip?.side == FoldStripSide.left ? strip!.width : 0,
right: strip?.side == FoldStripSide.right ? strip!.width : 0,
);
}
/// Projects the current route's controls into the window's side strip.
/// The portal retains route ownership and inherited providers for callbacks.
class DuoToolbarPortal extends HookConsumerWidget {
const DuoToolbarPortal({
required this.strip,
required this.active,
required this.actions,
required this.child,
super.key,
});
final FoldStrip? strip;
final bool active;
final List<IosNavigationAction> actions;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final portal = useMemoized(() => OverlayPortalController()..show());
return OverlayPortal(
overlayLocation: OverlayChildLocation.rootOverlay,
controller: portal,
overlayChildBuilder: (context) {
final strip = this.strip;
if (strip == null || actions.isEmpty) {
return const SizedBox.shrink();
}
final windowSize =
View.of(context).physicalSize / View.of(context).devicePixelRatio;
// The package fallback describes the cover display. The inner display's
// status cluster is shorter; keep this estimated until regions arrive.
final metrics = IosNavigationMetrics.of(context);
final measuredTop = metrics.windowSize == windowSize
? metrics.railTopClearance
: null;
final actionOffset =
(DuoToolbarScope.maybeOf(context)?.leadingSlots ?? 0) * 60.0;
final topClearance =
(measuredTop != null
? measuredTop + 12
: strip.isClearanceEstimated && windowSize.shortestSide >= 600
? duoInnerDisplayEstimatedRailClearance
: strip.topClearance) +
actionOffset;
return Positioned(
left:
IosNavigationMetrics.of(context).railCenter(
View.of(context).physicalSize /
View.of(context).devicePixelRatio,
strip.rect.center.dx,
) -
strip.width / 2,
top: strip.rect.top + topClearance,
width: strip.width,
child: Offstage(
offstage: !active,
child: MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
(strip.rect.height -
topClearance -
strip.bottomClearance -
220)
.clamp(0.0, double.infinity),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final action in actions)
SizedBox(
key: ValueKey('duo-toolbar-${action.label}'),
height: 60,
child: IosNavigationBar(
title: '',
standaloneControl: true,
leading: action,
),
),
],
),
),
),
),
),
);
},
child: child,
);
}
}
/// Marks sheets whose default Close action is supplied by the Duo rail.
class DuoSheetCloseScope extends InheritedWidget {
const DuoSheetCloseScope({
required this.inRail,
required super.child,
super.key,
});
final bool inRail;
static bool of(BuildContext context) =>
context
.dependOnInheritedWidgetOfExactType<DuoSheetCloseScope>()
?.inRail ??
false;
@override
bool updateShouldNotify(DuoSheetCloseScope oldWidget) =>
inRail != oldWidget.inRail;
}