From 83a0b85e433bab58d774ba176e2c54b60afdd82d Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Fri, 18 Oct 2024 12:18:08 -0700 Subject: [PATCH] Snap CupertinoSliverNavigationBar large title and bottom widget (#156381) ## Flutter (with this PR): https://github.com/user-attachments/assets/b0177c06-e4b5-4981-8ec3-62171a4f2547 ## Native: https://github.com/user-attachments/assets/6efa0825-d26c-4c20-81cc-c9fada6e15fc Fixes https://github.com/flutter/flutter/issues/126028 Fixes https://github.com/flutter/flutter/issues/23321 --- .../flutter/lib/src/cupertino/nav_bar.dart | 50 ++++- .../flutter/test/cupertino/nav_bar_test.dart | 198 ++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index e59d91139f7..46592e1abd8 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -921,6 +921,7 @@ class CupertinoSliverNavigationBar extends StatefulWidget { // lose their own states. class _CupertinoSliverNavigationBarState extends State { late _NavigationBarStaticComponentsKeys keys; + ScrollableState? _scrollableState; @override void initState() { @@ -928,6 +929,53 @@ class _CupertinoSliverNavigationBarState extends State bottomScrollOffset / 2 + ? bottomScrollOffset + : 0.0; + } + else if (position.pixels > bottomScrollOffset && position.pixels < bottomScrollOffset + _kNavBarLargeTitleHeightExtension) { + target = position.pixels > bottomScrollOffset + (_kNavBarLargeTitleHeightExtension / 2) + ? bottomScrollOffset + _kNavBarLargeTitleHeightExtension + : bottomScrollOffset; + } + + if (target != null) { + position.animateTo( + target, + // Eyeballed on an iPhone 16 simulator running iOS 18. + duration: const Duration(milliseconds: 300), + curve: Curves.fastEaseInToSlowEaseOut, + ); + } + } + @override Widget build(BuildContext context) { final _NavigationBarStaticComponents components = _NavigationBarStaticComponents( @@ -1118,7 +1166,7 @@ class _LargeTitleNavigationBarSliverDelegate bottom: 0.0, child: SizedBox( height: bottomHeight * (1.0 - bottomShrinkFactor), - child: bottom, + child: ClipRect(child: bottom), ), ), ], diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index 089567f8ad8..08e7d6d6d5b 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -2087,6 +2087,204 @@ void main() { ); }); + testWidgets('Large title snaps up to persistent nav bar when partially scrolled over halfway up', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + const double largeTitleHeight = 52.0; + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const [ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + ), + SliverFillRemaining( + child: SizedBox( + height: 1000.0, + ), + ), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType(); + + // The middle widget is initially invisible. + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll a little over the halfway point. + final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap to the persistent app bar. + expect(scrollController.position.pixels, largeTitleHeight); + expect(renderOpacity?.opacity.value, 1.0); + }); + + testWidgets('Large title snaps back to extended height when partially scrolled halfway up or less', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + const double largeTitleHeight = 52.0; + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const [ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + ), + SliverFillRemaining( + child: SizedBox( + height: 1000.0, + ), + ), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType(); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to the halfway point. + final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture.moveBy(const Offset(0.0, -(largeTitleHeight / 2))); + await scrollGesture.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap back to its extended height. + expect(scrollController.position.pixels, 0.0); + expect(renderOpacity?.opacity.value, 0.0); + }); + + testWidgets('Large title and bottom snap up when partially scrolled over halfway up in automatic mode', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + const double largeTitleHeight = 52.0; + const double bottomHeight = 100.0; + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const [ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverFillRemaining( + child: SizedBox( + height: 1000.0, + ), + ), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType(); + final Finder bottomFinder = find.byType(Placeholder); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to just past the halfway point of the bottom widget. + final TestGesture scrollGesture1 = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture1.moveBy(const Offset(0.0, -(bottomHeight / 2) - 1)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the bottom to snap up to the large title. + expect(scrollController.position.pixels, bottomHeight); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(renderOpacity?.opacity.value, 0.0); + + // Scroll to just past the halfway point of the large title. + final TestGesture scrollGesture2 = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture2.moveBy(const Offset(0.0, -(largeTitleHeight / 2) - 1)); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap up to the persistent nav bar. + expect(scrollController.position.pixels, bottomHeight + largeTitleHeight); + expect(renderOpacity?.opacity.value, 1.0); + }); + + testWidgets('Large title and bottom snap down when partially scrolled halfway up or less in automatic mode', (WidgetTester tester) async { + final ScrollController scrollController = ScrollController(); + const double largeTitleHeight = 52.0; + const double bottomHeight = 100.0; + + await tester.pumpWidget( + CupertinoApp( + home: CustomScrollView( + controller: scrollController, + slivers: const [ + CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + middle: Text('middle'), + alwaysShowMiddle: false, + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverFillRemaining( + child: SizedBox( + height: 1000.0, + ), + ), + ], + ), + ), + ); + + final RenderAnimatedOpacity? renderOpacity = tester.element(find.text('middle')).findAncestorRenderObjectOfType(); + final Finder bottomFinder = find.byType(Placeholder); + + expect(renderOpacity?.opacity.value, 0.0); + expect(scrollController.offset, 0.0); + + // Scroll to the halfway point of the bottom widget. + final TestGesture scrollGesture1 = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture1.moveBy(const Offset(0.0, -bottomHeight / 2)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + // Expect the bottom to snap back to its extended height. + expect(scrollController.position.pixels, 0.0); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, bottomHeight); + expect(renderOpacity?.opacity.value, 0.0); + + // Scroll to the halfway point of the large title. + final TestGesture scrollGesture2 = await tester.startGesture(tester.getCenter(find.byType(Scrollable))); + await scrollGesture2.moveBy(const Offset(0.0, -(bottomHeight + largeTitleHeight / 2))); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + // Expect the large title to snap back to its extended height, which is the + // same scroll offset as the fully-shrunk bottom widget. + expect(scrollController.position.pixels, bottomHeight); + expect(renderOpacity?.opacity.value, 0.0); + }); + testWidgets('CupertinoNavigationBar with bottom widget', (WidgetTester tester) async { const double persistentHeight = 44.0; const double bottomHeight = 10.0;