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:
Victor Sanni 2025-02-20 16:03:58 -08:00 committed by GitHub
parent edc20aca60
commit 2deb3b4be0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 48 deletions

View File

@ -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 {

View File

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

View File

@ -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,

View File

@ -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);

View File

@ -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);