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].

<!-- Links -->
[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
This commit is contained in:
Victor Sanni 2025-02-07 11:02:24 -08:00 committed by GitHub
parent a33904a57a
commit 41c3008afb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 645 additions and 83 deletions

View File

@ -4,7 +4,7 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
/// Flutter code sample for [CupertinoSliverNavigationBar]. /// Flutter code sample for [CupertinoSliverNavigationBar.search].
void main() => runApp(const SliverNavBarApp()); 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}); const NextPage({super.key, this.bottomMode = NavigationBarBottomMode.automatic});
final NavigationBarBottomMode bottomMode; final NavigationBarBottomMode bottomMode;
@override
State<NextPage> createState() => _NextPageState();
}
class _NextPageState extends State<NextPage> {
bool searchIsActive = false;
late String text;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Brightness brightness = CupertinoTheme.brightnessOf(context); final Brightness brightness = CupertinoTheme.brightnessOf(context);
@ -88,6 +96,7 @@ class NextPage extends StatelessWidget {
child: CustomScrollView( child: CustomScrollView(
slivers: <Widget>[ slivers: <Widget>[
CupertinoSliverNavigationBar.search( CupertinoSliverNavigationBar.search(
stretch: true,
backgroundColor: CupertinoColors.systemYellow, backgroundColor: CupertinoColors.systemYellow,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
@ -97,16 +106,47 @@ class NextPage extends StatelessWidget {
), ),
middle: const Text('Contacts Group'), middle: const Text('Contacts Group'),
largeTitle: const Text('Family'), largeTitle: const Text('Family'),
bottomMode: bottomMode, bottomMode: widget.bottomMode,
), searchField: CupertinoSearchTextField(
const SliverFillRemaining( autofocus: searchIsActive,
child: Column( placeholder: searchIsActive ? 'Enter search text' : 'Search',
mainAxisAlignment: MainAxisAlignment.spaceEvenly, onChanged: (String value) {
children: <Widget>[ setState(() {
Text('Drag me up', textAlign: TextAlign.center), if (value.isEmpty) {
Text('Tap on the leading button to navigate back', textAlign: TextAlign.center), 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: <Widget>[
Text('Drag me up', textAlign: TextAlign.center),
Text(
'Tap on the search field to open the search view',
textAlign: TextAlign.center,
),
],
),
),
), ),
], ],
), ),

View File

@ -91,6 +91,43 @@ void main() {
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 87.0); 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', ( testWidgets('CupertinoSliverNavigationBar with previous route has back button', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
@ -104,7 +141,7 @@ void main() {
expect(nextButton1, findsNothing); expect(nextButton1, findsNothing);
// Go back to the previous page. // Go back to the previous page.
final Finder backButton1 = find.byType(CupertinoButton); final Finder backButton1 = find.byType(CupertinoButton).first;
expect(backButton1, findsOneWidget); expect(backButton1, findsOneWidget);
await tester.tap(backButton1); await tester.tap(backButton1);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -118,7 +155,7 @@ void main() {
expect(nextButton2, findsNothing); expect(nextButton2, findsNothing);
// Go back to the previous page. // Go back to the previous page.
final Finder backButton2 = find.byType(CupertinoButton); final Finder backButton2 = find.byType(CupertinoButton).first;
expect(backButton2, findsOneWidget); expect(backButton2, findsOneWidget);
await tester.tap(backButton2); await tester.tap(backButton2);
await tester.pumpAndSettle(); await tester.pumpAndSettle();

View File

@ -64,6 +64,14 @@ const double _kNavBarBottomPadding = 8.0;
const double _kNavBarBackButtonTapWidth = 50.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. /// Title text transfer fade.
const Duration _kNavBarTitleFadeDuration = Duration(milliseconds: 150); const Duration _kNavBarTitleFadeDuration = Duration(milliseconds: 150);
@ -449,9 +457,6 @@ class CupertinoNavigationBar extends StatefulWidget implements ObstructingPrefer
/// {@endtemplate} /// {@endtemplate}
final Widget? trailing; final Widget? trailing;
// TODO(xster): https://github.com/flutter/flutter/issues/10469 implement
// support for double row navigation bars.
/// {@template flutter.cupertino.CupertinoNavigationBar.backgroundColor} /// {@template flutter.cupertino.CupertinoNavigationBar.backgroundColor}
/// The background color of the navigation bar. If it contains transparency, the /// The background color of the navigation bar. If it contains transparency, the
/// tab bar will automatically produce a blurring effect to the content /// tab bar will automatically produce a blurring effect to the content
@ -865,13 +870,6 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
/// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.0.dart ** /// ** See code in examples/api/lib/cupertino/nav_bar/cupertino_sliver_nav_bar.0.dart **
/// {@end-tool} /// {@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: /// See also:
/// ///
/// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling /// * [CupertinoNavigationBar], an iOS navigation bar for use on non-scrolling
@ -913,15 +911,36 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
assert( assert(
bottomMode == null || bottom != null, bottomMode == null || bottom != null,
'A bottomMode was provided without a corresponding bottom.', '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 /// A navigation bar for scrolling lists that integrates a provided search
/// [CupertinoSearchTextField] with padding. /// 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 /// If [automaticallyImplyTitle] is false, then the [largeTitle] argument is
/// required. /// 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({ const CupertinoSliverNavigationBar.search({
super.key, super.key,
required Widget this.searchField,
this.largeTitle, this.largeTitle,
this.leading, this.leading,
this.automaticallyImplyLeading = true, this.automaticallyImplyLeading = true,
@ -940,13 +959,15 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
this.heroTag = _defaultHeroTag, this.heroTag = _defaultHeroTag,
this.stretch = false, this.stretch = false,
this.bottomMode = NavigationBarBottomMode.automatic, this.bottomMode = NavigationBarBottomMode.automatic,
this.onSearchableBottomTap,
}) : assert( }) : assert(
automaticallyImplyTitle || largeTitle != null, automaticallyImplyTitle || largeTitle != null,
'No largeTitle has been provided but automaticallyImplyTitle is also ' 'No largeTitle has been provided but automaticallyImplyTitle is also '
'false. Either provide a largeTitle or set automaticallyImplyTitle to ' 'false. Either provide a largeTitle or set automaticallyImplyTitle to '
'true.', 'true.',
), ),
bottom = const _NavigationBarSearchField(); bottom = null,
_searchable = true;
/// The navigation bar's title. /// 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. /// Defaults to [NavigationBarBottomMode.automatic] if this is null and a [bottom] is provided.
final NavigationBarBottomMode? bottomMode; final NavigationBarBottomMode? bottomMode;
/// Called when the search field in [CupertinoSliverNavigationBar.search]
/// is tapped, toggling the search state between active and inactive.
final ValueChanged<bool>? onSearchableBottomTap;
/// True if the navigation bar's background color has no transparency. /// True if the navigation bar's background color has no transparency.
bool get opaque => backgroundColor?.alpha == 0xFF; bool get opaque => backgroundColor?.alpha == 0xFF;
@ -1069,6 +1094,18 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
/// Defaults to `false`. /// Defaults to `false`.
final bool stretch; 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 @override
State<CupertinoSliverNavigationBar> createState() => _CupertinoSliverNavigationBarState(); State<CupertinoSliverNavigationBar> 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 // 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 // don't change when rebuilding the nav bar, causing the sub-components to
// lose their own states. // lose their own states.
class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar> { class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar>
with TickerProviderStateMixin {
late _NavigationBarStaticComponentsKeys keys; late _NavigationBarStaticComponentsKeys keys;
ScrollableState? _scrollableState; ScrollableState? _scrollableState;
_NavigationBarSearchField? preferredSizeSearchField;
late AnimationController _animationController;
late Animation<double> persistentHeightAnimation;
late Animation<double> largeTitleHeightAnimation;
bool searchIsActive = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
keys = _NavigationBarStaticComponentsKeys(); keys = _NavigationBarStaticComponentsKeys();
_setupSearchableAnimation();
if (widget._searchable) {
assert(widget.searchField != null);
preferredSizeSearchField = _NavigationBarSearchField(searchField: widget.searchField!);
}
} }
@override @override
@ -1099,9 +1147,35 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
if (_scrollableState?.position != null) { if (_scrollableState?.position != null) {
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange); _scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
} }
_animationController.dispose();
super.dispose(); super.dispose();
} }
double get _bottomHeight {
assert(!widget._searchable || widget.bottom == null);
if (widget._searchable) {
return preferredSizeSearchField!.preferredSize.height;
} else if (widget.bottom != null) {
return widget.bottom!.preferredSize.height;
}
return 0.0;
}
void _setupSearchableAnimation() {
_animationController = AnimationController(vsync: this, duration: _kNavBarSearchDuration);
final Tween<double> persistentHeightTween = Tween<double>(
begin: _kNavBarPersistentHeight,
end: 0.0,
);
persistentHeightAnimation = persistentHeightTween.animate(_animationController)
..addStatusListener(_handleSearchFieldStatusChanged);
final Tween<double> largeTitleHeightTween = Tween<double>(
begin: _kNavBarLargeTitleHeightExtension,
end: 0.0,
);
largeTitleHeightAnimation = largeTitleHeightTween.animate(_animationController);
}
void _handleScrollChange() { void _handleScrollChange() {
final ScrollPosition? position = _scrollableState?.position; final ScrollPosition? position = _scrollableState?.position;
if (position == null || !position.hasPixels || position.pixels <= 0.0) { if (position == null || !position.hasPixels || position.pixels <= 0.0) {
@ -1109,11 +1183,13 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
} }
double? target; double? target;
final double bottomScrollOffset =
widget.bottomMode == NavigationBarBottomMode.always ? 0.0 : _bottomHeight;
final bool canScrollBottom = final bool canScrollBottom =
widget.bottom != null && (widget._searchable || widget.bottom != null) && bottomScrollOffset > 0.0;
(widget.bottomMode == NavigationBarBottomMode.automatic || widget.bottomMode == null);
final double bottomScrollOffset = canScrollBottom ? widget.bottom!.preferredSize.height : 0.0;
// Snap the scroll view to a target determined by the navigation bar's
// position.
if (canScrollBottom && position.pixels < bottomScrollOffset) { if (canScrollBottom && position.pixels < bottomScrollOffset) {
target = position.pixels > bottomScrollOffset / 2 ? bottomScrollOffset : 0.0; target = position.pixels > bottomScrollOffset / 2 ? bottomScrollOffset : 0.0;
} else if (position.pixels > bottomScrollOffset && } else if (position.pixels > bottomScrollOffset &&
@ -1134,17 +1210,44 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
} }
} }
void _handleSearchFieldStatusChanged(AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
case AnimationStatus.dismissed:
// Rebuild so that the leading, middle, and trailing widgets that were
// collapsed while the search field was active are re-expanded.
setState(() {});
case AnimationStatus.forward:
searchIsActive = true;
case AnimationStatus.reverse:
searchIsActive = false;
}
}
void _onSearchFieldTap() {
if (widget.onSearchableBottomTap != null) {
widget.onSearchableBottomTap!(!searchIsActive);
}
_animationController.toggle();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final _NavigationBarStaticComponents components = _NavigationBarStaticComponents( final _NavigationBarStaticComponents components = _NavigationBarStaticComponents(
keys: keys, keys: keys,
route: ModalRoute.of(context), route: ModalRoute.of(context),
userLeading: widget.leading, userLeading:
widget.leading != null
? Visibility(visible: !searchIsActive, child: widget.leading!)
: null,
automaticallyImplyLeading: widget.automaticallyImplyLeading, automaticallyImplyLeading: widget.automaticallyImplyLeading,
automaticallyImplyTitle: widget.automaticallyImplyTitle, automaticallyImplyTitle: widget.automaticallyImplyTitle,
previousPageTitle: widget.previousPageTitle, previousPageTitle: widget.previousPageTitle,
userMiddle: widget.middle, userMiddle: _animationController.isAnimating ? const Text('') : widget.middle,
userTrailing: widget.trailing, userTrailing:
widget.trailing != null
? Visibility(visible: !searchIsActive, child: widget.trailing!)
: null,
userLargeTitle: widget.largeTitle, userLargeTitle: widget.largeTitle,
padding: widget.padding, padding: widget.padding,
large: true, large: true,
@ -1152,30 +1255,57 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
); );
return MediaQuery.withNoTextScaling( return MediaQuery.withNoTextScaling(
child: SliverPersistentHeader( child: AnimatedBuilder(
pinned: true, // iOS navigation bars are always pinned. animation: _animationController,
delegate: _LargeTitleNavigationBarSliverDelegate( builder: (BuildContext context, Widget? child) {
keys: keys, return SliverPersistentHeader(
components: components, pinned: true, // iOS navigation bars are always pinned.
userMiddle: widget.middle, delegate: _LargeTitleNavigationBarSliverDelegate(
backgroundColor: keys: keys,
CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ?? components: components,
CupertinoTheme.of(context).barBackgroundColor, userMiddle: widget.middle,
automaticBackgroundVisibility: widget.automaticBackgroundVisibility, backgroundColor:
brightness: widget.brightness, CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ??
border: widget.border, CupertinoTheme.of(context).barBackgroundColor,
padding: widget.padding, automaticBackgroundVisibility: widget.automaticBackgroundVisibility,
actionsForegroundColor: CupertinoTheme.of(context).primaryColor, brightness: widget.brightness,
transitionBetweenRoutes: widget.transitionBetweenRoutes, border: widget.border,
heroTag: widget.heroTag, padding: widget.padding,
persistentHeight: _kNavBarPersistentHeight + MediaQuery.paddingOf(context).top, actionsForegroundColor: CupertinoTheme.of(context).primaryColor,
alwaysShowMiddle: widget.alwaysShowMiddle && widget.middle != null, transitionBetweenRoutes: widget.transitionBetweenRoutes,
stretchConfiguration: widget.stretch ? OverScrollHeaderStretchConfiguration() : null, heroTag: widget.heroTag,
enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur, persistentHeight: persistentHeightAnimation.value + MediaQuery.paddingOf(context).top,
bottom: widget.bottom ?? const SizedBox.shrink(), largeTitleHeight: largeTitleHeightAnimation.value,
bottomMode: widget.bottomMode ?? NavigationBarBottomMode.automatic, alwaysShowMiddle: widget.alwaysShowMiddle && widget.middle != null,
bottomHeight: widget.bottom != null ? widget.bottom!.preferredSize.height : 0.0, stretchConfiguration:
), widget.stretch && !searchIsActive ? OverScrollHeaderStretchConfiguration() : null,
enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur,
bottom:
(widget._searchable
? searchIsActive
? _ActiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: widget.searchField,
onSearchFieldTap: _onSearchFieldTap,
)
: _InactiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: preferredSizeSearchField,
onSearchFieldTap: _onSearchFieldTap,
)
: widget.bottom) ??
const SizedBox.shrink(),
bottomMode:
searchIsActive
? NavigationBarBottomMode.always
: widget.bottomMode ?? NavigationBarBottomMode.automatic,
bottomHeight: _bottomHeight,
controller: _animationController,
),
);
},
), ),
); );
} }
@ -1196,12 +1326,14 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
required this.transitionBetweenRoutes, required this.transitionBetweenRoutes,
required this.heroTag, required this.heroTag,
required this.persistentHeight, required this.persistentHeight,
required this.largeTitleHeight,
required this.alwaysShowMiddle, required this.alwaysShowMiddle,
required this.stretchConfiguration, required this.stretchConfiguration,
required this.enableBackgroundFilterBlur, required this.enableBackgroundFilterBlur,
required this.bottom, required this.bottom,
required this.bottomMode, required this.bottomMode,
required this.bottomHeight, required this.bottomHeight,
required this.controller,
}); });
final _NavigationBarStaticComponentsKeys keys; final _NavigationBarStaticComponentsKeys keys;
@ -1216,18 +1348,20 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
final bool transitionBetweenRoutes; final bool transitionBetweenRoutes;
final Object heroTag; final Object heroTag;
final double persistentHeight; final double persistentHeight;
final double largeTitleHeight;
final bool alwaysShowMiddle; final bool alwaysShowMiddle;
final bool enableBackgroundFilterBlur; final bool enableBackgroundFilterBlur;
final Widget bottom; final Widget bottom;
final NavigationBarBottomMode bottomMode; final NavigationBarBottomMode bottomMode;
final double bottomHeight; final double bottomHeight;
final AnimationController controller;
@override @override
double get minExtent => double get minExtent =>
persistentHeight + (bottomMode == NavigationBarBottomMode.always ? bottomHeight : 0.0); persistentHeight + (bottomMode == NavigationBarBottomMode.always ? bottomHeight : 0.0);
@override @override
double get maxExtent => persistentHeight + _kNavBarLargeTitleHeightExtension + bottomHeight; double get maxExtent => persistentHeight + largeTitleHeight + bottomHeight;
@override @override
OverScrollHeaderStretchConfiguration? stretchConfiguration; OverScrollHeaderStretchConfiguration? stretchConfiguration;
@ -1306,7 +1440,8 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
top: false, top: false,
bottom: false, bottom: false,
child: AnimatedOpacity( 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, duration: _kNavBarTitleFadeDuration,
child: Semantics( child: Semantics(
header: true, header: true,
@ -1381,12 +1516,14 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
actionsForegroundColor != oldDelegate.actionsForegroundColor || actionsForegroundColor != oldDelegate.actionsForegroundColor ||
transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes || transitionBetweenRoutes != oldDelegate.transitionBetweenRoutes ||
persistentHeight != oldDelegate.persistentHeight || persistentHeight != oldDelegate.persistentHeight ||
largeTitleHeight != oldDelegate.largeTitleHeight ||
alwaysShowMiddle != oldDelegate.alwaysShowMiddle || alwaysShowMiddle != oldDelegate.alwaysShowMiddle ||
heroTag != oldDelegate.heroTag || heroTag != oldDelegate.heroTag ||
enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur || enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur ||
bottom != oldDelegate.bottom || bottom != oldDelegate.bottom ||
bottomMode != oldDelegate.bottomMode || 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<double> 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: <Widget>[
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<double> animation;
final void Function()? onSearchFieldTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: _kNavBarEdgePadding),
child: Row(
spacing: _kNavBarEdgePadding,
children: <Widget>[
Expanded(child: searchField ?? const SizedBox.shrink()),
AnimatedBuilder(
animation: animation,
child: FadeTransition(
opacity: Tween<double>(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 should always be the first child of Hero widgets.
/// ///
/// This class helps each Hero transition obtain the start or end navigation /// 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);
}

View File

@ -440,10 +440,9 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
void _handleScrollNotification(ScrollNotification notification) { void _handleScrollNotification(ScrollNotification notification) {
if (_maxHeight == null) { if (_maxHeight == null) {
_maxHeight ??= (context.findRenderObject() as RenderBox?)?.size.height; _maxHeight ??= context.size?.height;
} else { } else if (notification is ScrollUpdateNotification) {
final RenderBox? renderBox = context.findRenderObject() as RenderBox?; final double currentHeight = context.size?.height ?? 0.0;
final double currentHeight = renderBox?.size.height ?? 0.0;
setState(() { setState(() {
_fadeExtent = _calculateScrollOpacity(currentHeight, _maxHeight!); _fadeExtent = _calculateScrollOpacity(currentHeight, _maxHeight!);
}); });

View File

@ -2538,6 +2538,231 @@ void main() {
); );
expect(navBar.preferredSize.height, persistentHeight + bottomHeight); 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: <Widget>[
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: <Widget>[
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: <Widget>[
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<RenderAnimatedOpacity>()!;
// The opacity of the decoy 'Cancel' button, which is always semi-transparent.
final RenderOpacity decoyCancelOpacity =
tester
.element(find.widgetWithText(CupertinoButton, 'Cancel'))
.findAncestorRenderObjectOfType<RenderOpacity>()!;
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<RenderAnimatedOpacity>()!;
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 { class _ExpectStyles extends StatelessWidget {