CupertinoSearchTextField and CupertinoSliverNavigationBar.search more fidelity updates (#169708)

This PR does the following:

## Show large title mid-transition if automaticallyImplyLeading is false

(see
https://github.com/flutter/flutter/pull/169708#issuecomment-2922792587)

## Correct color for the CupertinoSearchTextField placeholder 

(see
https://github.com/flutter/flutter/issues/163020#issuecomment-2660522169)

| Dark mode | Light mode | 
| --- | --- |
| <img width="381" alt="placeholder color dark mode"
src="https://github.com/user-attachments/assets/e37e23fa-9f4f-495e-8f02-b9c38a4faffb"
/> | <img width="381" alt="placeholder color light mode"
src="https://github.com/user-attachments/assets/16e24a61-2528-44e0-9afa-8431487cf5ff"
/> |

And also:

- Removes flaky mid-transition goldens
- Fixes a CupertinoTextField crash caused by
https://github.com/flutter/flutter/pull/166952 where size > constraints

Fixes [Improve fidelity of CupertinoSliverNavigationBar.search and
CupertinoSearchTextField](https://github.com/flutter/flutter/issues/163020)
This commit is contained in:
Victor Sanni 2025-06-03 16:16:41 -07:00 committed by GitHub
parent 134ca1c17e
commit b05da524ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 78 additions and 55 deletions

View File

@ -883,12 +883,19 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
/// It should be placed at top of the screen and automatically accounts for
/// the iOS status bar.
///
/// This navigation bar is expanded only in portrait orientation. In landscape
/// mode, the navigation bar remains permanently collapsed. The navigation bar
/// also collapses when scrolling in portrait mode.
///
/// Minimally, a [largeTitle] widget will appear in the middle of the app bar
/// when the sliver is collapsed and transfer to the area below in larger font
/// when the sliver is expanded.
/// when the sliver is expanded. This expanded view will only trigger in
/// portrait orientation, while in landscape mode the bar will stay in its
/// collapsed view.
///
/// For advanced uses, an optional [middle] widget can be supplied to show a
/// different widget in the middle of the navigation bar when the sliver is collapsed.
/// For advanced uses, an optional [middle] widget
/// can be supplied to show a different widget in the middle of the navigation
/// bar when the sliver is collapsed.
///
/// Like [CupertinoNavigationBar], it also supports a [leading] and [trailing]
/// widget on the static section on top that remains while scrolling.
@ -1085,10 +1092,12 @@ class CupertinoSliverNavigationBar extends StatefulWidget {
/// A widget to place in the middle of the static navigation bar instead of
/// the [largeTitle].
///
/// This widget is visible in both collapsed and expanded states if
/// [alwaysShowMiddle] is true, otherwise just in collapsed state. The text
/// supplied in [largeTitle] will no longer appear in collapsed state if a
/// [middle] widget is provided.
/// If [alwaysShowMiddle] is true, this widget is visible in both the
/// collapsed and expanded states of the navigation bar. Else, it is visible
/// only in the collapsed state.
///
/// If null, [largeTitle] will be displayed in the navigation bar's collapsed
/// state.
final Widget? middle;
/// {@macro flutter.cupertino.CupertinoNavigationBar.trailing}
@ -3060,7 +3069,6 @@ class _NavigationBarComponentsTransition {
final KeyedSubtree? bottomLargeTitle =
bottomComponents.largeTitleKey.currentWidget as KeyedSubtree?;
final KeyedSubtree? topBackLabel = topComponents.backLabelKey.currentWidget as KeyedSubtree?;
final KeyedSubtree? topLeading = topComponents.leadingKey.currentWidget as KeyedSubtree?;
if (bottomLargeTitle == null || !bottomLargeExpanded) {
return null;
@ -3093,32 +3101,28 @@ class _NavigationBarComponentsTransition {
);
}
if (topLeading != null) {
// Unlike bottom middle, the bottom large title moves when it can't
// transition to the top back label position.
final RelativeRect from = positionInTransitionBox(
bottomComponents.largeTitleKey,
from: bottomNavBarBox,
);
// Unlike bottom middle, the bottom large title moves when it can't
// transition to the top back label position.
final RelativeRect from = positionInTransitionBox(
bottomComponents.largeTitleKey,
from: bottomNavBarBox,
);
final RelativeRectTween positionTween = RelativeRectTween(
begin: from,
end: from.shift(Offset(forwardDirection * bottomNavBarBox.size.width / 4.0, 0.0)),
);
final RelativeRectTween positionTween = RelativeRectTween(
begin: from,
end: from.shift(Offset(forwardDirection * bottomNavBarBox.size.width / 4.0, 0.0)),
);
// Just shift slightly towards the trailing edge instead of moving to the
// back label position.
return PositionedTransition(
rect: animation.drive(positionTween),
child: FadeTransition(
opacity: fadeOutBy(0.4),
// Keep the font when transitioning into a non-back-label leading.
child: DefaultTextStyle(style: bottomLargeTitleTextStyle!, child: bottomLargeTitle.child),
),
);
}
return null;
// Just shift slightly towards the trailing edge instead of moving to the
// back label position.
return PositionedTransition(
rect: animation.drive(positionTween),
child: FadeTransition(
opacity: fadeOutBy(0.4),
// Keep the font when transitioning into a non-back-label leading.
child: DefaultTextStyle(style: bottomLargeTitleTextStyle!, child: bottomLargeTitle.child),
),
);
}
Widget? get bottomTrailing {

View File

@ -483,10 +483,17 @@ class _CupertinoSearchTextFieldState extends State<CupertinoSearchTextField> wit
Widget build(BuildContext context) {
final String placeholder =
widget.placeholder ?? CupertinoLocalizations.of(context).searchTextFieldPlaceholderLabel;
final Color defaultPlaceholderColor = CupertinoDynamicColor.resolve(
CupertinoColors.secondaryLabel,
context,
);
final TextStyle placeholderStyle =
widget.placeholderStyle ??
TextStyle(color: CupertinoColors.systemGrey.withOpacity(1.0 - _fadeExtent));
TextStyle(
color: defaultPlaceholderColor.withAlpha(
(255 * (defaultPlaceholderColor.a * (1 - _fadeExtent))).round(),
),
);
// The icon size will be scaled by a factor of the accessibility text scale,
// to follow the behavior of `UISearchTextField`.

View File

@ -1879,6 +1879,6 @@ class _RenderBaselineAlignedStack extends RenderBox
width = math.max(width, editableTextSize.width);
final Size size = Size(width, height);
assert(size.isFinite);
return size;
return constraints.constrain(size);
}
}

View File

@ -2846,14 +2846,6 @@ void main() {
// 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));
@ -2865,14 +2857,6 @@ void main() {
// 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));

View File

@ -1850,6 +1850,33 @@ void main() {
expect(find.text('Page 2'), findsOneWidget);
});
testWidgets('Bottom large title is shown mid-transition when top has no leading', (
WidgetTester tester,
) async {
setWindowToPortrait(tester);
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(largeTitle: Text('Page 1')),
to: const CupertinoSliverNavigationBar(
largeTitle: Text('Page 2'),
automaticallyImplyLeading: false,
),
);
// Go to the next page.
await tester.pump(const Duration(milliseconds: 600));
// Start the gesture at the edge of the screen.
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 200.0));
// Trigger the swipe.
await gesture.moveBy(const Offset(200.0, 0.0));
// Back gestures should trigger and draw the hero transition in the very same
// frame (since the "from" route has already moved to reveal the "to" route).
await tester.pump();
expect(flying(tester, find.text('Page 1')), findsOneWidget);
});
testWidgets('Back label is not clipped mid-transition', (WidgetTester tester) async {
const String label = 'backbackback';
await startTransitionBetween(

View File

@ -129,7 +129,7 @@ void main() {
);
Text placeholder = tester.widget(find.text('Search'));
expect(placeholder.style!.color!.value, CupertinoColors.systemGrey.darkColor.value);
expect(placeholder.style!.color!.value, CupertinoColors.secondaryLabel.darkColor.value);
await tester.pumpAndSettle();
@ -141,7 +141,7 @@ void main() {
);
placeholder = tester.widget(find.text('Search'));
expect(placeholder.style!.color!.value, CupertinoColors.systemGrey.color.value);
expect(placeholder.style!.color!.value, CupertinoColors.secondaryLabel.color.value);
});
testWidgets("placeholderStyle modifies placeholder's style and doesn't affect text's style", (
@ -623,7 +623,7 @@ void main() {
expect(suffixIconFinder, findsOneWidget);
expect(placeholderFinder, findsOneWidget);
// Initially, the icons and placeholder text are fully opaque.
// Initially, the icons are fully opaque.
expect(
tester
.widget<Opacity>(find.ancestor(of: prefixIconFinder, matching: find.byType(Opacity)))
@ -636,7 +636,8 @@ void main() {
.opacity,
equals(1.0),
);
expect(tester.widget<Text>(placeholderFinder).style?.color?.a, equals(1.0));
// The default placeholder color is semi-transparent.
expect(tester.widget<Text>(placeholderFinder).style?.color?.a, equals(0.6));
final double searchTextFieldHeight = tester.getSize(searchTextFieldFinder).height;