Align nav bar bottom transition with large title animation (#162097)

Makes the bottom widget sync up with the large title in hero transitions
between nav bars.

## Before


https://github.com/user-attachments/assets/3f8c67c3-20c2-4751-b29b-7db8d3f3409f

## After

https://github.com/user-attachments/assets/5e4c966f-1818-4851-87a1-0bf613ebda0b


## Native searchable-to-searchable:



https://github.com/user-attachments/assets/56cf93e0-e529-4ca8-9f49-4e40f710e5ed

## Flutter searchable-to-searchable:




https://github.com/user-attachments/assets/a98d9f53-8d4b-44cf-afa9-541751c21172







Fixes [CupertinoSliverNavigationBar/CupertinoNavigationBar bottom is not
displayed during nav bar flying hero
transitions](https://github.com/flutter/flutter/issues/162203)

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [ ] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Victor Sanni 2025-03-10 12:36:04 -07:00 committed by GitHub
parent ffaec10986
commit cd433d4d36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 307 additions and 31 deletions

View File

@ -728,6 +728,7 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
userTrailing: widget.trailing,
padding: widget.padding,
userLargeTitle: widget.largeTitle,
userBottom: widget.bottom,
large: widget.largeTitle != null,
staticBar: true, // This one does not scroll
context: context,
@ -764,7 +765,8 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
),
),
),
if (widget.bottom != null) SizedBox(height: bottomHeight, child: widget.bottom),
if (widget.bottom != null)
SizedBox(height: bottomHeight, child: components.navBarBottom),
],
),
);
@ -775,7 +777,8 @@ class _CupertinoNavigationBarState extends State<CupertinoNavigationBar> {
child: Column(
children: <Widget>[
navBar,
if (widget.bottom != null) SizedBox(height: bottomHeight, child: widget.bottom),
if (widget.bottom != null)
SizedBox(height: bottomHeight, child: components.navBarBottom),
],
),
);
@ -1265,6 +1268,23 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
? Visibility(visible: !searchIsActive, child: widget.trailing!)
: null,
userLargeTitle: widget.largeTitle,
userBottom:
(widget._searchable
? searchIsActive
? _ActiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: widget.searchField,
onSearchFieldTap: _onSearchFieldTap,
)
: _InactiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: preferredSizeSearchField,
onSearchFieldTap: _onSearchFieldTap,
)
: widget.bottom) ??
const SizedBox.shrink(),
padding: widget.padding,
large: true,
staticBar: false, // This one scrolls.
@ -1297,23 +1317,6 @@ class _CupertinoSliverNavigationBarState extends State<CupertinoSliverNavigation
stretchConfiguration:
widget.stretch && !searchIsActive ? OverScrollHeaderStretchConfiguration() : null,
enableBackgroundFilterBlur: widget.enableBackgroundFilterBlur,
bottom:
(widget._searchable
? searchIsActive
? _ActiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: widget.searchField,
onSearchFieldTap: _onSearchFieldTap,
)
: _InactiveSearchableBottom(
animationController: _animationController,
animation: persistentHeightAnimation,
searchField: preferredSizeSearchField,
onSearchFieldTap: _onSearchFieldTap,
)
: widget.bottom) ??
const SizedBox.shrink(),
bottomMode:
searchIsActive
? NavigationBarBottomMode.always
@ -1347,7 +1350,6 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
required this.alwaysShowMiddle,
required this.stretchConfiguration,
required this.enableBackgroundFilterBlur,
required this.bottom,
required this.bottomMode,
required this.bottomHeight,
required this.controller,
@ -1368,7 +1370,6 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
final double largeTitleHeight;
final bool alwaysShowMiddle;
final bool enableBackgroundFilterBlur;
final Widget bottom;
final NavigationBarBottomMode bottomMode;
final double bottomHeight;
final AnimationController controller;
@ -1482,14 +1483,14 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
bottom: 0.0,
child: SizedBox(
height: bottomHeight * (1.0 - bottomShrinkFactor),
child: ClipRect(child: bottom),
child: ClipRect(child: components.navBarBottom),
),
),
],
),
),
if (bottomMode == NavigationBarBottomMode.always)
SizedBox(height: bottomHeight, child: bottom),
SizedBox(height: bottomHeight, child: components.navBarBottom),
],
),
),
@ -1537,7 +1538,6 @@ class _LargeTitleNavigationBarSliverDelegate extends SliverPersistentHeaderDeleg
alwaysShowMiddle != oldDelegate.alwaysShowMiddle ||
heroTag != oldDelegate.heroTag ||
enableBackgroundFilterBlur != oldDelegate.enableBackgroundFilterBlur ||
bottom != oldDelegate.bottom ||
bottomMode != oldDelegate.bottomMode ||
bottomHeight != oldDelegate.bottomHeight ||
controller != oldDelegate.controller;
@ -1775,7 +1775,8 @@ class _NavigationBarStaticComponentsKeys {
backLabelKey = GlobalKey(debugLabel: 'Back label'),
middleKey = GlobalKey(debugLabel: 'Middle'),
trailingKey = GlobalKey(debugLabel: 'Trailing'),
largeTitleKey = GlobalKey(debugLabel: 'Large title');
largeTitleKey = GlobalKey(debugLabel: 'Large title'),
navBarBottomKey = GlobalKey(debugLabel: 'Navigation bar bottom');
final GlobalKey navBarBoxKey;
final GlobalKey leadingKey;
@ -1784,6 +1785,7 @@ class _NavigationBarStaticComponentsKeys {
final GlobalKey middleKey;
final GlobalKey trailingKey;
final GlobalKey largeTitleKey;
final GlobalKey navBarBottomKey;
}
// Based on various user Widgets and other parameters, construct KeyedSubtree
@ -1802,6 +1804,7 @@ class _NavigationBarStaticComponents {
required Widget? userMiddle,
required Widget? userTrailing,
required Widget? userLargeTitle,
required Widget? userBottom,
required EdgeInsetsDirectional? padding,
required bool large,
required bool staticBar,
@ -1847,6 +1850,10 @@ class _NavigationBarStaticComponents {
route: route,
automaticImplyTitle: automaticallyImplyTitle,
large: large,
),
navBarBottom = createNavBarBottom(
navBarBottomKey: keys.navBarBottomKey,
userBottom: userBottom,
);
static Widget? _derivedTitle({
@ -2024,6 +2031,14 @@ class _NavigationBarStaticComponents {
return KeyedSubtree(key: largeTitleKey, child: largeTitleContent!);
}
final KeyedSubtree? navBarBottom;
static KeyedSubtree? createNavBarBottom({
required GlobalKey navBarBottomKey,
required Widget? userBottom,
}) {
return KeyedSubtree(key: navBarBottomKey, child: userBottom ?? const SizedBox.shrink());
}
}
/// A nav bar back button typically used in [CupertinoNavigationBar].
@ -2517,6 +2532,7 @@ class _NavigationBarTransition extends StatelessWidget {
if (componentsTransition.bottomMiddle != null) componentsTransition.bottomMiddle!,
if (componentsTransition.bottomLargeTitle != null) componentsTransition.bottomLargeTitle!,
if (componentsTransition.bottomTrailing != null) componentsTransition.bottomTrailing!,
if (componentsTransition.bottomNavBarBottom != null) componentsTransition.bottomNavBarBottom!,
// Draw top components on top of the bottom components.
if (componentsTransition.topLeading != null) componentsTransition.topLeading!,
if (componentsTransition.topBackChevron != null) componentsTransition.topBackChevron!,
@ -2524,6 +2540,7 @@ class _NavigationBarTransition extends StatelessWidget {
if (componentsTransition.topMiddle != null) componentsTransition.topMiddle!,
if (componentsTransition.topLargeTitle != null) componentsTransition.topLargeTitle!,
if (componentsTransition.topTrailing != null) componentsTransition.topTrailing!,
if (componentsTransition.topNavBarBottom != null) componentsTransition.topNavBarBottom!,
];
// The actual outer box is big enough to contain both the bottom and top
@ -2897,6 +2914,39 @@ 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;
}
final RelativeRect from = positionInTransitionBox(
bottomComponents.navBarBottomKey,
from: bottomNavBarBox,
);
// Shift in from the leading edge of the screen.
final RelativeRectTween positionTween = RelativeRectTween(
begin: from,
end: from.shift(Offset(-forwardDirection * bottomNavBarBox.size.width, 0.0)),
);
Widget child = bottomNavBarBottom.child;
// 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), child: child);
}
return PositionedTransition(rect: animation.drive(positionTween), child: child);
}
Widget? get topLeading {
final KeyedSubtree? topLeading = topComponents.leadingKey.currentWidget as KeyedSubtree?;
@ -3093,7 +3143,7 @@ class _NavigationBarComponentsTransition {
return PositionedTransition(
rect: animation.drive(positionTween),
child: FadeTransition(
opacity: fadeInFrom(0.3),
opacity: fadeInFrom(0.0),
child: DefaultTextStyle(
style: topLargeTitleTextStyle!,
maxLines: 1,
@ -3103,6 +3153,39 @@ 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;
}
final RelativeRect to = positionInTransitionBox(
topComponents.navBarBottomKey,
from: topNavBarBox,
);
// Shift in from the trailing edge of the screen.
final RelativeRectTween positionTween = RelativeRectTween(
begin: to.shift(Offset(forwardDirection * topNavBarBox.size.width, 0.0)),
end: to,
);
Widget child = topNavBarBottom.child;
// 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), child: child);
}
return PositionedTransition(rect: animation.drive(positionTween), child: child);
}
}
/// Navigation bars' hero rect tween that will move between the static bars

View File

@ -131,7 +131,7 @@ void checkOpacity(WidgetTester tester, Finder finder, double opacity) {
)
.opacity
.value,
moreOrLessEquals(opacity),
moreOrLessEquals(opacity, epsilon: 0.001),
);
}
@ -1020,6 +1020,78 @@ void main() {
);
});
testWidgets('Bottom CupertinoSliverNavigationBar.bottom fades and slides out from the left', (
WidgetTester tester,
) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar(
bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()),
),
fromTitle: 'Page 1',
);
await tester.pump(const Duration(milliseconds: 50));
// There's 2, one from the bottom large title fading out and one from the
// bottom back label fading in.
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
expect(flying(tester, find.byType(Placeholder)), findsOneWidget);
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.946);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(-20.58, epsilon: 0.01),
);
await tester.pump(const Duration(milliseconds: 200));
// Halfway through the transition, the bottom is only slightly visible.
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(-620.46, epsilon: 0.01),
);
});
testWidgets('Bottom CupertinoNavigationBar.bottom fades and slides out from the left', (
WidgetTester tester,
) async {
await startTransitionBetween(
tester,
from: const CupertinoNavigationBar(
bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()),
),
fromTitle: 'Page 1',
);
await tester.pump(const Duration(milliseconds: 50));
// There's 2, one from the bottom large title fading out and one from the
// bottom back label fading in.
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
expect(flying(tester, find.byType(Placeholder)), findsOneWidget);
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.946);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(-20.58, epsilon: 0.01),
);
await tester.pump(const Duration(milliseconds: 200));
// Halfway through the transition, the bottom is only slightly visible.
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(-620.46, epsilon: 0.01),
);
});
testWidgets('Long title turns into the word back mid transition', (WidgetTester tester) async {
await startTransitionBetween(
tester,
@ -1179,7 +1251,7 @@ void main() {
expect(flying(tester, find.text('Page 2')), findsOneWidget);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.001);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(795.4206738471985, 54.0),
@ -1187,7 +1259,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.2601277381181717);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.444);
expect(
tester.getTopLeft(flying(tester, find.text('Page 2'))),
const Offset(325.3008875846863, 54.0),
@ -1208,7 +1280,7 @@ void main() {
expect(flying(tester, find.text('Page 2')), findsOneWidget);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.0);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.001);
expect(
tester.getTopRight(flying(tester, find.text('Page 2'))),
const Offset(4.579326152801514, 54.0),
@ -1216,13 +1288,134 @@ void main() {
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.2601277381181717);
checkOpacity(tester, flying(tester, find.text('Page 2')), 0.444);
expect(
tester.getTopRight(flying(tester, find.text('Page 2'))),
const Offset(474.6991124153137, 54.0),
);
});
testWidgets('Top CupertinoSliverNavigationBar.bottom is aligned with top large title animation', (
WidgetTester tester,
) async {
const double horizontalPadding = 16.0; // _kNavBarEdgePadding
const double height = 30.0;
await startTransitionBetween(
tester,
toTitle: 'Page 2',
to: const CupertinoSliverNavigationBar(
bottom: PreferredSize(preferredSize: Size.fromHeight(height), child: Placeholder()),
),
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('Page 2')), findsOneWidget);
expect(flying(tester, find.byType(Placeholder)), findsOneWidget);
final double largeTitleOpacity =
tester
.firstRenderObject<RenderAnimatedOpacity>(
find.ancestor(
of: flying(tester, find.text('Page 2')),
matching: find.byType(FadeTransition),
),
)
.opacity
.value;
checkOpacity(tester, flying(tester, find.byType(Placeholder)), largeTitleOpacity);
Offset largeTitleOffset = tester.getTopLeft(flying(tester, find.text('Page 2')));
// The nav bar bottom is horizontally aligned to the large title.
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
largeTitleOffset.dx - horizontalPadding,
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.444);
largeTitleOffset = tester.getTopLeft(flying(tester, find.text('Page 2')));
// The nav bar bottom is horizontally aligned to the large title.
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
largeTitleOffset.dx - horizontalPadding,
);
});
testWidgets('Top CupertinoNavigationBar.bottom fades and slides in to the right', (
WidgetTester tester,
) async {
await startTransitionBetween(
tester,
toTitle: 'Page 2',
to: const CupertinoNavigationBar(
bottom: PreferredSize(preferredSize: Size.fromHeight(30.0), child: Placeholder()),
),
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.text('Page 2')), findsOneWidget);
expect(flying(tester, find.byType(Placeholder)), findsOneWidget);
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.001);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(779.42, epsilon: 0.01),
);
await tester.pump(const Duration(milliseconds: 150));
checkOpacity(tester, flying(tester, find.byType(Placeholder)), 0.444);
expect(
tester.getTopLeft(flying(tester, find.byType(Placeholder))).dx,
moreOrLessEquals(309.30, epsilon: 0.01),
);
});
testWidgets('Searchable-to-searchable transition does not fade', (WidgetTester tester) async {
await startTransitionBetween(
tester,
from: const CupertinoSliverNavigationBar.search(searchField: CupertinoSearchTextField()),
to: const CupertinoSliverNavigationBar.search(searchField: CupertinoSearchTextField()),
fromTitle: 'Page 1',
toTitle: 'Page 2',
);
await tester.pump(const Duration(milliseconds: 50));
expect(flying(tester, find.byType(CupertinoSearchTextField)), findsNWidgets(2));
// Either no FadeTransition ancestor is found, or one is found but there is no fade.
expect(
find.ancestor(
of: find.byType(CupertinoSearchTextField).first,
matching: find.byType(FadeTransition),
),
findsNothing,
);
checkOpacity(tester, flying(tester, find.byType(CupertinoSearchTextField).last), 1.0);
await tester.pump(const Duration(milliseconds: 150));
// Either no FadeTransition ancestor is found, or one is found but there is no fade.
expect(
find.ancestor(
of: find.byType(CupertinoSearchTextField).first,
matching: find.byType(FadeTransition),
),
findsNothing,
);
checkOpacity(tester, flying(tester, find.byType(CupertinoSearchTextField).last), 1.0);
});
testWidgets('Components are not unnecessarily rebuilt during transitions', (
WidgetTester tester,
) async {