diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 61573024a03..118863db581 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -71,6 +71,10 @@ const double _kNavBarBackButtonTapWidth = 50.0; /// Eyeballed on an iPhone 15 simulator running iOS 17.5. const double _kSearchFieldCancelButtonWidth = 67.0; +/// The height of the unscaled search field used in +/// a [CupertinoSliverNavigationBar.search]. +const double _kSearchFieldHeight = 36.0; + /// The duration of the animation when the search field in /// [CupertinoSliverNavigationBar.search] is tapped. const Duration _kNavBarSearchDuration = Duration(milliseconds: 300); @@ -831,6 +835,7 @@ class _CupertinoNavigationBarState extends State { border: effectiveBorder, hasUserMiddle: widget.middle != null, largeExpanded: widget.largeTitle != null, + searchable: false, automaticBackgroundVisibility: widget.automaticBackgroundVisibility, child: navBar, ), @@ -1153,7 +1158,6 @@ class _CupertinoSliverNavigationBarState extends State @@ -1547,6 +1552,7 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg border: effectiveBorder, hasUserMiddle: userMiddle != null && (alwaysShowMiddle || !showLargeTitle), largeExpanded: showLargeTitle, + searchable: searchable, automaticBackgroundVisibility: automaticBackgroundVisibility, child: navBar, ), @@ -1570,7 +1576,8 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur || bottomMode != oldDelegate.bottomMode || bottomHeight != oldDelegate.bottomHeight || - controller != oldDelegate.controller; + controller != oldDelegate.controller || + searchable != oldDelegate.searchable; } } @@ -2300,20 +2307,37 @@ class _InactiveSearchableBottom extends StatelessWidget { const _InactiveSearchableBottom({ required this.animationController, required this.searchField, - required this.onSearchFieldTap, required this.animation, + required this.searchFieldHeight, + required this.onSearchFieldTap, }); final AnimationController animationController; - final _NavigationBarSearchField? searchField; + final Widget? searchField; final Animation animation; + final double searchFieldHeight; final void Function()? onSearchFieldTap; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: animation, - child: GestureDetector(onTap: onSearchFieldTap, child: searchField), + child: GestureDetector( + onTap: onSearchFieldTap, + child: AbsorbPointer( + child: FocusableActionDetector( + descendantsAreFocusable: false, + child: Padding( + padding: const EdgeInsetsDirectional.only( + start: _kNavBarEdgePadding, + end: _kNavBarEdgePadding, + bottom: _kNavBarBottomPadding, + ), + child: SizedBox(height: searchFieldHeight, child: searchField), + ), + ), + ), + ), builder: (BuildContext context, Widget? child) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { @@ -2349,12 +2373,14 @@ class _ActiveSearchableBottom extends StatelessWidget { required this.animationController, required this.searchField, required this.animation, + required this.searchFieldHeight, required this.onSearchFieldTap, }); final AnimationController animationController; final Widget? searchField; final Animation animation; + final double searchFieldHeight; final void Function()? onSearchFieldTap; @override @@ -2367,7 +2393,12 @@ class _ActiveSearchableBottom extends StatelessWidget { child: Row( spacing: 12.0, // Eyeballed on an iPhone 15 simulator running iOS 17.5. children: [ - Expanded(child: searchField ?? const SizedBox.shrink()), + Expanded( + child: SizedBox( + height: searchFieldHeight, + child: searchField ?? const SizedBox.shrink(), + ), + ), AnimatedBuilder( animation: animation, child: FadeTransition( @@ -2387,36 +2418,6 @@ class _ActiveSearchableBottom extends StatelessWidget { } } -/// The search field used in the expanded state of a -/// [CupertinoSliverNavigationBar.search]. -class _NavigationBarSearchField extends StatelessWidget implements PreferredSizeWidget { - const _NavigationBarSearchField({required this.searchField}); - - static const double verticalPadding = 8.0; - static const double searchFieldHeight = 36.0; - final Widget searchField; - - @override - Widget build(BuildContext context) { - return AbsorbPointer( - child: FocusableActionDetector( - descendantsAreFocusable: false, - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: _kNavBarEdgePadding, - end: _kNavBarEdgePadding, - bottom: verticalPadding, - ), - child: SizedBox(height: searchFieldHeight, child: searchField), - ), - ), - ); - } - - @override - Size get preferredSize => const Size.fromHeight(searchFieldHeight + verticalPadding); -} - /// This should always be the first child of Hero widgets. /// /// This class helps each Hero transition obtain the start or end navigation @@ -2435,6 +2436,7 @@ class _TransitionableNavigationBar extends StatelessWidget { required this.border, required this.hasUserMiddle, required this.largeExpanded, + required this.searchable, required this.automaticBackgroundVisibility, required this.child, }) : assert(!largeExpanded || largeTitleTextStyle != null), @@ -2448,6 +2450,7 @@ class _TransitionableNavigationBar extends StatelessWidget { final Border? border; final bool hasUserMiddle; final bool largeExpanded; + final bool searchable; final bool automaticBackgroundVisibility; final Widget child; @@ -2621,6 +2624,7 @@ class _NavigationBarComponentsTransition { bottomAutomaticBackgroundVisibility = bottomNavBar.automaticBackgroundVisibility, userGestureInProgress = topNavBar.userGestureInProgress || bottomNavBar.userGestureInProgress, + searchable = topNavBar.searchable && bottomNavBar.searchable, transitionBox = // paintBounds are based on offset zero so it's ok to expand the Rects. bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds), @@ -2651,6 +2655,7 @@ class _NavigationBarComponentsTransition { final bool bottomLargeExpanded; final bool topLargeExpanded; final bool userGestureInProgress; + final bool searchable; final bool bottomAutomaticBackgroundVisibility; final Color? bottomBackgroundColor; @@ -2993,8 +2998,6 @@ class _NavigationBarComponentsTransition { Widget? get bottomNavBarBottom { final KeyedSubtree? bottomNavBarBottom = bottomComponents.navBarBottomKey.currentWidget as KeyedSubtree?; - final KeyedSubtree? topNavBarBottom = - topComponents.navBarBottomKey.currentWidget as KeyedSubtree?; if (bottomNavBarBottom == null) { return null; @@ -3018,13 +3021,8 @@ class _NavigationBarComponentsTransition { // Fade out only if this is not a CupertinoSliverNavigationBar.search to // CupertinoSliverNavigationBar.search transition. - if (topNavBarBottom == null || - topNavBarBottom.child is! _InactiveSearchableBottom || - bottomNavBarBottom.child is! _InactiveSearchableBottom) { - child = FadeTransition( - opacity: fadeOutBy(0.8, curve: animationCurve), - child: ClipRect(child: child), - ); + if (!searchable) { + child = FadeTransition(opacity: fadeOutBy(0.8, curve: animationCurve), child: child); } return PositionedTransition( @@ -3034,7 +3032,7 @@ class _NavigationBarComponentsTransition { ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), - child: child, + child: ClipRect(child: child), ); } @@ -3320,8 +3318,6 @@ class _NavigationBarComponentsTransition { Widget? get topNavBarBottom { final KeyedSubtree? topNavBarBottom = topComponents.navBarBottomKey.currentWidget as KeyedSubtree?; - final KeyedSubtree? bottomNavBarBottom = - bottomComponents.navBarBottomKey.currentWidget as KeyedSubtree?; if (topNavBarBottom == null) { return null; @@ -3346,13 +3342,8 @@ class _NavigationBarComponentsTransition { // Fade in only if this is not a CupertinoSliverNavigationBar.search to // CupertinoSliverNavigationBar.search transition. - if (bottomNavBarBottom == null || - bottomNavBarBottom.child is! _InactiveSearchableBottom || - topNavBarBottom.child is! _InactiveSearchableBottom) { - child = FadeTransition( - opacity: fadeInFrom(0.0, curve: animationCurve), - child: ClipRect(child: child), - ); + if (!searchable) { + child = FadeTransition(opacity: fadeInFrom(0.0, curve: animationCurve), child: child); } return PositionedTransition( @@ -3361,7 +3352,7 @@ class _NavigationBarComponentsTransition { userGestureInProgress ? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween) : animation.drive(CurveTween(curve: animationCurve)).drive(positionTween), - child: child, + child: ClipRect(child: child), ); } } diff --git a/packages/flutter/test/cupertino/nav_bar_transition_test.dart b/packages/flutter/test/cupertino/nav_bar_transition_test.dart index 24a078511bc..f761d238f12 100644 --- a/packages/flutter/test/cupertino/nav_bar_transition_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_transition_test.dart @@ -1313,6 +1313,125 @@ void main() { }, ); + testWidgets( + 'CupertinoSliverNavigationBar searchable-to-searchable transition clips its contents mid-transition when scrolled', + (WidgetTester tester) async { + setWindowToPortrait(tester); + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget? navigator) { + return navigator!; + }, + home: const Placeholder(), + ), + ); + + tester + .state(find.byType(Navigator)) + .push( + CupertinoPageRoute( + title: 'Page 1', + builder: + (BuildContext context) => + scaffoldForNavBar( + const CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + suffixIcon: Icon(CupertinoIcons.mic_solid), + ), + ), + )!, + ), + ); + + await tester.pumpAndSettle(); + + final TestGesture scrollGesture1 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture1.moveBy(const Offset(0, -300)); + await scrollGesture1.up(); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + + tester + .state(find.byType(Navigator)) + .push( + CupertinoPageRoute( + title: 'Page 2', + builder: + (BuildContext context) => + scaffoldForNavBar( + const CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + suffixIcon: Icon(CupertinoIcons.mic_solid), + ), + ), + )!, + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsNWidgets(4)); + expect(find.byIcon(CupertinoIcons.search), findsNWidgets(4)); + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('nav_bar_transition.search_to_search.bottom.png'), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + + tester + .state(find.byType(Navigator)) + .push( + CupertinoPageRoute( + title: 'Page 3', + builder: + (BuildContext context) => + scaffoldForNavBar( + const CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + suffixMode: OverlayVisibilityMode.always, + suffixIcon: Icon(CupertinoIcons.mic_solid), + ), + ), + )!, + ), + ); + + await tester.pumpAndSettle(); + + final TestGesture scrollGesture2 = await tester.startGesture( + tester.getCenter(find.byType(CustomScrollView)), + ); + await scrollGesture2.moveBy(const Offset(0, -300)); + await scrollGesture2.up(); + await tester.pumpAndSettle(); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget); + expect(find.byIcon(CupertinoIcons.search), findsOneWidget); + + tester.state(find.byType(Navigator)).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.byIcon(CupertinoIcons.mic_solid), findsNWidgets(4)); + expect(find.byIcon(CupertinoIcons.search), findsNWidgets(4)); + await expectLater( + find.byType(CupertinoApp), + matchesGoldenFile('nav_bar_transition.search_to_search.top.png'), + ); + await tester.pumpAndSettle(); + }, + ); + testWidgets('Long title turns into the word back mid transition', (WidgetTester tester) async { setWindowToPortrait(tester); await startTransitionBetween(