Files
buzz/mobile/lib/shared/widgets/frosted_scaffold.dart
Tom Brow bf8b356f3b Fix iOS channel crash from unbounded scroll depth (#8407)
### Summary

Opening a channel on iOS 27.0.1 could terminate Buzz when a reversed,
lazily loaded timeline reported an unbounded scroll extent. Flutter
forwarded `Infinity` to the native navigation bar, and UIKit threw
`CALayerInvalidGeometry` while setting the scroll view offset.

Use the finite viewport depth for that unbounded case, preserving the
scrolled-under appearance. Reject non-finite offsets at the native
platform boundary as a final safety check.

### Validation

- Added tests: a Flutter widget regression for an unbounded reversed
timeline, and an iOS native regression for a non-finite platform-channel
offset.
- Reproduced the original crash by tapping `#general` on an iPhone SE
running iOS 27.0.1. With this change, the channel rendered and survived
repeated back-and-reopen cycles. Native logging confirmed the channel
now sends finite offsets where it previously sent `Infinity`.
- The native regression passed on iOS 26.5 and 27.0 simulators with
Xcode 27.0. Flutter widget tests and `flutter analyze` passed.
- A full channel-flow simulator run was not available: the isolated
simulator Profile build could not resolve `BuzzPushKit`. The iOS 27.0.1
physical run exercised the full channel flow.
- The full Flutter suite had eight notification and push test failures,
all reproduced on the unchanged 0.24.0 source.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 16:25:41 -07:00

302 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'ios_navigation_bar.dart';
import 'ios_scroll_to_top.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../theme/theme.dart';
import 'directional_transition_scope.dart';
import 'frosted_app_bar.dart';
import 'frosted_scroll_under_scope.dart';
/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body.
///
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
/// floating above it. The body is responsible for adding its own top spacing
/// using [frostedAppBarHeight] so content starts below the bar.
class FrostedScaffold extends HookWidget {
/// The frosted app bar displayed at the top of the screen.
final FrostedAppBar appBar;
/// The primary content of the scaffold. Must handle its own top spacing
/// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding.
final Widget body;
/// Non-scrolling content painted above the navigation bar and its blur.
/// Position this child within the scaffold's full-screen stack.
final Widget? foreground;
/// Optional floating action button, passed through to [Scaffold].
final Widget? floatingActionButton;
/// Whether the body should resize when the on-screen keyboard appears.
final bool? resizeToAvoidBottomInset;
/// Optional scaffold background, useful when a parent supplies a shared
/// surface behind this page.
final Color? backgroundColor;
/// A fixed gradient painted behind the app bar and scrolling body.
final Gradient? backgroundGradient;
/// Whether this settings-style page should invert the canvas and container
/// surface roles.
final bool useUtilitySurfaceTheme;
/// Reserves the native large title above fixed controls.
final bool nativePinnedBody;
const FrostedScaffold({
super.key,
required this.appBar,
required this.body,
this.foreground,
this.floatingActionButton,
this.resizeToAvoidBottomInset,
this.backgroundColor,
this.backgroundGradient,
this.useUtilitySurfaceTheme = false,
this.nativePinnedBody = false,
});
@override
Widget build(BuildContext context) {
final scrollOffset = useValueNotifier(0.0);
final pinnedController = useScrollController(keepScrollOffset: false);
// A keyed page/tab owns both coordinated positions and their restoration
// bucket, so a new timeline cannot restore a stale inner offset.
final pinnedStorage = useMemoized(PageStorageBucket.new);
final pinnedKey = useMemoized(GlobalKey<NestedScrollViewState>.new);
final collapseExtent = appBar.nativeLargeTitle
? IosNavigationMetrics.of(context).largeTitleHeight
: 0.0;
final previousExtent = useRef(collapseExtent);
// Redistribute consumed distance after native metric changes without
// shifting the visible content or expanding a deeply scrolled title.
useEffect(() {
final old = previousExtent.value;
previousExtent.value = collapseExtent;
final inner = pinnedKey.currentState?.innerController;
if (old != collapseExtent &&
pinnedController.hasClients &&
pinnedController.offset >= old &&
inner != null &&
inner.hasClients &&
inner.offset > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!pinnedController.hasClients || !inner.hasClients) return;
final total = pinnedController.offset + inner.offset;
pinnedController.jumpTo(collapseExtent.clamp(0.0, total));
inner.jumpTo((total - collapseExtent).clamp(0.0, double.infinity));
});
}
return null;
}, [collapseExtent]);
final nativePinned =
nativePinnedBody && defaultTargetPlatform == TargetPlatform.iOS;
useEffect(() {
void update() {
if (pinnedController.hasClients) {
final inner = pinnedKey.currentState?.innerController;
scrollOffset.value =
(pinnedController.offset +
(inner != null && inner.hasClients ? inner.offset : 0))
.clamp(0.0, double.infinity);
}
}
if (!nativePinned) return null;
pinnedController.addListener(update);
return () => pinnedController.removeListener(update);
}, [nativePinned, pinnedController]);
final isScrolledUnder = useState(false);
final pendingScrolledUnder = useRef<bool?>(null);
final scrollUpdateScheduled = useRef(false);
void updateScrollUnder(bool next) {
if (scrollUpdateScheduled.value) {
pendingScrolledUnder.value =
(pendingScrolledUnder.value ?? false) || next;
return;
}
pendingScrolledUnder.value = next;
scrollUpdateScheduled.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollUpdateScheduled.value = false;
final pending = pendingScrolledUnder.value;
pendingScrolledUnder.value = null;
if (!context.mounted ||
pending == null ||
pending == isScrolledUnder.value) {
return;
}
isScrolledUnder.value = pending;
});
}
final observedBody = NotificationListener<Notification>(
onNotification: (notification) {
final ScrollMetrics metrics;
final int depth;
final BuildContext? sourceContext;
if (notification is ScrollNotification) {
metrics = notification.metrics;
depth = notification.depth;
sourceContext = notification.context;
} else if (notification is ScrollMetricsNotification) {
// Initial layout and content-size changes matter even before a drag.
metrics = notification.metrics;
depth = notification.depth;
sourceContext = notification.context;
} else {
return false;
}
if (depth != 0 || metrics.axis != Axis.vertical) return false;
// EditableText has its own depth-zero scrollable. Composer edits and
// caret scrolling must not replace the page's scroll-under state.
if (sourceContext?.findAncestorWidgetOfExactType<EditableText>() !=
null) {
return false;
}
// Reversed chats start at zero at the newest message. Older content
// behind the top bar is measured by extentAfter, not scroll pixels.
final topContentDepth = metrics.axisDirection == AxisDirection.up
? metrics.extentAfter
: metrics.extentBefore;
if (nativePinned &&
metrics.axis == Axis.vertical &&
pinnedController.hasClients) {
final inner = pinnedKey.currentState?.innerController;
scrollOffset.value =
(pinnedController.offset +
(inner != null && inner.hasClients ? inner.offset : 0))
.clamp(0.0, double.infinity);
}
if (!nativePinned && depth == 0 && metrics.axis == Axis.vertical) {
// Keep real depth so a later UIKit metrics update (Dynamic Type or
// rotation) can apply its new collapse range without another scroll.
// Lazy reversed lists can report an unbounded extentAfter. The
// visible viewport is enough depth for UIKit's collapsed state,
// while infinity is not a valid UIScrollView content offset.
final finiteDepth = topContentDepth.isFinite
? topContentDepth
: metrics.viewportDimension;
final next = finiteDepth.clamp(0.0, double.infinity);
if (next.isFinite && (scrollOffset.value - next).abs() > 0.1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) scrollOffset.value = next;
});
}
}
final next = topContentDepth > 0.5;
if (next != isScrolledUnder.value) updateScrollUnder(next);
return false;
},
child: body,
);
final scaffold = IosScrollToTop(
onScrollToTop: nativePinned
? () {
if (!pinnedController.hasClients) return;
if (MediaQuery.disableAnimationsOf(context)) {
pinnedController.jumpTo(0);
} else {
pinnedController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
: null,
child: IosNavigationScrollScope(
offset: scrollOffset,
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
body: FrostedScrollUnderScope(
isScrolledUnder: isScrolledUnder.value,
child: Stack(
children: _stackChildren(
observedBody,
pinnedController,
pinnedStorage,
pinnedKey,
),
),
),
),
),
);
if (!useUtilitySurfaceTheme) return scaffold;
return Theme(
data: utilitySurfaceThemeData(Theme.of(context)),
child: scaffold,
);
}
List<Widget> _stackChildren(
Widget observedBody,
ScrollController pinnedController,
PageStorageBucket pinnedStorage,
GlobalKey<NestedScrollViewState> pinnedKey,
) {
final backdrop = backgroundGradient == null
? const <Widget>[]
: [
Positioned.fill(
child: _PinnedGradientBackground(gradient: backgroundGradient!),
),
];
final bodyMotion = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
child: nativePinnedBody && defaultTargetPlatform == TargetPlatform.iOS
? PageStorage(
bucket: pinnedStorage,
child: NestedScrollView(
key: pinnedKey,
controller: pinnedController,
headerSliverBuilder: (context, innerScrolled) => [
SliverToBoxAdapter(
child: SizedBox(
height: appBar.nativeLargeTitle
? IosNavigationMetrics.of(context).largeTitleHeight
: 0,
),
),
],
body: observedBody,
),
)
: observedBody,
);
// The bar must be painted after the scrollable sheet: [BackdropFilter]
// only samples pixels that were already painted behind it. This is the
// same composition as channel navigation, so top-level headers blur their
// content rather than only the fixed gradient.
return [
...backdrop,
bodyMotion,
appBar,
if (foreground != null)
Positioned.fill(child: DirectionalTransitionMotion(child: foreground!)),
];
}
}
class _PinnedGradientBackground extends StatelessWidget {
final Gradient gradient;
const _PinnedGradientBackground({required this.gradient});
@override
Widget build(BuildContext context) => DecoratedBox(
key: const ValueKey('frosted-scaffold-pinned-gradient'),
decoration: BoxDecoration(gradient: gradient),
);
}