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 {
}
}
// 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
/// is opaque. Otherwise, also blur with [BackdropFilter].
///
......@@ -1709,49 +1755,74 @@ class _NavigationBarComponentsTransition {
);
}
// Create a Tween that moves a widget between its original position in its
// ancestor navigation bar to another widget's position in that widget's
// navigation bar.
// Create an animated widget that moves the given child widget between its
// original position in its ancestor navigation bar to another widget's
// position in that widget's navigation bar.
//
// Anchor their positions based on the vertical middle of their respective
// render boxes' leading edge.
//
// Also produce RelativeRects with sizes that would preserve the constant
// BoxConstraints of the 'from' widget so that animating font sizes etc don't
// produce rounding error artifacts with a linearly resizing rect.
RelativeRectTween slideFromLeadingEdge({
// This method assumes there's no other transforms other than translations
// when converting a rect from the original navigation bar's coordinate space
// to the other navigation bar's coordinate space, to avoid performing
// 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 RenderBox fromNavBarBox,
required GlobalKey toKey,
required RenderBox toNavBarBox,
required Widget child,
}) {
final RelativeRect fromRect = positionInTransitionBox(fromKey, from: fromNavBarBox);
final RenderBox fromBox = fromKey.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
// 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.
final bool isLTR = forwardDirection > 0;
if (forwardDirection < 0) {
// If RTL, move the center right to the center right instead of matching
// the center lefts.
toRect = toRect.translate(- fromBox.size.width + toBox.size.width, 0.0);
}
// The animation moves the fromBox so its anchor (left-center or right-center
// depending on the writing direction) aligns with toBox's anchor.
final Offset fromAnchorLocal = Offset(
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);
final RelativeRect fromBoxMargin = positionInTransitionBox(fromKey, from: fromNavBarBox);
final Offset fromOriginInTransitionBox = Offset(
isLTR ? fromBoxMargin.left : fromBoxMargin.right,
fromBoxMargin.top,
);
return RelativeRectTween(
begin: fromRect,
end: RelativeRect.fromRect(toRect, transitionBox),
);
final Tween<Offset> anchorMovementInTransitionBox = Tween<Offset>(
begin: fromOriginInTransitionBox,
end: fromOriginInTransitionBox + translation,
);
return _FixedSizeSlidingTransition(
isLTR: isLTR,
offsetAnimation: animation.drive(anchorMovementInTransitionBox),
size: fromBox.size,
child: child,
);
}
Animation<double> fadeInFrom(double t, { Curve curve = Curves.easeIn }) {
......@@ -1846,13 +1917,11 @@ class _NavigationBarComponentsTransition {
if (bottomMiddle != null && topBackLabel != null) {
// Move from current position to the top page's back label position.
return PositionedTransition(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
)),
return slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
child: FadeTransition(
// A custom middle widget like a segmented control fades away faster.
opacity: fadeOutBy(bottomHasUserMiddle ? 0.4 : 0.7),
......@@ -1903,13 +1972,11 @@ class _NavigationBarComponentsTransition {
if (bottomLargeTitle != null && topBackLabel != null) {
// Move from current position to the top page's back label position.
return PositionedTransition(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
)),
return slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
child: FadeTransition(
opacity: fadeOutBy(0.6),
child: Align(
......@@ -2063,13 +2130,11 @@ class _NavigationBarComponentsTransition {
if (bottomLargeTitle != null &&
topBackLabel != null &&
bottomLargeExpanded) {
return PositionedTransition(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
)),
return slideFromLeadingEdge(
fromKey: bottomComponents.largeTitleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
child: FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.4),
child: DefaultTextStyleTransition(
......@@ -2088,13 +2153,11 @@ class _NavigationBarComponentsTransition {
// The topBackLabel always comes from the large title first if available
// and expanded instead of middle.
if (bottomMiddle != null && topBackLabel != null) {
return PositionedTransition(
rect: animation.drive(slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
)),
return slideFromLeadingEdge(
fromKey: bottomComponents.middleKey,
fromNavBarBox: bottomNavBarBox,
toKey: topComponents.backLabelKey,
toNavBarBox: topNavBarBox,
child: FadeTransition(
opacity: midClickOpacity ?? fadeInFrom(0.3),
child: DefaultTextStyleTransition(
......@@ -2125,20 +2188,32 @@ class _NavigationBarComponentsTransition {
}
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.
final RelativeRectTween positionTween = RelativeRectTween(
begin: to.shift(
Offset(
forwardDirection * topNavBarBox.size.width / 2.0,
0.0,
),
final Tween<Offset> anchorMovementInTransitionBox = Tween<Offset>(
begin: Offset(
// the "width / 2" here makes the middle widget's horizontal center on
// the trailing edge of the top nav bar.
topNavBarBox.size.width - toBox.size.width / 2,
to.top,
),
end: to,
end: toAnchorInTransitionBox,
);
return PositionedTransition(
rect: animation.drive(positionTween),
return _FixedSizeSlidingTransition(
isLTR: isLTR,
offsetAnimation: animation.drive(anchorMovementInTransitionBox),
size: toBox.size,
child: FadeTransition(
opacity: fadeInFrom(0.25),
child: DefaultTextStyle(
......
......@@ -496,7 +496,7 @@ class RenderStack extends RenderBox
child.layout(childConstraints, parentUsesSize: true);
late final double x;
final double x;
if (childParentData.left != null) {
x = childParentData.left!;
} else if (childParentData.right != null) {
......@@ -508,7 +508,7 @@ class RenderStack extends RenderBox
if (x < 0.0 || x + child.size.width > size.width)
hasVisualOverflow = true;
late final double y;
final double y;
if (childParentData.top != null) {
y = childParentData.top!;
} else if (childParentData.bottom != null) {
......
......@@ -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 {
await startTransitionBetween(tester, fromTitle: 'Page 1');
......@@ -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 {
await startTransitionBetween(
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