mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
CupertinoSliverNavigationBar.search fidelity updates (#163089)
## Native iOS overlaid on Flutter view | Inactive search state | Active search state | | --- | --- | | <img alt="Screenshot 2025-02-11 at 3 31 40 PM" src="https://github.com/user-attachments/assets/eefa539e-8e78-4047-8876-c16db26f59df" /> | <img alt="Screenshot 2025-02-11 at 3 34 01 PM" src="https://github.com/user-attachments/assets/2b4b9a20-5a99-493d-b343-ac38b039efa7" /> | Animation curve and duration values gotten from xcode. Addresses some of the issues in https://github.com/flutter/flutter/issues/163020
This commit is contained in:
parent
edc20aca60
commit
2deb3b4be0
@ -39,8 +39,8 @@ void main() {
|
||||
// Middle, large title, and search field are visible.
|
||||
expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5);
|
||||
expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 139.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 132.0);
|
||||
|
||||
await tester.fling(find.text('Drag me up'), bottomDragUp, 50.0);
|
||||
await tester.pumpAndSettle();
|
||||
@ -48,8 +48,8 @@ void main() {
|
||||
// Search field is hidden, but large title and middle title are visible.
|
||||
expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5);
|
||||
expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 104.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 96.0);
|
||||
|
||||
await tester.fling(find.text('Drag me up'), titleDragUp, 50.0);
|
||||
await tester.pumpAndSettle();
|
||||
@ -60,7 +60,7 @@ void main() {
|
||||
tester.getBottomLeft(find.text('Family').first).dy,
|
||||
36.0 + 8.0,
|
||||
); // Static part + _kNavBarBottomPadding.
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 52.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 44.0);
|
||||
});
|
||||
|
||||
testWidgets('Search field is always shown in bottom always mode', (WidgetTester tester) async {
|
||||
@ -75,8 +75,8 @@ void main() {
|
||||
// Middle, large title, and search field are visible.
|
||||
expect(tester.getBottomLeft(find.text('Contacts Group').first).dy, 30.5);
|
||||
expect(tester.getBottomLeft(find.text('Family').first).dy, 88.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 104.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 139.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 96.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 132.0);
|
||||
|
||||
await tester.fling(find.text('Drag me up'), titleDragUp, 50.0);
|
||||
await tester.pumpAndSettle();
|
||||
@ -87,8 +87,8 @@ void main() {
|
||||
tester.getBottomLeft(find.text('Family').first).dy,
|
||||
36.0 + 8.0,
|
||||
); // Static part + _kNavBarBottomPadding.
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 52.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 87.0);
|
||||
expect(tester.getTopLeft(find.byType(CupertinoSearchTextField)).dy, 44.0);
|
||||
expect(tester.getBottomLeft(find.byType(CupertinoSearchTextField)).dy, 80.0);
|
||||
});
|
||||
|
||||
testWidgets('Opens the search view when the search field is tapped', (WidgetTester tester) async {
|
||||
|
@ -66,12 +66,18 @@ 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;
|
||||
///
|
||||
/// Eyeballed on an iPhone 15 simulator running iOS 17.5.
|
||||
const double _kSearchFieldCancelButtonWidth = 67.0;
|
||||
|
||||
/// The duration of the animation when the search field in
|
||||
/// [CupertinoSliverNavigationBar.search] is tapped.
|
||||
const Duration _kNavBarSearchDuration = Duration(milliseconds: 300);
|
||||
|
||||
/// The curve of the animation when the search field in
|
||||
/// [CupertinoSliverNavigationBar.search] is tapped.
|
||||
const Curve _kNavBarSearchCurve = Curves.easeInOut;
|
||||
|
||||
/// Title text transfer fade.
|
||||
const Duration _kNavBarTitleFadeDuration = Duration(milliseconds: 150);
|
||||
|
||||
@ -1070,13 +1076,15 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
|
||||
/// * [PreferredSize], which can be used to give an arbitrary widget a preferred size.
|
||||
final PreferredSizeWidget? bottom;
|
||||
|
||||
/// Modes that determine how to display the navigation bar's [bottom] and scrolling behavior.
|
||||
/// Modes that determine how to display the navigation bar's [bottom], or the
|
||||
/// search field in a [CupertinoSliverNavigationBar.search].
|
||||
///
|
||||
/// Defaults to [NavigationBarBottomMode.automatic] if this is null and a [bottom] is provided.
|
||||
/// If null, defaults to [NavigationBarBottomMode.automatic] if either a
|
||||
/// [bottom] is provided or this is a [CupertinoSliverNavigationBar.search].
|
||||
final NavigationBarBottomMode? bottomMode;
|
||||
|
||||
/// Called when the search field in [CupertinoSliverNavigationBar.search]
|
||||
/// is tapped, toggling the search state between active and inactive.
|
||||
/// is tapped, toggling between an active and an inactive search state.
|
||||
final ValueChanged<bool>? onSearchableBottomTap;
|
||||
|
||||
/// True if the navigation bar's background color has no transparency.
|
||||
@ -1119,6 +1127,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
ScrollableState? _scrollableState;
|
||||
_NavigationBarSearchField? preferredSizeSearchField;
|
||||
late AnimationController _animationController;
|
||||
late CurvedAnimation _searchAnimation;
|
||||
late Animation<double> persistentHeightAnimation;
|
||||
late Animation<double> largeTitleHeightAnimation;
|
||||
bool searchIsActive = false;
|
||||
@ -1148,6 +1157,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
_scrollableState?.position.isScrollingNotifier.removeListener(_handleScrollChange);
|
||||
}
|
||||
_animationController.dispose();
|
||||
_searchAnimation.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -1163,6 +1173,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
|
||||
void _setupSearchableAnimation() {
|
||||
_animationController = AnimationController(vsync: this, duration: _kNavBarSearchDuration);
|
||||
_searchAnimation = CurvedAnimation(parent: _animationController, curve: _kNavBarSearchCurve);
|
||||
final Tween<double> persistentHeightTween = Tween<double>(
|
||||
begin: _kNavBarPersistentHeight,
|
||||
end: 0.0,
|
||||
@ -1211,17 +1222,20 @@ 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;
|
||||
}
|
||||
// If the search animation is stopped, rebuild so that the leading, middle,
|
||||
// and trailing widgets that were collapsed while the search field was
|
||||
// active are re-expanded. Otherwise, rebuild to update this widget with the
|
||||
// animation controller's values.
|
||||
setState(() {
|
||||
switch (status) {
|
||||
case AnimationStatus.forward:
|
||||
searchIsActive = true;
|
||||
case AnimationStatus.reverse:
|
||||
searchIsActive = false;
|
||||
case AnimationStatus.completed:
|
||||
case AnimationStatus.dismissed:
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchFieldTap() {
|
||||
@ -1256,7 +1270,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
|
||||
|
||||
return MediaQuery.withNoTextScaling(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
animation: _searchAnimation,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return SliverPersistentHeader(
|
||||
pinned: true, // iOS navigation bars are always pinned.
|
||||
@ -2263,7 +2277,10 @@ class _InactiveSearchableBottom extends StatelessWidget {
|
||||
// A decoy 'Cancel' button used in the collapsed-to-expanded animation.
|
||||
SizedBox(
|
||||
width: animationController.value * _kSearchFieldCancelButtonWidth,
|
||||
child: _CancelButton(opacity: 0.4, onPressed: () {}),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: _kNavBarBottomPadding),
|
||||
child: _CancelButton(opacity: 0.4, onPressed: () {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -2292,9 +2309,12 @@ class _ActiveSearchableBottom extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _kNavBarEdgePadding),
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: _kNavBarEdgePadding,
|
||||
bottom: _kNavBarBottomPadding,
|
||||
),
|
||||
child: Row(
|
||||
spacing: _kNavBarEdgePadding,
|
||||
spacing: 12.0, // Eyeballed on an iPhone 15 simulator running iOS 17.5.
|
||||
children: <Widget>[
|
||||
Expanded(child: searchField ?? const SizedBox.shrink()),
|
||||
AnimatedBuilder(
|
||||
@ -2322,7 +2342,7 @@ class _NavigationBarSearchField extends StatelessWidget implements PreferredSize
|
||||
const _NavigationBarSearchField({required this.searchField});
|
||||
|
||||
static const double verticalPadding = 8.0;
|
||||
static const double searchFieldHeight = 35.0;
|
||||
static const double searchFieldHeight = 36.0;
|
||||
final Widget searchField;
|
||||
|
||||
@override
|
||||
@ -2331,9 +2351,10 @@ class _NavigationBarSearchField extends StatelessWidget implements PreferredSize
|
||||
child: FocusableActionDetector(
|
||||
descendantsAreFocusable: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _kNavBarEdgePadding,
|
||||
vertical: verticalPadding,
|
||||
padding: const EdgeInsetsDirectional.only(
|
||||
start: _kNavBarEdgePadding,
|
||||
end: _kNavBarEdgePadding,
|
||||
bottom: verticalPadding,
|
||||
),
|
||||
child: SizedBox(height: searchFieldHeight, child: searchField),
|
||||
),
|
||||
@ -2342,7 +2363,7 @@ class _NavigationBarSearchField extends StatelessWidget implements PreferredSize
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(searchFieldHeight + verticalPadding * 2);
|
||||
Size get preferredSize => const Size.fromHeight(searchFieldHeight + verticalPadding);
|
||||
}
|
||||
|
||||
/// This should always be the first child of Hero widgets.
|
||||
|
@ -118,9 +118,9 @@ class CupertinoSearchTextField extends StatefulWidget {
|
||||
this.padding = const EdgeInsetsDirectional.fromSTEB(5.5, 8, 5.5, 8),
|
||||
this.itemColor = CupertinoColors.secondaryLabel,
|
||||
this.itemSize = 20.0,
|
||||
this.prefixInsets = const EdgeInsetsDirectional.fromSTEB(6, 0, 0, 3),
|
||||
this.prefixInsets = const EdgeInsetsDirectional.fromSTEB(6, 8, 0, 8),
|
||||
this.prefixIcon = const Icon(CupertinoIcons.search),
|
||||
this.suffixInsets = const EdgeInsetsDirectional.fromSTEB(0, 0, 5, 2),
|
||||
this.suffixInsets = const EdgeInsetsDirectional.fromSTEB(0, 8, 5, 8),
|
||||
this.suffixIcon = const Icon(CupertinoIcons.xmark_circle_fill),
|
||||
this.suffixMode = OverlayVisibilityMode.editing,
|
||||
this.onSuffixTap,
|
||||
@ -462,6 +462,18 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
|
||||
}
|
||||
}
|
||||
|
||||
// Animate the top padding so that the contents of the search field
|
||||
// move upwards when the search text field is resized on scroll.
|
||||
EdgeInsetsGeometry _animatedInsets(BuildContext context, EdgeInsetsGeometry insets) {
|
||||
final EdgeInsets currentInsets = insets.resolve(Directionality.of(context));
|
||||
final EdgeInsetsGeometry? animatedInsets = EdgeInsetsGeometry.lerp(
|
||||
insets,
|
||||
currentInsets.copyWith(top: currentInsets.top / 2),
|
||||
_fadeExtent,
|
||||
);
|
||||
return animatedInsets ?? insets;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String placeholder =
|
||||
@ -489,19 +501,10 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
|
||||
size: scaledIconSize,
|
||||
);
|
||||
|
||||
// Animate the top padding so that the placeholder and editable text
|
||||
// move when the search text field is resized on scroll.
|
||||
final EdgeInsets currentInsets = widget.padding.resolve(Directionality.of(context));
|
||||
final EdgeInsetsGeometry? padding = EdgeInsetsGeometry.lerp(
|
||||
widget.padding,
|
||||
widget.padding.resolve(Directionality.of(context)).copyWith(top: currentInsets.top / 2),
|
||||
_fadeExtent,
|
||||
);
|
||||
|
||||
final Widget prefix = Opacity(
|
||||
opacity: 1.0 - _fadeExtent,
|
||||
child: Padding(
|
||||
padding: widget.prefixInsets,
|
||||
padding: _animatedInsets(context, widget.prefixInsets),
|
||||
child: IconTheme(data: iconThemeData, child: widget.prefixIcon),
|
||||
),
|
||||
);
|
||||
@ -509,7 +512,7 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
|
||||
final Widget suffix = Opacity(
|
||||
opacity: 1.0 - _fadeExtent,
|
||||
child: Padding(
|
||||
padding: widget.suffixInsets,
|
||||
padding: _animatedInsets(context, widget.suffixInsets),
|
||||
child: CupertinoButton(
|
||||
onPressed: widget.onSuffixTap ?? _defaultOnSuffixTap,
|
||||
minSize: 0,
|
||||
@ -536,7 +539,7 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
|
||||
suffixMode: widget.suffixMode,
|
||||
placeholder: placeholder,
|
||||
placeholderStyle: placeholderStyle,
|
||||
padding: padding ?? widget.padding,
|
||||
padding: _animatedInsets(context, widget.padding),
|
||||
onChanged: widget.onChanged,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
focusNode: widget.focusNode,
|
||||
|
@ -2608,6 +2608,69 @@ void main() {
|
||||
expect(find.widgetWithText(CupertinoButton, 'Cancel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CupertinoSliverNavigationBar.search golden tests', (WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(390, 850));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
const CupertinoApp(
|
||||
home: RepaintBoundary(
|
||||
child: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
CupertinoSliverNavigationBar.search(
|
||||
largeTitle: Text('Large title'),
|
||||
searchField: CupertinoSearchTextField(),
|
||||
),
|
||||
SliverFillRemaining(child: SizedBox(height: 300.0)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
find.byType(CupertinoSliverNavigationBar),
|
||||
matchesGoldenFile('nav_bar.search.inactive.png'),
|
||||
);
|
||||
|
||||
// Tap the search field.
|
||||
await tester.tap(find.byType(CupertinoSearchTextField), warnIfMissed: false);
|
||||
await tester.pump();
|
||||
// Pump halfway through the animation.
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
await expectLater(
|
||||
find.byType(CupertinoSliverNavigationBar),
|
||||
matchesGoldenFile('nav_bar.search.transition_forward.png'),
|
||||
);
|
||||
|
||||
// Pump to the end of the animation.
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
await expectLater(
|
||||
find.byType(CupertinoSliverNavigationBar),
|
||||
matchesGoldenFile('nav_bar.search.active.png'),
|
||||
);
|
||||
|
||||
// Tap the 'Cancel' button to exit the search view.
|
||||
await tester.tap(find.widgetWithText(CupertinoButton, 'Cancel'));
|
||||
await tester.pump();
|
||||
// Pump halfway through the animation.
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
|
||||
await expectLater(
|
||||
find.byType(CupertinoSliverNavigationBar),
|
||||
matchesGoldenFile('nav_bar.search.transition_backward.png'),
|
||||
);
|
||||
|
||||
// Pump for the duration of the search field animation.
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
await expectLater(
|
||||
find.byType(CupertinoSliverNavigationBar),
|
||||
matchesGoldenFile('nav_bar.search.inactive.png'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('onSearchableBottomTap callback', (WidgetTester tester) async {
|
||||
const Color activeSearchColor = Color(0x0000000A);
|
||||
const Color inactiveSearchColor = Color(0x0000000B);
|
||||
|
@ -83,7 +83,7 @@ void main() {
|
||||
expect(
|
||||
tester.getTopLeft(find.text('initial')) -
|
||||
tester.getTopLeft(find.byType(CupertinoSearchTextField)),
|
||||
const Offset(31.5, 8.0),
|
||||
const Offset(31.5, 9.5),
|
||||
);
|
||||
});
|
||||
|
||||
@ -253,6 +253,32 @@ void main() {
|
||||
expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Default prefix and suffix insets are aligned', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const CupertinoApp(home: Center(child: CupertinoSearchTextField())));
|
||||
|
||||
expect(find.byIcon(CupertinoIcons.search), findsOneWidget);
|
||||
expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(CupertinoSearchTextField), 'text input');
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('text input'), findsOneWidget);
|
||||
expect(find.byIcon(CupertinoIcons.search), findsOneWidget);
|
||||
expect(find.byIcon(CupertinoIcons.xmark_circle_fill), findsOneWidget);
|
||||
|
||||
expect(tester.getTopLeft(find.byIcon(CupertinoIcons.search)), const Offset(6.0, 290.0));
|
||||
expect(
|
||||
tester.getTopLeft(find.byIcon(CupertinoIcons.xmark_circle_fill)),
|
||||
const Offset(775.0, 290.0),
|
||||
);
|
||||
|
||||
expect(tester.getBottomRight(find.byIcon(CupertinoIcons.search)), const Offset(26.0, 310.0));
|
||||
expect(
|
||||
tester.getBottomRight(find.byIcon(CupertinoIcons.xmark_circle_fill)),
|
||||
const Offset(795.0, 310.0),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('clear button shows with right visibility mode', (WidgetTester tester) async {
|
||||
TextEditingController controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
|
Loading…
Reference in New Issue
Block a user