mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
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:
parent
a33904a57a
commit
41c3008afb
@ -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<NextPage> createState() => _NextPageState();
|
||||
}
|
||||
|
||||
class _NextPageState extends State<NextPage> {
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
Text('Drag me up', textAlign: TextAlign.center),
|
||||
Text(
|
||||
'Tap on the search field to open the search view',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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();
|
||||
|
@ -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<CupertinoNavigationBar> {
|
||||
/// ** 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<bool>? 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<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
|
||||
// don't change when rebuilding the nav bar, causing the sub-components to
|
||||
// lose their own states.
|
||||
class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar> {
|
||||
class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigationBar>
|
||||
with TickerProviderStateMixin {
|
||||
late _NavigationBarStaticComponentsKeys keys;
|
||||
ScrollableState? _scrollableState;
|
||||
_NavigationBarSearchField? preferredSizeSearchField;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> persistentHeightAnimation;
|
||||
late Animation<double> 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<CupertinoSliverNavigation
|
||||
if (_scrollableState?.position != null) {
|
||||
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
|
||||
}
|
||||
_animationController.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() {
|
||||
final ScrollPosition? position = _scrollableState?.position;
|
||||
if (position == null || !position.hasPixels || position.pixels <= 0.0) {
|
||||
@ -1109,11 +1183,13 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
}
|
||||
|
||||
double? target;
|
||||
final double bottomScrollOffset =
|
||||
widget.bottomMode == NavigationBarBottomMode.always ? 0.0 : _bottomHeight;
|
||||
final bool canScrollBottom =
|
||||
widget.bottom != null &&
|
||||
(widget.bottomMode == NavigationBarBottomMode.automatic || widget.bottomMode == null);
|
||||
final double bottomScrollOffset = canScrollBottom ? widget.bottom!.preferredSize.height : 0.0;
|
||||
(widget._searchable || widget.bottom != null) && bottomScrollOffset > 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<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
|
||||
Widget build(BuildContext context) {
|
||||
final _NavigationBarStaticComponents components = _NavigationBarStaticComponents(
|
||||
keys: keys,
|
||||
route: ModalRoute.of(context),
|
||||
userLeading: widget.leading,
|
||||
userLeading:
|
||||
widget.leading != null
|
||||
? Visibility(visible: !searchIsActive, child: widget.leading!)
|
||||
: null,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
automaticallyImplyTitle: widget.automaticallyImplyTitle,
|
||||
previousPageTitle: widget.previousPageTitle,
|
||||
userMiddle: widget.middle,
|
||||
userTrailing: widget.trailing,
|
||||
userMiddle: _animationController.isAnimating ? const Text('') : widget.middle,
|
||||
userTrailing:
|
||||
widget.trailing != null
|
||||
? Visibility(visible: !searchIsActive, child: widget.trailing!)
|
||||
: null,
|
||||
userLargeTitle: widget.largeTitle,
|
||||
padding: widget.padding,
|
||||
large: true,
|
||||
@ -1152,30 +1255,57 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
);
|
||||
|
||||
return MediaQuery.withNoTextScaling(
|
||||
child: SliverPersistentHeader(
|
||||
pinned: true, // iOS navigation bars are always pinned.
|
||||
delegate: _LargeTitleNavigationBarSliverDelegate(
|
||||
keys: keys,
|
||||
components: components,
|
||||
userMiddle: widget.middle,
|
||||
backgroundColor:
|
||||
CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ??
|
||||
CupertinoTheme.of(context).barBackgroundColor,
|
||||
automaticBackgroundVisibility: widget.automaticBackgroundVisibility,
|
||||
brightness: widget.brightness,
|
||||
border: widget.border,
|
||||
padding: widget.padding,
|
||||
actionsForegroundColor: CupertinoTheme.of(context).primaryColor,
|
||||
transitionBetweenRoutes: widget.transitionBetweenRoutes,
|
||||
heroTag: widget.heroTag,
|
||||
persistentHeight: _kNavBarPersistentHeight + MediaQuery.paddingOf(context).top,
|
||||
alwaysShowMiddle: widget.alwaysShowMiddle && widget.middle != null,
|
||||
stretchConfiguration: widget.stretch ? OverScrollHeaderStretchConfiguration() : null,
|
||||
enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur,
|
||||
bottom: widget.bottom ?? const SizedBox.shrink(),
|
||||
bottomMode: widget.bottomMode ?? NavigationBarBottomMode.automatic,
|
||||
bottomHeight: widget.bottom != null ? widget.bottom!.preferredSize.height : 0.0,
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true, // iOS navigation bars are always pinned.
|
||||
delegate: _LargeTitleNavigationBarSliverDelegate(
|
||||
keys: keys,
|
||||
components: components,
|
||||
userMiddle: widget.middle,
|
||||
backgroundColor:
|
||||
CupertinoDynamicColor.maybeResolve(widget.backgroundColor, context) ??
|
||||
CupertinoTheme.of(context).barBackgroundColor,
|
||||
automaticBackgroundVisibility: widget.automaticBackgroundVisibility,
|
||||
brightness: widget.brightness,
|
||||
border: widget.border,
|
||||
padding: widget.padding,
|
||||
actionsForegroundColor: CupertinoTheme.of(context).primaryColor,
|
||||
transitionBetweenRoutes: widget.transitionBetweenRoutes,
|
||||
heroTag: widget.heroTag,
|
||||
persistentHeight: persistentHeightAnimation.value + MediaQuery.paddingOf(context).top,
|
||||
largeTitleHeight: largeTitleHeightAnimation.value,
|
||||
alwaysShowMiddle: widget.alwaysShowMiddle && widget.middle != null,
|
||||
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.heroTag,
|
||||
required this.persistentHeight,
|
||||
required this.largeTitleHeight,
|
||||
required this.alwaysShowMiddle,
|
||||
required this.stretchConfiguration,
|
||||
required this.enableBackgroundFilterBlur,
|
||||
required this.bottom,
|
||||
required this.bottomMode,
|
||||
required this.bottomHeight,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final _NavigationBarStaticComponentsKeys keys;
|
||||
@ -1216,18 +1348,20 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
|
||||
final bool transitionBetweenRoutes;
|
||||
final Object heroTag;
|
||||
final double persistentHeight;
|
||||
final double largeTitleHeight;
|
||||
final bool alwaysShowMiddle;
|
||||
final bool enableBackgroundFilterBlur;
|
||||
final Widget bottom;
|
||||
final NavigationBarBottomMode bottomMode;
|
||||
final double bottomHeight;
|
||||
final AnimationController controller;
|
||||
|
||||
@override
|
||||
double get minExtent =>
|
||||
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<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 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);
|
||||
}
|
||||
|
@ -440,10 +440,9 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> 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!);
|
||||
});
|
||||
|
@ -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: <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 {
|
||||
|
Loading…
Reference in New Issue
Block a user