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';
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -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();
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
|
@ -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!);
|
||||||
});
|
});
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user