diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 326df1fb32..24692e38a3 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -530,6 +530,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { } private func setScrollOffset(_ value: CGFloat) { + guard value.isFinite else { return } offset = max(0, value) material.alpha = alwaysFrosted ? 1 : min(1, offset / 12) applyScroll() diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 9dc3eeb6d7..4c11c935a2 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -520,6 +520,29 @@ class RunnerTests: XCTestCase { } } + @MainActor + func testNavigationIgnoresNonFiniteScrollOffsets() throws { + let parent = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = parent + window.makeKeyAndVisible() + defer { window.isHidden = true } + let messenger = NavigationTestMessenger() + let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create( + withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120), + viewIdentifier: 999990, + arguments: ["title": "general", "alwaysFrosted": true]) + parent.view.addSubview(bar.view()) + bar.view().layoutIfNeeded() + let navigation = try XCTUnwrap(parent.children.first as? UINavigationController) + let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView) + messenger.scroll(to: 52) + let finiteOffset = scroll.contentOffset.y + XCTAssertTrue(finiteOffset.isFinite) + messenger.scroll(to: .infinity) + XCTAssertEqual(scroll.contentOffset.y, finiteOffset, accuracy: 0.5) + } + @MainActor func testSubtitlelessThreadKeepsFullFallbackBehindControls() throws { let parent = UIViewController() diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 1c6b2b211f..1b9f898d36 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -175,8 +175,14 @@ class FrostedScaffold extends HookWidget { 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. - final next = topContentDepth.clamp(0.0, double.infinity); - if ((scrollOffset.value - next).abs() > 0.1) { + // 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; }); diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index c77322ff95..6398516843 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/frosted_scaffold.dart'; +import 'package:buzz/shared/widgets/ios_navigation_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +13,35 @@ Border? _appBarBorder(WidgetTester tester) { } void main() { + testWidgets('unbounded reversed scroll depth stays finite for native bar', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: FrostedScaffold( + appBar: FrostedAppBar(title: Text('Conversation')), + body: SizedBox(key: ValueKey('timeline')), + ), + ), + ); + final source = tester.element(find.byKey(const ValueKey('timeline'))); + final offset = IosNavigationScrollScope.maybeOf(source)!; + ScrollUpdateNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: double.infinity, + pixels: 0, + viewportDimension: 600, + axisDirection: AxisDirection.up, + devicePixelRatio: 1, + ), + context: source, + scrollDelta: 0, + ).dispatch(source); + await tester.pump(); + expect(offset.value, 600); + }); + testWidgets('foreground paints above header and leaves body interactive', ( tester, ) async {