From 41c3008afb59d6179fc482e64b74c667096d15b1 Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Fri, 7 Feb 2025 11:02:24 -0800 Subject: [PATCH] Support CupertinoSliverNavigationBar.search with condensed large title (#159120) https://github.com/user-attachments/assets/70f48a0e-c87e-4399-ad7b-4dfac4376938 Fixes [Suggestion: CupertinoSliverNavigationBar allow forcing condensed title](https://github.com/flutter/flutter/issues/59525) Fixes [Expose search field in CupertinoSliverNavigationBar.search](https://github.com/flutter/flutter/issues/161556) ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../nav_bar/cupertino_sliver_nav_bar.1.dart | 62 ++- .../cupertino_sliver_nav_bar.1_test.dart | 41 +- .../flutter/lib/src/cupertino/nav_bar.dart | 393 +++++++++++++++--- .../lib/src/cupertino/search_field.dart | 7 +- .../flutter/test/cupertino/nav_bar_test.dart | 225 ++++++++++ 5 files changed, 645 insertions(+), 83 deletions(-) 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 index e0f0e72e38e..88f9d9cd50f 100644 --- 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 @@ -4,7 +4,7 @@ import 'package:flutter/cupertino.dart'; -/// Flutter code sample for [CupertinoSliverNavigationBar]. +/// Flutter code sample for [CupertinoSliverNavigationBar.search]. void main() => runApp(const SliverNavBarApp()); @@ -76,11 +76,19 @@ class SliverNavBarExample extends StatelessWidget { } } -class NextPage extends StatelessWidget { +class NextPage extends StatefulWidget { const NextPage({super.key, this.bottomMode = NavigationBarBottomMode.automatic}); final NavigationBarBottomMode bottomMode; + @override + State createState() => _NextPageState(); +} + +class _NextPageState extends State { + bool searchIsActive = false; + late String text; + @override Widget build(BuildContext context) { final Brightness brightness = CupertinoTheme.brightnessOf(context); @@ -88,6 +96,7 @@ class NextPage extends StatelessWidget { child: CustomScrollView( slivers: [ CupertinoSliverNavigationBar.search( + stretch: true, backgroundColor: CupertinoColors.systemYellow, border: Border( bottom: BorderSide( @@ -97,16 +106,47 @@ class NextPage extends StatelessWidget { ), 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), - ], + bottomMode: widget.bottomMode, + searchField: CupertinoSearchTextField( + autofocus: searchIsActive, + placeholder: searchIsActive ? 'Enter search text' : 'Search', + onChanged: (String value) { + setState(() { + if (value.isEmpty) { + text = 'Type in the search field to show text here'; + } else { + text = 'The text has changed to: $value'; + } + }); + }, ), + onSearchableBottomTap: (bool value) { + text = 'Type in the search field to show text here'; + setState(() { + searchIsActive = value; + }); + }, + ), + SliverFillRemaining( + child: + searchIsActive + ? ColoredBox( + color: CupertinoColors.extraLightBackgroundGray, + child: Center(child: Text(text, textAlign: TextAlign.center)), + ) + : const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text('Drag me up', textAlign: TextAlign.center), + Text( + 'Tap on the search field to open the search view', + 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 index e89b7bd1c32..3fd0b843d38 100644 --- 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 @@ -91,6 +91,43 @@ void main() { expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 87.0); }); + testWidgets('Opens the search view when the search field is tapped', (WidgetTester tester) async { + await tester.pumpWidget(const example.SliverNavBarApp()); + + // Navigate to a page with a search field. + final Finder nextButton = find.text('Bottom Automatic mode'); + expect(nextButton, findsOneWidget); + await tester.tap(nextButton); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect(find.text('Tap on the search field to open the search view'), findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap on the search field to open the search view. + await tester.tap(find.byType(CupertinoSearchTextField), warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CupertinoSearchTextField, 'Enter search text'), findsOneWidget); + expect(find.text('Tap on the search field to open the search view'), findsNothing); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + await tester.enterText(find.byType(CupertinoSearchTextField), 'a'); + await tester.pumpAndSettle(); + + expect(find.text('The text has changed to: a'), findsOneWidget); + + // Tap on the 'Cancel' button to close the search view. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect(find.text('Tap on the search field to open the search view'), findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + }); + testWidgets('CupertinoSliverNavigationBar with previous route has back button', ( WidgetTester tester, ) async { @@ -104,7 +141,7 @@ void main() { expect(nextButton1, findsNothing); // Go back to the previous page. - final Finder backButton1 = find.byType(CupertinoButton); + final Finder backButton1 = find.byType(CupertinoButton).first; expect(backButton1, findsOneWidget); await tester.tap(backButton1); await tester.pumpAndSettle(); @@ -118,7 +155,7 @@ void main() { expect(nextButton2, findsNothing); // Go back to the previous page. - final Finder backButton2 = find.byType(CupertinoButton); + final Finder backButton2 = find.byType(CupertinoButton).first; expect(backButton2, findsOneWidget); await tester.tap(backButton2); await tester.pumpAndSettle(); diff --git a/packages/flutter/lib/src/cupertino/nav_bar.dart b/packages/flutter/lib/src/cupertino/nav_bar.dart index 488c62b2e34..c4b6647cf9d 100644 --- a/packages/flutter/lib/src/cupertino/nav_bar.dart +++ b/packages/flutter/lib/src/cupertino/nav_bar.dart @@ -64,6 +64,14 @@ const double _kNavBarBottomPadding = 8.0; const double _kNavBarBackButtonTapWidth = 50.0; +/// The width of the 'Cancel' button if the search field in a +/// [CupertinoSliverNavigationBar.search] is active. +const double _kSearchFieldCancelButtonWidth = 65.0; + +/// The duration of the animation when the search field in +/// [CupertinoSliverNavigationBar.search] is tapped. +const Duration _kNavBarSearchDuration = Duration(milliseconds: 300); + /// Title text transfer fade. const Duration _kNavBarTitleFadeDuration = Duration(milliseconds: 150); @@ -449,9 +457,6 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer /// {@endtemplate} final Widget? trailing; - // TODO(xster): https://github.com/flutter/flutter/issues/10469 implement - // support for double row navigation bars. - /// {@template flutter.cupertino.CupertinoNavigationBar.backgroundColor} /// The background color of the navigation bar. If it contains transparency, the /// tab bar will automatically produce a blurring effect to the content @@ -865,13 +870,6 @@ 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 @@ -913,15 +911,36 @@ class CupertinoSliverNavigationBar extends StatefulWidget { assert( bottomMode == null || bottom != null, 'A bottomMode was provided without a corresponding bottom.', - ); + ), + onSearchableBottomTap = null, + searchField = null, + _searchable = false; - /// Create a navigation bar for scrolling lists with [bottom] set to a - /// [CupertinoSearchTextField] with padding. + /// A navigation bar for scrolling lists that integrates a provided search + /// field directly into the navigation bar. + /// + /// This search-enabled navigation bar is functionally equivalent to + /// the standard [CupertinoSliverNavigationBar] constructor, but with the + /// addition of [searchField], which sits at the bottom of the navigation bar. + /// + /// When the search field is tapped, [leading], [trailing], [middle], and + /// [largeTitle] all collapse, causing the search field to animate to the + /// 'top' of the navigation bar. A 'Cancel' button is presented next to the + /// active [searchField], which when tapped, closes the search view, bringing + /// the navigation bar back to its initial state. /// /// If [automaticallyImplyTitle] is false, then the [largeTitle] argument is /// required. + /// + /// {@tool dartpad} + /// This example demonstrates how to use a + /// [CupertinoSliverNavigationBar.search] to manage a search view. + /// + /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.1.dart ** + /// {@end-tool} const CupertinoSliverNavigationBar.search({ super.key, + required Widget this.searchField, this.largeTitle, this.leading, this.automaticallyImplyLeading = true, @@ -940,13 +959,15 @@ class CupertinoSliverNavigationBar extends StatefulWidget { this.heroTag = _defaultHeroTag, this.stretch = false, this.bottomMode = NavigationBarBottomMode.automatic, + this.onSearchableBottomTap, }) : 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(); + bottom = null, + _searchable = true; /// The navigation bar's title. /// @@ -1054,6 +1075,10 @@ class CupertinoSliverNavigationBar extends StatefulWidget { /// Defaults to [NavigationBarBottomMode.automatic] if this is null and a [bottom] is provided. final NavigationBarBottomMode? bottomMode; + /// Called when the search field in [CupertinoSliverNavigationBar.search] + /// is tapped, toggling the search state between active and inactive. + final ValueChanged? onSearchableBottomTap; + /// True if the navigation bar's background color has no transparency. bool get opaque => backgroundColor?.alpha == 0xFF; @@ -1069,6 +1094,18 @@ class CupertinoSliverNavigationBar extends StatefulWidget { /// Defaults to `false`. final bool stretch; + /// The search field used in [CupertinoSliverNavigationBar.search]. + /// + /// The provided search field is constrained to a fixed height of 35 pixels in + /// its inactive state, and [kMinInteractiveDimensionCupertino] pixels in its + /// active state. + /// + /// Typically a [CupertinoSearchTextField]. + final Widget? searchField; + + /// True if the [CupertinoSliverNavigationBar.search] constructor is used. + final bool _searchable; + @override State createState() => _CupertinoSliverNavigationBarState(); } @@ -1076,14 +1113,25 @@ class CupertinoSliverNavigationBar extends StatefulWidget { // A state class exists for the nav bar so that the keys of its sub-components // don't change when rebuilding the nav bar, causing the sub-components to // lose their own states. -class _CupertinoSliverNavigationBarState extends State { +class _CupertinoSliverNavigationBarState extends State + with TickerProviderStateMixin { late _NavigationBarStaticComponentsKeys keys; ScrollableState? _scrollableState; + _NavigationBarSearchField? preferredSizeSearchField; + late AnimationController _animationController; + late Animation persistentHeightAnimation; + late Animation largeTitleHeightAnimation; + bool searchIsActive = false; @override void initState() { super.initState(); keys = _NavigationBarStaticComponentsKeys(); + _setupSearchableAnimation(); + if (widget._searchable) { + assert(widget.searchField != null); + preferredSizeSearchField = _NavigationBarSearchField(searchField: widget.searchField!); + } } @override @@ -1099,9 +1147,35 @@ class _CupertinoSliverNavigationBarState extends State persistentHeightTween = Tween( + begin: _kNavBarPersistentHeight, + end: 0.0, + ); + persistentHeightAnimation = persistentHeightTween.animate(_animationController) + ..addStatusListener(_handleSearchFieldStatusChanged); + final Tween largeTitleHeightTween = Tween( + begin: _kNavBarLargeTitleHeightExtension, + end: 0.0, + ); + largeTitleHeightAnimation = largeTitleHeightTween.animate(_animationController); + } + void _handleScrollChange() { final ScrollPosition? position = _scrollableState?.position; if (position == null || !position.hasPixels || position.pixels <= 0.0) { @@ -1109,11 +1183,13 @@ class _CupertinoSliverNavigationBarState extends State 0.0; + // Snap the scroll view to a target determined by the navigation bar's + // position. if (canScrollBottom && position.pixels < bottomScrollOffset) { target = position.pixels > bottomScrollOffset / 2 ? bottomScrollOffset : 0.0; } else if (position.pixels > bottomScrollOffset && @@ -1134,17 +1210,44 @@ class _CupertinoSliverNavigationBarState extends State persistentHeight + (bottomMode == NavigationBarBottomMode.always ? bottomHeight : 0.0); @override - double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension + bottomHeight; + double get maxExtent => persistentHeight + largeTitleHeight + bottomHeight; @override OverScrollHeaderStretchConfiguration? stretchConfiguration; @@ -1306,7 +1440,8 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg top: false, bottom: false, child: AnimatedOpacity( - opacity: showLargeTitle ? 1.0 : 0.0, + // Fade the large title as the search field animates from its expanded to its collapsed state. + opacity: showLargeTitle && !controller.isForwardOrCompleted ? 1.0 : 0.0, duration: _kNavBarTitleFadeDuration, child: Semantics( header: true, @@ -1381,12 +1516,14 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg actionsForegroundColor != oldDelegate.actionsForegroundColor || transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes || persistentHeight != oldDelegate.persistentHeight || + largeTitleHeight != oldDelegate.largeTitleHeight || alwaysShowMiddle != oldDelegate.alwaysShowMiddle || heroTag != oldDelegate.heroTag || enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur || bottom != oldDelegate.bottom || bottomMode != oldDelegate.bottomMode || - bottomHeight != oldDelegate.bottomHeight; + bottomHeight != oldDelegate.bottomHeight || + controller != oldDelegate.controller; } } @@ -2066,6 +2203,148 @@ class _BackLabel extends StatelessWidget { } } +/// The 'Cancel' button next to the search field in a +/// [CupertinoSliverNavigationBar.search]. +class _CancelButton extends StatelessWidget { + const _CancelButton({this.opacity = 1.0, required this.onPressed}); + + final void Function()? onPressed; + final double opacity; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Opacity( + opacity: opacity, + child: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + // TODO(victorsanni): Localize this string. + // See https://github.com/flutter/flutter/issues/48616. + child: const Text('Cancel', maxLines: 1, overflow: TextOverflow.clip), + ), + ), + ); + } +} + +/// The bottom of a [CupertinoSliverNavigationBar.search] when the search field +/// is inactive. +class _InactiveSearchableBottom extends StatelessWidget { + const _InactiveSearchableBottom({ + required this.animationController, + required this.searchField, + required this.onSearchFieldTap, + required this.animation, + }); + + final AnimationController animationController; + final _NavigationBarSearchField? searchField; + final Animation animation; + final void Function()? onSearchFieldTap; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + child: GestureDetector(onTap: onSearchFieldTap, child: searchField), + builder: (BuildContext context, Widget? child) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Row( + children: [ + SizedBox( + width: + constraints.maxWidth - + (_kSearchFieldCancelButtonWidth * animationController.value), + child: child, + ), + // A decoy 'Cancel' button used in the collapsed-to-expanded animation. + SizedBox( + width: animationController.value * _kSearchFieldCancelButtonWidth, + child: _CancelButton(opacity: 0.4, onPressed: () {}), + ), + ], + ); + }, + ); + }, + ); + } +} + +/// The bottom of a [CupertinoSliverNavigationBar.search] when the search field +/// is active. +class _ActiveSearchableBottom extends StatelessWidget { + const _ActiveSearchableBottom({ + required this.animationController, + required this.searchField, + required this.animation, + required this.onSearchFieldTap, + }); + + final AnimationController animationController; + final Widget? searchField; + final Animation animation; + final void Function()? onSearchFieldTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: _kNavBarEdgePadding), + child: Row( + spacing: _kNavBarEdgePadding, + children: [ + Expanded(child: searchField ?? const SizedBox.shrink()), + AnimatedBuilder( + animation: animation, + child: FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate(animationController), + child: _CancelButton(onPressed: onSearchFieldTap), + ), + builder: (BuildContext context, Widget? child) { + return SizedBox( + width: animationController.value * _kSearchFieldCancelButtonWidth, + child: child, + ); + }, + ), + ], + ), + ); + } +} + +/// 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 = 35.0; + final Widget searchField; + + @override + Widget build(BuildContext context) { + return AbsorbPointer( + child: FocusableActionDetector( + descendantsAreFocusable: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _kNavBarEdgePadding, + vertical: verticalPadding, + ), + child: SizedBox(height: searchFieldHeight, child: searchField), + ), + ), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(searchFieldHeight + verticalPadding * 2); +} + /// This should always be the first child of Hero widgets. /// /// This class helps each Hero transition obtain the start or end navigation @@ -2876,21 +3155,3 @@ 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/lib/src/cupertino/search_field.dart b/packages/flutter/lib/src/cupertino/search_field.dart index 968e00415f1..80aefa1376e 100644 --- a/packages/flutter/lib/src/cupertino/search_field.dart +++ b/packages/flutter/lib/src/cupertino/search_field.dart @@ -440,10 +440,9 @@ class _CupertinoSearchTextFieldState extends State wit void _handleScrollNotification(ScrollNotification notification) { if (_maxHeight == null) { - _maxHeight ??= (context.findRenderObject() as RenderBox?)?.size.height; - } else { - final RenderBox? renderBox = context.findRenderObject() as RenderBox?; - final double currentHeight = renderBox?.size.height ?? 0.0; + _maxHeight ??= context.size?.height; + } else if (notification is ScrollUpdateNotification) { + final double currentHeight = context.size?.height ?? 0.0; setState(() { _fadeExtent = _calculateScrollOpacity(currentHeight, _maxHeight!); }); diff --git a/packages/flutter/test/cupertino/nav_bar_test.dart b/packages/flutter/test/cupertino/nav_bar_test.dart index 676faecc529..4ea12e7588f 100644 --- a/packages/flutter/test/cupertino/nav_bar_test.dart +++ b/packages/flutter/test/cupertino/nav_bar_test.dart @@ -2538,6 +2538,231 @@ void main() { ); expect(navBar.preferredSize.height, persistentHeight + bottomHeight); }); + + testWidgets('CupertinoSliverNavigationBar.search field collapses nav bar on tap', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar.search( + leading: Icon(CupertinoIcons.person_2), + trailing: Icon(CupertinoIcons.add_circled), + largeTitle: Text('Large title'), + middle: Text('middle'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + final Finder searchFieldFinder = find.byType(CupertinoSearchTextField); + final Finder largeTitleFinder = + find.ancestor(of: find.text('Large title').first, matching: find.byType(Padding)).first; + final Finder middleFinder = + find.ancestor(of: find.text('middle').first, matching: find.byType(Padding)).first; + + // Initially, all widgets are visible. + expect(find.byIcon(CupertinoIcons.person_2), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add_circled), findsOneWidget); + expect(largeTitleFinder, findsOneWidget); + expect(middleFinder.hitTestable(), findsOneWidget); + expect(searchFieldFinder, findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap the search field. + await tester.tap(searchFieldFinder, warnIfMissed: false); + await tester.pump(); + // Pump for the duration of the search field animation. + await tester.pump(const Duration(microseconds: 1) + const Duration(milliseconds: 300)); + + // After tapping, the leading and trailing widgets are removed from the + // widget tree, the large title is collapsed, and middle is hidden + // underneath the navigation bar. + expect(find.byIcon(CupertinoIcons.person_2), findsNothing); + expect(find.byIcon(CupertinoIcons.add_circled), findsNothing); + expect(tester.getBottomRight(largeTitleFinder).dy, 0.0); + expect(middleFinder.hitTestable(), findsNothing); + + // Search field and 'Cancel' button are visible. + expect(searchFieldFinder, findsOneWidget); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + + // Tap the 'Cancel' button to exit the search view. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pump(); + // Pump for the duration of the search field animation. + await tester.pump(const Duration(microseconds: 1) + const Duration(milliseconds: 300)); + + // All widgets are visible again. + expect(find.byIcon(CupertinoIcons.person_2), findsOneWidget); + expect(find.byIcon(CupertinoIcons.add_circled), findsOneWidget); + expect(largeTitleFinder, findsOneWidget); + expect(middleFinder.hitTestable(), findsOneWidget); + expect(searchFieldFinder, findsOneWidget); + // A decoy 'Cancel' button used in the animation. + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + }); + + testWidgets('onSearchableBottomTap callback', (WidgetTester tester) async { + const Color activeSearchColor = Color(0x0000000A); + const Color inactiveSearchColor = Color(0x0000000B); + bool isSearchActive = false; + String text = ''; + + await tester.pumpWidget( + CupertinoApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar.search( + searchField: CupertinoSearchTextField( + onChanged: (String value) { + setState(() { + text = 'The text has changed to: $value'; + }); + }, + ), + onSearchableBottomTap: (bool value) { + setState(() { + isSearchActive = value; + }); + }, + largeTitle: const Text('Large title'), + middle: const Text('middle'), + bottomMode: NavigationBarBottomMode.always, + ), + SliverFillRemaining( + child: ColoredBox( + color: isSearchActive ? activeSearchColor : inactiveSearchColor, + child: Text(text), + ), + ), + ], + ); + }, + ), + ), + ); + + // Initially, all widgets are visible. + expect(find.text('Large title'), findsOneWidget); + expect(find.text('middle'), findsOneWidget); + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ColoredBox && widget.color == inactiveSearchColor; + }), + findsOneWidget, + ); + + // Tap the search field. + await tester.tap(find.widgetWithText(CupertinoSearchTextField, 'Search'), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Search field and 'Cancel' button should be visible. + expect(isSearchActive, true); + expect(find.widgetWithText(CupertinoSearchTextField, 'Search'), findsOneWidget); + expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget); + expect( + find.byWidgetPredicate((Widget widget) { + return widget is ColoredBox && widget.color == activeSearchColor; + }), + findsOneWidget, + ); + + // Enter text into search field to search. + await tester.enterText(find.widgetWithText(CupertinoSearchTextField, 'Search'), 'aaa'); + await tester.pumpAndSettle(); + + // The entered text is shown in the search view. + expect(find.text('The text has changed to: aaa'), findsOne); + }); + + testWidgets( + 'CupertinoSliverNavigationBar.search large title and cancel buttons fade during search animation', + (WidgetTester tester) async { + await tester.pumpWidget( + const CupertinoApp( + home: CustomScrollView( + slivers: [ + CupertinoSliverNavigationBar.search( + largeTitle: Text('Large title'), + middle: Text('Middle'), + searchField: CupertinoSearchTextField(), + ), + SliverFillRemaining(child: SizedBox(height: 1000.0)), + ], + ), + ), + ); + + // Initially, all widgets are visible. + final RenderAnimatedOpacity largeTitleOpacity = + tester + .element(find.text('Large title')) + .findAncestorRenderObjectOfType()!; + // The opacity of the decoy 'Cancel' button, which is always semi-transparent. + final RenderOpacity decoyCancelOpacity = + tester + .element(find.widgetWithText(CupertinoButton, 'Cancel')) + .findAncestorRenderObjectOfType()!; + + expect(largeTitleOpacity.opacity.value, 1.0); + expect(decoyCancelOpacity.opacity, 0.4); + + // Tap the search field, and pump up until partway through the animation. + await tester.tap( + find.widgetWithText(CupertinoSearchTextField, 'Search'), + warnIfMissed: false, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // During the inactive-to-active search animation, the large title fades + // out and the 'Cancel' button remains at a constant semi-transparent + // value. + expect(largeTitleOpacity.opacity.value, lessThan(1.0)); + expect(largeTitleOpacity.opacity.value, greaterThan(0.0)); + expect(decoyCancelOpacity.opacity, 0.4); + + // At the end of the animation, the large title has completely faded out. + await tester.pump(const Duration(milliseconds: 300)); + expect(largeTitleOpacity.opacity.value, 0.0); + expect(decoyCancelOpacity.opacity, 0.4); + + // The opacity of the tappable 'Cancel' button. + final RenderAnimatedOpacity cancelOpacity = + tester + .element(find.widgetWithText(CupertinoButton, 'Cancel')) + .findAncestorRenderObjectOfType()!; + + expect(cancelOpacity.opacity.value, 1.0); + + // Tap the 'Cancel' button, and pump up until partway through the animation. + await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // During the active-to-inactive search animation, the large title fades + // in and the 'Cancel' button fades out. + expect(largeTitleOpacity.opacity.value, lessThan(1.0)); + expect(largeTitleOpacity.opacity.value, greaterThan(0.0)); + expect(cancelOpacity.opacity.value, lessThan(1.0)); + expect(cancelOpacity.opacity.value, greaterThan(0.0)); + + // At the end of the animation, the large title has completely faded in + // and the 'Cancel' button has completely faded out. + await tester.pump(const Duration(milliseconds: 300)); + expect(largeTitleOpacity.opacity.value, 1.0); + expect(cancelOpacity.opacity.value, 0.0); + }, + ); } class _ExpectStyles extends StatelessWidget {