Unverified Commit a0b560f4 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Workaround rounding erros in cupertino nav bar transition (#88935)

parent a3a77ac9
...@@ -80,6 +80,52 @@ class _HeroTag { ...@@ -80,6 +80,52 @@ class _HeroTag {
} }
} }
// An `AnimatedWidget` that imposes a fixed size on its child widget, and
// shifts the child widget in the parent stack, driven by its `offsetAnimation`
// property.
class _FixedSizeSlidingTransition extends AnimatedWidget {
const _FixedSizeSlidingTransition({
Key? key,
required this.isLTR,
required this.offsetAnimation,
required this.size,
required this.child,
}) : super(key: key, listenable: offsetAnimation);
// Whether the writing direction used in the navigation bar transition is
// left-to-right.
final bool isLTR;
// The fixed size to impose on `child`.
final Size size;
// The animated offset from the top-leading corner of the stack.
//
// When `isLTR` is true, the `Offset` is the position of the child widget in
// the stack render box's regular coordinate space.
//
// When `isLTR` is false, the coordinate system is flipped around the
// horizontal axis and the origin is set to the top right corner of the render
// boxes. In other words, this parameter describes the offset from the top
// right corner of the stack, to the top right corner of the child widget, and
// the x-axis runs right to left.
final Animation<Offset> offsetAnimation;
final Widget child;
@override
Widget build(BuildContext context) {
return Positioned(
top: offsetAnimation.value.dy,
left: isLTR ? offsetAnimation.value.dx : null,
right: isLTR ? null : offsetAnimation.value.dx,
width: size.width,
height: size.height,
child: child,
);
}
}
/// Returns `child` wrapped with background and a bottom border if background color /// Returns `child` wrapped with background and a bottom border if background color
/// is opaque. Otherwise, also blur with [BackdropFilter]. /// is opaque. Otherwise, also blur with [BackdropFilter].
/// ///
...@@ -1709,48 +1755,73 @@ class _NavigationBarComponentsTransition { ...@@ -1709,48 +1755,73 @@ class _NavigationBarComponentsTransition {
); );
} }
// Create a Tween that moves a widget between its original position in its // Create an animated widget that moves the given child widget between its
// ancestor navigation bar to another widget's position in that widget's // original position in its ancestor navigation bar to another widget's
// navigation bar. // position in that widget's navigation bar.
// //
// Anchor their positions based on the vertical middle of their respective // Anchor their positions based on the vertical middle of their respective
// render boxes' leading edge. // render boxes' leading edge.
// //
// Also produce RelativeRects with sizes that would preserve the constant // This method assumes there's no other transforms other than translations
// BoxConstraints of the 'from' widget so that animating font sizes etc don't // when converting a rect from the original navigation bar's coordinate space
// produce rounding error artifacts with a linearly resizing rect. // to the other navigation bar's coordinate space, to avoid performing
RelativeRectTween slideFromLeadingEdge({ // floating point operations on the size of the child widget, so that the
// incoming constraints used for sizing the child widget will be exactly the
// same.
_FixedSizeSlidingTransition slideFromLeadingEdge({
required GlobalKey fromKey, required GlobalKey fromKey,
required RenderBox fromNavBarBox, required RenderBox fromNavBarBox,
required GlobalKey toKey, required GlobalKey toKey,
required RenderBox toNavBarBox, required RenderBox toNavBarBox,
required Widget child,
}) { }) {
final RelativeRect fromRect = positionInTransitionBox(fromKey, from: fromNavBarBox);
final RenderBox fromBox = fromKey.currentContext!.findRenderObject()! as RenderBox; final RenderBox fromBox = fromKey.currentContext!.findRenderObject()! as RenderBox;
final RenderBox toBox = toKey.currentContext!.findRenderObject()! as RenderBox; final RenderBox toBox = toKey.currentContext!.findRenderObject()! as RenderBox;
// We move a box with the size of the 'from' render object such that its final bool isLTR = forwardDirection > 0;
// upper left corner is at the upper left corner of the 'to' render object.
// With slight y axis adjustment for those render objects' height differences.
Rect toRect =
toBox.localToGlobal(
Offset.zero,
ancestor: toNavBarBox,
).translate(
0.0,
- fromBox.size.height / 2 + toBox.size.height / 2,
) & fromBox.size; // Keep the from render object's size.
if (forwardDirection < 0) { // The animation moves the fromBox so its anchor (left-center or right-center
// If RTL, move the center right to the center right instead of matching // depending on the writing direction) aligns with toBox's anchor.
// the center lefts. final Offset fromAnchorLocal = Offset(
toRect = toRect.translate(- fromBox.size.width + toBox.size.width, 0.0); isLTR ? 0 : fromBox.size.width,
} fromBox.size.height / 2,
);
final Offset toAnchorLocal = Offset(
isLTR ? 0 : toBox.size.width,
toBox.size.height / 2,
);
final Offset fromAnchorInFromBox = fromBox.localToGlobal(fromAnchorLocal, ancestor: fromNavBarBox);
final Offset toAnchorInToBox = toBox.localToGlobal(toAnchorLocal, ancestor: toNavBarBox);
// We can't get ahold of the render box of the stack (i.e., `transitionBox`)
// we place components on yet, but we know the stack needs to be top-leading
// aligned with both fromNavBarBox and toNavBarBox to make the transition
// look smooth. Also use the top-leading point as the origin for ease of
// calculation.
// The offset to move fromAnchor to toAnchor, in transitionBox's top-leading
// coordinates.
final Offset translation = isLTR
? toAnchorInToBox - fromAnchorInFromBox
: Offset(toNavBarBox.size.width - toAnchorInToBox.dx, toAnchorInToBox.dy)
- Offset(fromNavBarBox.size.width - fromAnchorInFromBox.dx, fromAnchorInFromBox.dy);
return RelativeRectTween( final RelativeRect fromBoxMargin = positionInTransitionBox(fromKey, from: fromNavBarBox);
begin: fromRect, final Offset fromOriginInTransitionBox = Offset(
end: RelativeRect.fromRect(toRect, transitionBox), isLTR ? fromBoxMargin.left : fromBoxMargin.right,
fromBoxMargin.top,
);
final Tween<Offset> anchorMovementInTransitionBox = Tween<Offset>(
begin: fromOriginInTransitionBox,
end: fromOriginInTransitionBox + translation,
);
return _FixedSizeSlidingTransition(
isLTR: isLTR,
offsetAnimation: animation.drive(anchorMovementInTransitionBox),
size: fromBox.size,
child: child,
); );
} }
...@@ -1846,13 +1917,11 @@ class _NavigationBarComponentsTransition { ...@@ -1846,13 +1917,11 @@ class _NavigationBarComponentsTransition {
if (bottomMiddle != null && topBackLabel != null) { if (bottomMiddle != null && topBackLabel != null) {
// Move from current position to the top page's back label position. // Move from current position to the top page's back label position.
return PositionedTransition( return slideFromLeadingEdge(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.middleKey, fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox, fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey, toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox, toNavBarBox: topNavBarBox,
)),
child: FadeTransition( child: FadeTransition(
// A custom middle widget like a segmented control fades away faster. // A custom middle widget like a segmented control fades away faster.
opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7), opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7),
...@@ -1903,13 +1972,11 @@ class _NavigationBarComponentsTransition { ...@@ -1903,13 +1972,11 @@ class _NavigationBarComponentsTransition {
if (bottomLargeTitle != null && topBackLabel != null) { if (bottomLargeTitle != null && topBackLabel != null) {
// Move from current position to the top page's back label position. // Move from current position to the top page's back label position.
return PositionedTransition( return slideFromLeadingEdge(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey, fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox, fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey, toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox, toNavBarBox: topNavBarBox,
)),
child: FadeTransition( child: FadeTransition(
opacity: fadeOutBy(0.6), opacity: fadeOutBy(0.6),
child: Align( child: Align(
...@@ -2063,13 +2130,11 @@ class _NavigationBarComponentsTransition { ...@@ -2063,13 +2130,11 @@ class _NavigationBarComponentsTransition {
if (bottomLargeTitle != null && if (bottomLargeTitle != null &&
topBackLabel != null && topBackLabel != null &&
bottomLargeExpanded) { bottomLargeExpanded) {
return PositionedTransition( return slideFromLeadingEdge(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey, fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox, fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey, toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox, toNavBarBox: topNavBarBox,
)),
child: FadeTransition( child: FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.4), opacity: midClickOpacity ?? fadeInFrom(0.4),
child: DefaultTextStyleTransition( child: DefaultTextStyleTransition(
...@@ -2088,13 +2153,11 @@ class _NavigationBarComponentsTransition { ...@@ -2088,13 +2153,11 @@ class _NavigationBarComponentsTransition {
// The topBackLabel always comes from the large title first if available // The topBackLabel always comes from the large title first if available
// and expanded instead of middle. // and expanded instead of middle.
if (bottomMiddle != null && topBackLabel != null) { if (bottomMiddle != null && topBackLabel != null) {
return PositionedTransition( return slideFromLeadingEdge(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.middleKey, fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox, fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey, toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox, toNavBarBox: topNavBarBox,
)),
child: FadeTransition( child: FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.3), opacity: midClickOpacity ?? fadeInFrom(0.3),
child: DefaultTextStyleTransition( child: DefaultTextStyleTransition(
...@@ -2125,20 +2188,32 @@ class _NavigationBarComponentsTransition { ...@@ -2125,20 +2188,32 @@ class _NavigationBarComponentsTransition {
} }
final RelativeRect to = positionInTransitionBox(topComponents.middleKey, from: topNavBarBox); final RelativeRect to = positionInTransitionBox(topComponents.middleKey, from: topNavBarBox);
final RenderBox toBox = topComponents.middleKey.currentContext!.findRenderObject()! as RenderBox;
final bool isLTR = forwardDirection > 0;
// Anchor is the top-leading point of toBox, in transition box's top-leading
// coordinate space.
final Offset toAnchorInTransitionBox = Offset(
isLTR ? to.left : to.right,
to.top,
);
// Shift in from the trailing edge of the screen. // Shift in from the trailing edge of the screen.
final RelativeRectTween positionTween = RelativeRectTween( final Tween<Offset> anchorMovementInTransitionBox = Tween<Offset>(
begin: to.shift( begin: Offset(
Offset( // the "width / 2" here makes the middle widget's horizontal center on
forwardDirection * topNavBarBox.size.width / 2.0, // the trailing edge of the top nav bar.
0.0, topNavBarBox.size.width - toBox.size.width / 2,
), to.top,
), ),
end: to, end: toAnchorInTransitionBox,
); );
return PositionedTransition( return _FixedSizeSlidingTransition(
rect: animation.drive(positionTween), isLTR: isLTR,
offsetAnimation: animation.drive(anchorMovementInTransitionBox),
size: toBox.size,
child: FadeTransition( child: FadeTransition(
opacity: fadeInFrom(0.25), opacity: fadeInFrom(0.25),
child: DefaultTextStyle( child: DefaultTextStyle(
......
...@@ -496,7 +496,7 @@ class RenderStack extends RenderBox ...@@ -496,7 +496,7 @@ class RenderStack extends RenderBox
child.layout(childConstraints, parentUsesSize: true); child.layout(childConstraints, parentUsesSize: true);
late final double x; final double x;
if (childParentData.left != null) { if (childParentData.left != null) {
x = childParentData.left!; x = childParentData.left!;
} else if (childParentData.right != null) { } else if (childParentData.right != null) {
...@@ -508,7 +508,7 @@ class RenderStack extends RenderBox ...@@ -508,7 +508,7 @@ class RenderStack extends RenderBox
if (x < 0.0 || x + child.size.width > size.width) if (x < 0.0 || x + child.size.width > size.width)
hasVisualOverflow = true; hasVisualOverflow = true;
late final double y; final double y;
if (childParentData.top != null) { if (childParentData.top != null) {
y = childParentData.top!; y = childParentData.top!;
} else if (childParentData.bottom != null) { } else if (childParentData.bottom != null) {
......
...@@ -171,6 +171,27 @@ void main() { ...@@ -171,6 +171,27 @@ void main() {
); );
}); });
testWidgets('Bottom middle never changes size during the animation', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1080.0 / 2.75, 600));
addTearDown(() async {
await tester.binding.setSurfaceSize(const Size(800.0, 600.0));
});
await startTransitionBetween(
tester,
fromTitle: 'Page 1',
);
final Size size = tester.getSize(find.text('Page 1'));
for (int i = 0; i < 150; i++) {
await tester.pump(const Duration(milliseconds: 1));
expect(flying(tester, find.text('Page 1')), findsNWidgets(2));
expect(tester.getSize(flying(tester, find.text('Page 1')).first), size);
expect(tester.getSize(flying(tester, find.text('Page 1')).last), size);
}
});
testWidgets('Bottom middle and top back label transitions their font', (WidgetTester tester) async { testWidgets('Bottom middle and top back label transitions their font', (WidgetTester tester) async {
await startTransitionBetween(tester, fromTitle: 'Page 1'); await startTransitionBetween(tester, fromTitle: 'Page 1');
...@@ -978,6 +999,29 @@ void main() { ...@@ -978,6 +999,29 @@ void main() {
); );
}); });
testWidgets('Top middle never changes size during the animation', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1080.0 / 2.75, 600));
addTearDown(() async {
await tester.binding.setSurfaceSize(const Size(800.0, 600.0));
});
await startTransitionBetween(
tester,
toTitle: 'Page 2',
);
Size? previousSize;
for (int i = 0; i < 150; i++) {
await tester.pump(const Duration(milliseconds: 1));
expect(flying(tester, find.text('Page 2')), findsOneWidget);
final Size size = tester.getSize(flying(tester, find.text('Page 2')));
if (previousSize != null)
expect(size, previousSize);
previousSize = size;
}
});
testWidgets('Top middle fades in and slides in from the left in RTL', (WidgetTester tester) async { testWidgets('Top middle fades in and slides in from the left in RTL', (WidgetTester tester) async {
await startTransitionBetween( await startTransitionBetween(
tester, tester,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment