mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
9771b4e464
commit
40e990fd17
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user