Clip search artifacts in CupertinoSliverNavigationBar searchable-to-searchable transitions (#168510)

In https://github.com/flutter/flutter/pull/166705, non-searchable
transitions were clipped. This PR also clips searchable-to-searchable
transitions.

Also replaces type check to detect searchable-to-searchable transitions.

| Before | After | 
| --- | --- |
| <img width="377" alt="before bottom search artifactsd"
src="https://github.com/user-attachments/assets/a0726905-6dc1-463e-8cbb-bf1f5626fb1a"
/> | <img width="379" alt="nav bar search artifacts bottom"
src="https://github.com/user-attachments/assets/9474b3e5-a7a9-4a0e-9395-5f62106e65ad"
/> |
| <img width="379" alt="before top search artifacts"
src="https://github.com/user-attachments/assets/adf2b34d-ffa9-4c86-bee5-6683b06ec44f"
/> | <img width="379" alt="nav bar search artifacts top"
src="https://github.com/user-attachments/assets/a7a8edd8-0c66-4e3d-8958-3152bf55e42a"
/> |


Fixes [Transition between two CupertinoSliverNavigationBar.search routes
shows search artifacts mid-transition if one is collapsed and one is
extended](https://github.com/flutter/flutter/issues/168494)
This commit is contained in:
Victor Sanni 2025-05-13 09:56:59 -07:00 committed by GitHub
parent 9771b4e464
commit 40e990fd17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 172 additions and 62 deletions

View File

@ -71,6 +71,10 @@ const double _kNavBarBackButtonTapWidth = 50.0;
/// Eyeballed on an iPhone 15 simulator running iOS 17.5.
const double _kSearchFieldCancelButtonWidth = 67.0;
/// The height of the unscaled search field used in
/// a [CupertinoSliverNavigationBar.search].
const double _kSearchFieldHeight = 36.0;
/// The duration of the animation when the search field in
/// [CupertinoSliverNavigationBar.search] is tapped.
const Duration _kNavBarSearchDuration = Duration(milliseconds: 300);
@ -831,6 +835,7 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
border: effectiveBorder,
hasUserMiddle: widget.middle != null,
largeExpanded: widget.largeTitle != null,
searchable: false,
automaticBackgroundVisibility: widget.automaticBackgroundVisibility,
child: navBar,
),
@ -1153,7 +1158,6 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
with TickerProviderStateMixin {
late _NavigationBarStaticComponentsKeys keys;
ScrollableState? _scrollableState;
_NavigationBarSearchField? preferredSizeSearchField;
Widget? effectiveMiddle;
late AnimationController _animationController;
late CurvedAnimation _searchAnimation;
@ -1167,10 +1171,6 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
super.initState();
keys = _NavigationBarStaticComponentsKeys();
_setupSearchableAnimation();
if (widget._searchable) {
assert(widget.searchField != null);
preferredSizeSearchField = _NavigationBarSearchField(searchField: widget.searchField!);
}
}
@override
@ -1202,7 +1202,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
double get _bottomHeight {
assert(!widget._searchable || widget.bottom == null);
if (widget._searchable) {
return preferredSizeSearchField!.preferredSize.height;
return _kSearchFieldHeight + _kNavBarBottomPadding;
} else if (widget.bottom != null) {
return widget.bottom!.preferredSize.height;
}
@ -1304,12 +1304,14 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: widget.searchField,
searchFieldHeight: _kSearchFieldHeight,
onSearchFieldTap: _onSearchFieldTap,
)
: _InactiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: preferredSizeSearchField,
searchField: widget.searchField,
searchFieldHeight: _kSearchFieldHeight,
onSearchFieldTap: _onSearchFieldTap,
)
: widget.bottom) ??
@ -1352,6 +1354,7 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
: widget.bottomMode ?? NavigationBarBottomMode.automatic,
bottomHeight: _bottomHeight,
controller: _animationController,
searchable: widget._searchable,
),
);
},
@ -1382,6 +1385,7 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
required this.bottomMode,
required this.bottomHeight,
required this.controller,
required this.searchable,
});
final _NavigationBarStaticComponentsKeys keys;
@ -1402,6 +1406,7 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
final NavigationBarBottomMode bottomMode;
final double bottomHeight;
final AnimationController controller;
final bool searchable;
@override
double get minExtent =>
@ -1547,6 +1552,7 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
border: effectiveBorder,
hasUserMiddle: userMiddle != null && (alwaysShowMiddle || !showLargeTitle),
largeExpanded: showLargeTitle,
searchable: searchable,
automaticBackgroundVisibility: automaticBackgroundVisibility,
child: navBar,
),
@ -1570,7 +1576,8 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur ||
bottomMode != oldDelegate.bottomMode ||
bottomHeight != oldDelegate.bottomHeight ||
controller != oldDelegate.controller;
controller != oldDelegate.controller ||
searchable != oldDelegate.searchable;
}
}
@ -2300,20 +2307,37 @@ class _InactiveSearchableBottom extends StatelessWidget {
const _InactiveSearchableBottom({
required this.animationController,
required this.searchField,
required this.onSearchFieldTap,
required this.animation,
required this.searchFieldHeight,
required this.onSearchFieldTap,
});
final AnimationController animationController;
final _NavigationBarSearchField? searchField;
final Widget? searchField;
final Animation<double> animation;
final double searchFieldHeight;
final void Function()? onSearchFieldTap;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
child: GestureDetector(onTap: onSearchFieldTap, child: searchField),
child: GestureDetector(
onTap: onSearchFieldTap,
child: AbsorbPointer(
child: FocusableActionDetector(
descendantsAreFocusable: false,
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: _kNavBarEdgePadding,
end: _kNavBarEdgePadding,
bottom: _kNavBarBottomPadding,
),
child: SizedBox(height: searchFieldHeight, child: searchField),
),
),
),
),
builder: (BuildContext context, Widget? child) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
@ -2349,12 +2373,14 @@ class _ActiveSearchableBottom extends StatelessWidget {
required this.animationController,
required this.searchField,
required this.animation,
required this.searchFieldHeight,
required this.onSearchFieldTap,
});
final AnimationController animationController;
final Widget? searchField;
final Animation<double> animation;
final double searchFieldHeight;
final void Function()? onSearchFieldTap;
@override
@ -2367,7 +2393,12 @@ class _ActiveSearchableBottom extends StatelessWidget {
child: Row(
spacing: 12.0, // Eyeballed on an iPhone 15 simulator running iOS 17.5.
children: <Widget>[
Expanded(child: searchField ?? const SizedBox.shrink()),
Expanded(
child: SizedBox(
height: searchFieldHeight,
child: searchField ?? const SizedBox.shrink(),
),
),
AnimatedBuilder(
animation: animation,
child: FadeTransition(
@ -2387,36 +2418,6 @@ class _ActiveSearchableBottom extends StatelessWidget {
}
}
/// 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 = 36.0;
final Widget searchField;
@override
Widget build(BuildContext context) {
return AbsorbPointer(
child: FocusableActionDetector(
descendantsAreFocusable: false,
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: _kNavBarEdgePadding,
end: _kNavBarEdgePadding,
bottom: verticalPadding,
),
child: SizedBox(height: searchFieldHeight, child: searchField),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(searchFieldHeight + verticalPadding);
}
/// This should always be the first child of Hero widgets.
///
/// This class helps each Hero transition obtain the start or end navigation
@ -2435,6 +2436,7 @@ class _TransitionableNavigationBar extends StatelessWidget {
required this.border,
required this.hasUserMiddle,
required this.largeExpanded,
required this.searchable,
required this.automaticBackgroundVisibility,
required this.child,
}) : assert(!largeExpanded || largeTitleTextStyle != null),
@ -2448,6 +2450,7 @@ class _TransitionableNavigationBar extends StatelessWidget {
final Border? border;
final bool hasUserMiddle;
final bool largeExpanded;
final bool searchable;
final bool automaticBackgroundVisibility;
final Widget child;
@ -2621,6 +2624,7 @@ class _NavigationBarComponentsTransition {
bottomAutomaticBackgroundVisibility = bottomNavBar.automaticBackgroundVisibility,
userGestureInProgress =
topNavBar.userGestureInProgress || bottomNavBar.userGestureInProgress,
searchable = topNavBar.searchable && bottomNavBar.searchable,
transitionBox =
// paintBounds are based on offset zero so it's ok to expand the Rects.
bottomNavBar.renderBox.paintBounds.expandToInclude(topNavBar.renderBox.paintBounds),
@ -2651,6 +2655,7 @@ class _NavigationBarComponentsTransition {
final bool bottomLargeExpanded;
final bool topLargeExpanded;
final bool userGestureInProgress;
final bool searchable;
final bool bottomAutomaticBackgroundVisibility;
final Color? bottomBackgroundColor;
@ -2993,8 +2998,6 @@ class _NavigationBarComponentsTransition {
Widget? get bottomNavBarBottom {
final KeyedSubtree? bottomNavBarBottom =
bottomComponents.navBarBottomKey.currentWidget as KeyedSubtree?;
final KeyedSubtree? topNavBarBottom =
topComponents.navBarBottomKey.currentWidget as KeyedSubtree?;
if (bottomNavBarBottom == null) {
return null;
@ -3018,13 +3021,8 @@ class _NavigationBarComponentsTransition {
// Fade out only if this is not a CupertinoSliverNavigationBar.search to
// CupertinoSliverNavigationBar.search transition.
if (topNavBarBottom == null ||
topNavBarBottom.child is! _InactiveSearchableBottom ||
bottomNavBarBottom.child is! _InactiveSearchableBottom) {
child = FadeTransition(
opacity: fadeOutBy(0.8, curve: animationCurve),
child: ClipRect(child: child),
);
if (!searchable) {
child = FadeTransition(opacity: fadeOutBy(0.8, curve: animationCurve), child: child);
}
return PositionedTransition(
@ -3034,7 +3032,7 @@ class _NavigationBarComponentsTransition {
? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween)
: animation.drive(CurveTween(curve: animationCurve)).drive(positionTween),
child: child,
child: ClipRect(child: child),
);
}
@ -3320,8 +3318,6 @@ class _NavigationBarComponentsTransition {
Widget? get topNavBarBottom {
final KeyedSubtree? topNavBarBottom =
topComponents.navBarBottomKey.currentWidget as KeyedSubtree?;
final KeyedSubtree? bottomNavBarBottom =
bottomComponents.navBarBottomKey.currentWidget as KeyedSubtree?;
if (topNavBarBottom == null) {
return null;
@ -3346,13 +3342,8 @@ class _NavigationBarComponentsTransition {
// Fade in only if this is not a CupertinoSliverNavigationBar.search to
// CupertinoSliverNavigationBar.search transition.
if (bottomNavBarBottom == null ||
bottomNavBarBottom.child is! _InactiveSearchableBottom ||
topNavBarBottom.child is! _InactiveSearchableBottom) {
child = FadeTransition(
opacity: fadeInFrom(0.0, curve: animationCurve),
child: ClipRect(child: child),
);
if (!searchable) {
child = FadeTransition(opacity: fadeInFrom(0.0, curve: animationCurve), child: child);
}
return PositionedTransition(
@ -3361,7 +3352,7 @@ class _NavigationBarComponentsTransition {
userGestureInProgress
? routeAnimation.drive(CurveTween(curve: Curves.linear)).drive(positionTween)
: animation.drive(CurveTween(curve: animationCurve)).drive(positionTween),
child: child,
child: ClipRect(child: child),
);
}
}

View File

@ -1313,6 +1313,125 @@ void main() {
},
);
testWidgets(
'CupertinoSliverNavigationBar searchable-to-searchable transition clips its contents mid-transition when scrolled',
(WidgetTester tester) async {
setWindowToPortrait(tester);
await tester.pumpWidget(
CupertinoApp(
builder: (BuildContext context, Widget? navigator) {
return navigator!;
},
home: const Placeholder(),
),
);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(
CupertinoPageRoute<void>(
title: 'Page 1',
builder:
(BuildContext context) =>
scaffoldForNavBar(
const CupertinoSliverNavigationBar.search(
searchField: CupertinoSearchTextField(
suffixMode: OverlayVisibilityMode.always,
suffixIcon: Icon(CupertinoIcons.mic_solid),
),
),
)!,
),
);
await tester.pumpAndSettle();
final TestGesture scrollGesture1 = await tester.startGesture(
tester.getCenter(find.byType(CustomScrollView)),
);
await scrollGesture1.moveBy(const Offset(0, -300));
await scrollGesture1.up();
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget);
expect(find.byIcon(CupertinoIcons.search), findsOneWidget);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(
CupertinoPageRoute<void>(
title: 'Page 2',
builder:
(BuildContext context) =>
scaffoldForNavBar(
const CupertinoSliverNavigationBar.search(
searchField: CupertinoSearchTextField(
suffixMode: OverlayVisibilityMode.always,
suffixIcon: Icon(CupertinoIcons.mic_solid),
),
),
)!,
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byIcon(CupertinoIcons.mic_solid), findsNWidgets(4));
expect(find.byIcon(CupertinoIcons.search), findsNWidgets(4));
await expectLater(
find.byType(CupertinoApp),
matchesGoldenFile('nav_bar_transition.search_to_search.bottom.png'),
);
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget);
expect(find.byIcon(CupertinoIcons.search), findsOneWidget);
tester
.state<NavigatorState>(find.byType(Navigator))
.push(
CupertinoPageRoute<void>(
title: 'Page 3',
builder:
(BuildContext context) =>
scaffoldForNavBar(
const CupertinoSliverNavigationBar.search(
searchField: CupertinoSearchTextField(
suffixMode: OverlayVisibilityMode.always,
suffixIcon: Icon(CupertinoIcons.mic_solid),
),
),
)!,
),
);
await tester.pumpAndSettle();
final TestGesture scrollGesture2 = await tester.startGesture(
tester.getCenter(find.byType(CustomScrollView)),
);
await scrollGesture2.moveBy(const Offset(0, -300));
await scrollGesture2.up();
await tester.pumpAndSettle();
expect(find.byIcon(CupertinoIcons.mic_solid), findsOneWidget);
expect(find.byIcon(CupertinoIcons.search), findsOneWidget);
tester.state<NavigatorState>(find.byType(Navigator)).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.byIcon(CupertinoIcons.mic_solid), findsNWidgets(4));
expect(find.byIcon(CupertinoIcons.search), findsNWidgets(4));
await expectLater(
find.byType(CupertinoApp),
matchesGoldenFile('nav_bar_transition.search_to_search.top.png'),
);
await tester.pumpAndSettle();
},
);
testWidgets('Long title turns into the word back mid transition', (WidgetTester tester) async {
setWindowToPortrait(tester);
await startTransitionBetween(