fix(mobile): stop Settings rows blinking while Settings opens (#8463)

## Summary

On iOS, opening Settings made the Status, Profile and Notifications
cards disappear for most of the slide-up. They came back when the
transition finished. Closing Settings did the same thing.

**Cause:** Flutter's iOS compositor (`flow/view_slicer.cc`) puts Flutter
content that's painted after a platform view and overlaps no native view
into the background canvas. During the transition, Home's native nav bar
and glass tab bar are still on screen under the full-screen Settings
page, so the overlay created for Home's tab bar already covers the whole
background. Everything painted after the native presence pill, outside a
native view's rect, was hidden behind it. "Send identity to desktop"
stayed visible only because it overlaps Home's tab bar.

**Fix:** `IosPresenceMenu` mounts its native view only after its route
settles (animation completed, nothing covering it). During route
transitions, and while the native view is being created, it draws a
matching Flutter stand-in: same capsule, colors, SF Pro Text at the same
size and weight.

### Related issue

N/A, none found. Reported by Wes from a screen recording.

### Testing

- Reproduced in an uncommitted harness on an iOS 27 simulator: Home with
native nav bar and glass tabs, then `BottomUpPageRoute`, then a
Settings-shaped page using the real `IosPresenceMenu`. The cards blank
out on every open and close.
- A/B in the same harness: the blink goes away if any one of the three
is removed (native pill, Home nav bar, Home glass tabs).
- With this change, the open and close cycles in the harness show no
blank frames. At rest the native pill is mounted again. Its pixels are
identical to before, and the stand-in matches it within about 1px of
text rasterization.
- `settings_overlay_test.dart` now checks that the native pill isn't
mounted mid-transition, and that exactly one native view is created
after settling. The new assertion fails without the fix.
- Pre-push mobile checks passed: analyze, format, full mobile test
suite.

**Human test:** on iOS, open and close Settings from Home a few times.
Every card should stay visible throughout, and the "Online" pill should
open the native availability menu once Settings has settled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-10-19 15:09:17 -07:00
committed by GitHub
parent b6aa5539e3
commit bc9e7de904
2 changed files with 118 additions and 20 deletions

View File

@@ -26,6 +26,37 @@ class IosPresenceMenu extends StatefulWidget {
class _IosPresenceMenuState extends State<IosPresenceMenu> {
MethodChannel? _channel;
ModalRoute<Object?>? _route;
bool _routeSettled = false;
bool _nativeVisible = false;
// iOS composites Flutter content painted after a platform view outside
// every native view rect into the background, beneath earlier overlays.
// While Home's native bars still show through a route transition, that
// hides the Settings rows below this pill, so the native view mounts only
// once its route has settled and a drawn stand-in covers the transitions.
static bool _isSettled(ModalRoute<Object?>? route) =>
(route?.animation?.isCompleted ?? true) &&
(route?.secondaryAnimation?.isDismissed ?? true);
void _syncRouteSettled([AnimationStatus? _]) {
final settled = _isSettled(_route);
if (settled == _routeSettled) return;
if (!settled) {
_channel?.setMethodCallHandler(null);
_channel = null;
_nativeVisible = false;
}
setState(() => _routeSettled = settled);
}
void _listenToRoute(ModalRoute<Object?>? route) {
_route?.animation?.removeStatusListener(_syncRouteSettled);
_route?.secondaryAnimation?.removeStatusListener(_syncRouteSettled);
_route = route;
route?.animation?.addStatusListener(_syncRouteSettled);
route?.secondaryAnimation?.addStatusListener(_syncRouteSettled);
}
Map<String, Object> _configuration(BuildContext context) => {
'presence': widget.presence,
@@ -36,6 +67,19 @@ class _IosPresenceMenuState extends State<IosPresenceMenu> {
'fontSize': MediaQuery.textScalerOf(context).scale(15),
};
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null && identical(route, _route)) return;
_listenToRoute(route);
// A newly pushed route can still report a completed animation while it
// first builds, so decide once that frame has been laid out.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _syncRouteSettled();
});
}
@override
void didUpdateWidget(covariant IosPresenceMenu oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -44,6 +88,7 @@ class _IosPresenceMenuState extends State<IosPresenceMenu> {
@override
void dispose() {
_listenToRoute(null);
_channel?.setMethodCallHandler(null);
super.dispose();
}
@@ -65,27 +110,65 @@ class _IosPresenceMenuState extends State<IosPresenceMenu> {
key: const ValueKey('settings-presence-target'),
width: width,
height: height,
child: UiKitView(
key: const ValueKey('settings-presence-menu'),
viewType: 'buzz/presence_menu',
creationParams: _configuration(context),
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) {
final channel = MethodChannel('buzz/presence_menu/$id');
_channel = channel;
channel.setMethodCallHandler((call) async {
if (!mounted || call.method != 'selected') return;
final value = call.arguments;
if (value is String &&
const ['online', 'away', 'offline'].contains(value)) {
widget.onSelected(value);
}
});
unawaited(
channel.invokeMethod<void>('update', _configuration(context)),
);
},
child: Stack(
fit: StackFit.expand,
children: [
if (!_nativeVisible) _standIn(fontSize),
if (_routeSettled) _nativeMenu(context),
],
),
);
}
/// Draws the native pill's capsule and title while the native view is
/// absent, so route transitions and view creation do not blink the pill.
Widget _standIn(double fontSize) => Semantics(
label: 'Availability',
value: widget.label,
excludeSemantics: true,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: DecoratedBox(
decoration: ShapeDecoration(
color: widget.background,
shape: const StadiumBorder(),
),
child: Center(
child: Text(
widget.label,
maxLines: 1,
textScaler: TextScaler.noScaling,
style: TextStyle(
fontFamily: 'CupertinoSystemText',
fontSize: fontSize,
fontWeight: FontWeight.w500,
color: widget.foreground,
),
),
),
),
),
);
Widget _nativeMenu(BuildContext context) => UiKitView(
key: const ValueKey('settings-presence-menu'),
viewType: 'buzz/presence_menu',
creationParams: _configuration(context),
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (id) {
if (!mounted || !_routeSettled) return;
final channel = MethodChannel('buzz/presence_menu/$id');
_channel = channel;
setState(() => _nativeVisible = true);
channel.setMethodCallHandler((call) async {
if (!mounted || call.method != 'selected') return;
final value = call.arguments;
if (value is String &&
const ['online', 'away', 'offline'].contains(value)) {
widget.onSelected(value);
}
});
unawaited(channel.invokeMethod<void>('update', _configuration(context)));
},
);
}

View File

@@ -138,6 +138,21 @@ void main() {
appBar.nativeActions!
.singleWhere((action) => action.label == 'Settings')
.onPressed!();
// Mid-transition, Home's native bars still composite beneath Settings;
// the presence pill must stay drawn by Flutter until the route settles.
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
find.byKey(const ValueKey('settings-presence-menu')),
findsNothing,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('settings-presence-target')),
matching: find.text('Offline'),
),
findsOneWidget,
);
} else {
await tester.tap(find.byType(ProfileAvatar));
}