diff --git a/examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart b/examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart new file mode 100644 index 00000000000..10d21fb1cd8 --- /dev/null +++ b/examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart @@ -0,0 +1,119 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [CupertinoSliverNavigationBar]. + +void main() => runApp(const SliverNavBarApp()); + +class SliverNavBarApp extends StatelessWidget { + const SliverNavBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + theme: CupertinoThemeData(brightness: Brightness.light), + home: SliverNavBarExample(), + ); + } +} + +class SliverNavBarExample extends StatelessWidget { + const SliverNavBarExample({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( + leading: Icon(CupertinoIcons.person_2), + largeTitle: Text('Contacts'), + trailing: Icon(CupertinoIcons.add_circled), + ), + SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text('Drag me up', textAlign: TextAlign.center), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage(); + }, + ), + ); + }, + child: const Text('Bottom Automatic mode'), + ), + CupertinoButton.filled( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return const NextPage( + bottomMode: NavigationBarBottomMode.always, + ); + }, + ), + ); + }, + child: const Text('Bottom Always mode'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class NextPage extends StatelessWidget { + const NextPage({super.key, this.bottomMode = NavigationBarBottomMode.automatic}); + + final NavigationBarBottomMode bottomMode; + + @override + Widget build(BuildContext context) { + final Brightness brightness = CupertinoTheme.brightnessOf(context); + return CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar.search( + backgroundColor: CupertinoColors.systemYellow, + border: Border( + bottom: BorderSide( + color: brightness == Brightness.light + ? CupertinoColors.black + : CupertinoColors.white, + ), + ), + middle: const Text('Contacts Group'), + largeTitle: const Text('Family'), + bottomMode: bottomMode, + ), + const SliverFillRemaining( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text('Drag me up', textAlign: TextAlign.center), + Text('Tap on the leading button to navigate back', + textAlign: TextAlign.center), + ], + ), + ), + ], + ), + ); + } +} diff --git a/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.1_test.dart b/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.1_test.dart new file mode 100644 index 00000000000..a9a7c0e4282 --- /dev/null +++ b/examples/api/test/cupertino/nav_bar/cupertino_sliver_nav_bar.1_test.dart @@ -0,0 +1,122 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +const Offset titleDragUp = Offset(0.0, -100.0); +const Offset bottomDragUp = Offset(0.0, -50.0); + +void main() { + testWidgets('Collapse and expand CupertinoSliverNavigationBar changes title position', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverNavBarApp(), + ); + + // Large title is visible and at lower position. + expect(tester.getBottomLeft(find.text('Contacts').first).dy, 88.0); + await tester.fling(find.text('Drag me up'), titleDragUp, 500.0); + await tester.pumpAndSettle(); + + // Large title is hidden and at higher position. + expect(tester.getBottomLeft(find.text('Contacts').first).dy, 36.0 + 8.0); // Static part + _kNavBarBottomPadding. + }); + + testWidgets('Search field is hidden in bottom automatic mode', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverNavBarApp(), + ); + + // Navigate to a page with bottom automatic mode. + final Finder nextButton = find.text('Bottom Automatic mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Middle, large title, and search field are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0); + expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 139.0); + + await tester.fling(find.text('Drag me up'), bottomDragUp, 50.0); + await tester.pumpAndSettle(); + + // Search field is hidden, but large title and middle title are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0); + expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 104.0); + + await tester.fling(find.text('Drag me up'), titleDragUp, 50.0); + await tester.pumpAndSettle(); + + // Large title and search field are hidden and middle title is visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 36.0 + 8.0); // Static part + _kNavBarBottomPadding. + expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 52.0); + }); + + testWidgets('Search field is always shown in bottom always mode', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverNavBarApp(), + ); + + // Navigate to a page with bottom always mode. + final Finder nextButton = find.text('Bottom Always mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + // Middle, large title, and search field are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0); + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0); + expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 139.0); + + await tester.fling(find.text('Drag me up'), titleDragUp, 50.0); + await tester.pumpAndSettle(); + + // Large title is hidden, but search field and middle title are visible. + expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5); + expect(tester.getBottomLeft(find.text('Family').first).dy, 36.0 + 8.0); // Static part + _kNavBarBottomPadding. + expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 52.0); + expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 87.0); + }); + + testWidgets('CupertinoSliverNavigationBar with previous route has back button', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SliverNavBarApp(), + ); + + // Navigate to the first page. + final Finder nextButton1 = find.text('Bottom Automatic mode'); + expect(nextButton1, findsOneWidget); + await tester.tap(nextButton1); + await tester.pumpAndSettle(); + expect(nextButton1, findsNothing); + + // Go back to the previous page. + final Finder backButton1 = find.byType(CupertinoButton); + expect(backButton1, findsOneWidget); + await tester.tap(backButton1); + await tester.pumpAndSettle(); + expect(nextButton1, findsOneWidget); + + // Navigate to the second page. + final Finder nextButton2 = find.text('Bottom Always mode'); + expect(nextButton2, findsOneWidget); + await tester.tap(nextButton2); + await tester.pumpAndSettle(); + expect(nextButton2, findsNothing); + + // Go back to the previous page. + final Finder backButton2 = find.byType(CupertinoButton); + expect(backButton2, findsOneWidget); + await tester.tap(backButton2); + await tester.pumpAndSettle(); + expect(nextButton2, findsOneWidget); + }); +} diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index e7c2c37cd0c..b6e4296d392 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -19,8 +19,25 @@ import 'constants.dart'; import 'icons.dart'; import 'page_scaffold.dart'; import 'route.dart'; +import 'search_field.dart'; import 'theme.dart'; +/// Modes that determine how to display the navigation bar's bottom in relation to scroll events. +enum NavigationBarBottomMode { + /// Enable hiding the bottom in response to scrolling. + /// + /// As scrolling starts, the large title stays pinned while the bottom resizes + /// until it is completely consumed. Then, the large title scrolls under the + /// persistent navigation bar. + automatic, + + /// Always display the bottom regardless of the scroll activity. + /// + /// When scrolled, the bottom stays pinned while the large title scrolls under + /// the persistent navigation bar. + always, +} + /// Standard iOS navigation bar height without the status bar. /// /// This height is constant and independent of accessibility as it is in iOS. @@ -667,6 +684,13 @@ class _CupertinoNavigationBarState extends State { /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.0.dart ** /// {@end-tool} /// +/// {@tool dartpad} +/// This example shows how to add a bottom (typically a +/// [CupertinoSearchTextField]) to a [CupertinoSliverNavigationBar]. +/// +/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart ** +/// {@end-tool} +/// /// See also: /// /// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling @@ -697,13 +721,52 @@ class CupertinoSliverNavigationBar extends StatefulWidget { this.transitionBetweenRoutes = true, this.heroTag = _defaultHeroTag, this.stretch = false, + this.bottom, + this.bottomMode, }) : assert( automaticallyImplyTitle || largeTitle != null, 'No largeTitle has been provided but automaticallyImplyTitle is also ' 'false. Either provide a largeTitle or set automaticallyImplyTitle to ' 'true.', + ), + assert( + bottomMode == null || bottom != null, + 'A bottomMode was provided without a corresponding bottom.', ); + /// Create a navigation bar for scrolling lists with [bottom] set to a + /// [CupertinoSearchTextField] with padding. + /// + /// If [automaticallyImplyTitle] is false, then the [largeTitle] argument is + /// required. + const CupertinoSliverNavigationBar.search({ + super.key, + this.largeTitle, + this.leading, + this.automaticallyImplyLeading = true, + this.automaticallyImplyTitle = true, + this.alwaysShowMiddle = true, + this.previousPageTitle, + this.middle, + this.trailing, + this.border = _kDefaultNavBarBorder, + this.backgroundColor, + this.automaticBackgroundVisibility = true, + this.enableBackgroundFilterBlur = true, + this.brightness, + this.padding, + this.transitionBetweenRoutes = true, + this.heroTag = _defaultHeroTag, + this.stretch = false, + this.bottomMode = NavigationBarBottomMode.automatic, + }) : assert( + automaticallyImplyTitle || largeTitle != null, + 'No largeTitle has been provided but automaticallyImplyTitle is also ' + 'false. Either provide a largeTitle or set automaticallyImplyTitle to ' + 'true.', + ), + bottom = const _NavigationBarSearchField(); + /// The navigation bar's title. /// /// This text will appear in the top static navigation bar when collapsed and @@ -794,6 +857,22 @@ class CupertinoSliverNavigationBar extends StatefulWidget { /// {@macro flutter.cupertino.CupertinoNavigationBar.heroTag} final Object heroTag; + /// A widget to place at the bottom of the large title or static navigation + /// bar if there is no large title. + /// + /// Only widgets that implement [PreferredSizeWidget] can be used at the + /// bottom of a navigation bar. + /// + /// See also: + /// + /// * [PreferredSize], which can be used to give an arbitrary widget a preferred size. + final PreferredSizeWidget? bottom; + + /// Modes that determine how to display the navigation bar's [bottom] and scrolling behavior. + /// + /// Defaults to [NavigationBarBottomMode.automatic] if this is null and a [bottom] is provided. + final NavigationBarBottomMode? bottomMode; + /// True if the navigation bar's background color has no transparency. bool get opaque => backgroundColor?.alpha == 0xFF; @@ -860,6 +939,9 @@ class _CupertinoSliverNavigationBarState extends State persistentHeight; + double get minExtent => persistentHeight + (bottomMode == NavigationBarBottomMode.always ? bottomHeight : 0.0); @override - double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension; + double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension + bottomHeight; @override OverScrollHeaderStretchConfiguration? stretchConfiguration; @@ -914,6 +1002,10 @@ class _LargeTitleNavigationBarSliverDelegate Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { final double largeTitleThreshold = maxExtent - minExtent - _kNavBarShowLargeTitleThreshold; final bool showLargeTitle = shrinkOffset < largeTitleThreshold; + + // Calculate how much the bottom should shrink. + final double bottomShrinkFactor = clampDouble(shrinkOffset / bottomHeight, 0, 1); + final double shrinkAnimationValue = clampDouble( (shrinkOffset - largeTitleThreshold - _kNavBarScrollUnderAnimationExtent) / _kNavBarScrollUnderAnimationExtent, 0, @@ -947,50 +1039,68 @@ class _LargeTitleNavigationBarSliverDelegate enableBackgroundFilterBlur: enableBackgroundFilterBlur, child: DefaultTextStyle( style: CupertinoTheme.of(context).textTheme.textStyle, - child: Stack( - fit: StackFit.expand, + child: Column( children: [ - Positioned( - top: persistentHeight, - left: 0.0, - right: 0.0, - bottom: 0.0, - child: ClipRect( - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: _kNavBarEdgePadding, - bottom: _kNavBarBottomPadding - ), - child: SafeArea( - top: false, - bottom: false, - child: AnimatedOpacity( - opacity: showLargeTitle ? 1.0 : 0.0, - duration: _kNavBarTitleFadeDuration, - child: Semantics( - header: true, - child: DefaultTextStyle( - style: CupertinoTheme.of(context) - .textTheme - .navLargeTitleTextStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: _LargeTitle( - child: components.largeTitle, + Expanded( + child: Stack( + children: [ + Positioned( + top: persistentHeight, + left: 0.0, + right: 0.0, + bottom: bottomMode == NavigationBarBottomMode.automatic + ? bottomHeight * (1.0 - bottomShrinkFactor) + : 0.0, + child: ClipRect( + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding + ), + child: SafeArea( + top: false, + bottom: false, + child: AnimatedOpacity( + opacity: showLargeTitle ? 1.0 : 0.0, + duration: _kNavBarTitleFadeDuration, + child: Semantics( + header: true, + child: DefaultTextStyle( + style: CupertinoTheme.of(context) + .textTheme + .navLargeTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: _LargeTitle( + child: components.largeTitle, + ), + ), + ), ), ), ), ), ), - ), + Positioned( + left: 0.0, + right: 0.0, + top: 0.0, + child: persistentNavigationBar, + ), + if (bottomMode == NavigationBarBottomMode.automatic) + Positioned( + left: 0.0, + right: 0.0, + bottom: 0.0, + child: SizedBox( + height: bottomHeight * (1.0 - bottomShrinkFactor), + child: bottom, + ), + ), + ], ), ), - Positioned( - left: 0.0, - right: 0.0, - top: 0.0, - child: persistentNavigationBar, - ), + if (bottomMode == NavigationBarBottomMode.always) SizedBox(height: bottomHeight, child: bottom), ], ), ), @@ -1038,7 +1148,10 @@ class _LargeTitleNavigationBarSliverDelegate || persistentHeight != oldDelegate.persistentHeight || alwaysShowMiddle != oldDelegate.alwaysShowMiddle || heroTag != oldDelegate.heroTag - || enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur; + || enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur + || bottom != oldDelegate.bottom + || bottomMode != oldDelegate.bottomMode + || bottomHeight != oldDelegate.bottomHeight; } } @@ -2633,3 +2746,24 @@ Widget _navBarHeroFlightShuttleBuilder( ); } } + +class _NavigationBarSearchField extends StatelessWidget implements PreferredSizeWidget { + const _NavigationBarSearchField(); + + static const double padding = 8.0; + static const double searchFieldHeight = 35.0; + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: padding, vertical: padding), + child: SizedBox( + height: searchFieldHeight, + child: CupertinoSearchTextField() + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(searchFieldHeight + padding * 2); +} diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index f87e3e12c8c..6e10f785525 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -1888,6 +1888,204 @@ void main() { expect(largeTitleFinder.hitTestable(), findsOneWidget); }, ); + + testWidgets('NavigationBarBottomMode.automatic mode for bottom', (WidgetTester tester) async { + const double persistentHeight = 44.0; + const double largeTitleHeight = 44.0; + const double bottomHeight = 10.0; + final ScrollController controller = ScrollController(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + controller: controller, + slivers: [ + const CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + ), + SliverToBoxAdapter( + child: Container( + height: 1200.0, + ), + ), + ], + ), + ), + ), + ); + + expect(controller.offset, 0.0); + + final Finder largeTitleFinder = find.ancestor( + of: find.text('Large title').first, + matching: find.byType(Padding), + ).first; + final Finder bottomFinder = find.byType(Placeholder); + + // The persistent navigation bar, large title, and search field are all + // visible. + expect(tester.getTopLeft(largeTitleFinder).dy, persistentHeight); + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getTopLeft(bottomFinder).dy, 96.0); + expect(tester.getBottomLeft(bottomFinder).dy, 96.0 + bottomHeight); + + // Scroll the length of the navigation bar search text field. + controller.jumpTo(bottomHeight); + await tester.pump(); + + // The search field is hidden, but the large title remains visible. + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + + // Scroll until the large title scrolls under the persistent navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -400.0), 10.0); + await tester.pump(); + + // The large title and search field are both hidden. + expect(tester.getBottomLeft(largeTitleFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + + controller.dispose(); + }); + + testWidgets('NavigationBarBottomMode.always mode for bottom', (WidgetTester tester) async { + const double persistentHeight = 44.0; + const double largeTitleHeight = 44.0; + const double bottomHeight = 10.0; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.always, + ), + SliverToBoxAdapter( + child: Container( + height: 1200.0, + ), + ), + ], + ), + ), + ), + ); + + final Finder largeTitleFinder = find.ancestor( + of: find.text('Large title').first, + matching: find.byType(Padding), + ).first; + final Finder bottomFinder = find.byType(Placeholder); + + // The persistent navigation bar, large title, and search field are all + // visible. + expect(tester.getTopLeft(largeTitleFinder).dy, persistentHeight); + expect(tester.getBottomLeft(largeTitleFinder).dy, persistentHeight + largeTitleHeight); + expect(tester.getTopLeft(bottomFinder).dy, 96.0); + expect(tester.getBottomLeft(bottomFinder).dy, 96.0 + bottomHeight); + + // Scroll until the large title scrolls under the persistent navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, -400.0), 10.0); + await tester.pump(); + + // Only the large title is hidden. + expect(tester.getBottomLeft(largeTitleFinder).dy - tester.getTopLeft(bottomFinder).dy, 0.0); + expect(tester.getTopLeft(bottomFinder).dy, persistentHeight); + expect(tester.getBottomLeft(bottomFinder).dy, persistentHeight + bottomHeight); + }); + + testWidgets('Disallow providing a bottomMode without a corresponding bottom', (WidgetTester tester) async { + expect( + () => const CupertinoSliverNavigationBar( + bottom: PreferredSize( + preferredSize: Size.fromHeight(10.0), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + returnsNormally, + ); + + expect( + () => const CupertinoSliverNavigationBar( + bottom: PreferredSize( + preferredSize: Size.fromHeight(10.0), + child: Placeholder(), + ), + ), + returnsNormally, + ); + + expect( + () => CupertinoSliverNavigationBar( + bottomMode: NavigationBarBottomMode.automatic, + ), + throwsA(isA().having( + (AssertionError e) => e.message, + 'message', + contains('A bottomMode was provided without a corresponding bottom.'), + )), + ); + }); + + testWidgets('Overscroll when stretched does not resize bottom in automatic mode', (WidgetTester tester) async { + const double bottomHeight = 10.0; + const double bottomDisplacement = 96.0; + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + child: CustomScrollView( + slivers: [ + const CupertinoSliverNavigationBar( + stretch: true, + largeTitle: Text('Large title'), + bottom: PreferredSize( + preferredSize: Size.fromHeight(bottomHeight), + child: Placeholder(), + ), + bottomMode: NavigationBarBottomMode.automatic, + ), + SliverToBoxAdapter( + child: Container( + height: 1200.0, + ), + ), + ], + ), + ), + ), + ); + + final Finder bottomFinder = find.byType(Placeholder); + expect(tester.getTopLeft(bottomFinder).dy, bottomDisplacement); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + + // Overscroll to stretch the navigation bar. + await tester.fling(find.byType(CustomScrollView), const Offset(0.0, 50.0), 10.0); + await tester.pump(); + + // The bottom stretches without resizing. + expect(tester.getTopLeft(bottomFinder).dy, greaterThan(bottomDisplacement)); + expect( + tester.getBottomLeft(bottomFinder).dy - tester.getTopLeft(bottomFinder).dy, + bottomHeight, + ); + }); } class _ExpectStyles extends StatelessWidget {