Support navigation during a Cupertino back gesture (#142248)

Fixes a bug where programmatically navigating during an iOS back gesture caused the app to enter an unstable state.
This commit is contained in:
Justin McCandless 2024-02-02 11:27:20 -08:00 committed by GitHub
parent ac7879e2aa
commit 3280be9371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 298 additions and 8 deletions

View file

@ -158,7 +158,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
/// True if an iOS-style back swipe pop gesture is currently underway for [route].
///
/// This just check the route's [NavigatorState.userGestureInProgress].
/// This just checks the route's [NavigatorState.userGestureInProgress].
///
/// See also:
///
@ -247,6 +247,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
return _CupertinoBackGestureController<T>(
navigator: route.navigator!,
getIsCurrent: () => route.isCurrent,
getIsActive: () => route.isActive,
controller: route.controller!, // protected access
);
}
@ -293,6 +295,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route),
getIsCurrent: () => route.isCurrent,
getIsActive: () => route.isActive,
child: child,
),
);
@ -596,6 +600,8 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
required this.enabledCallback,
required this.onStartPopGesture,
required this.child,
required this.getIsActive,
required this.getIsCurrent,
});
final Widget child;
@ -604,6 +610,9 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
final ValueGetter<bool> getIsActive;
final ValueGetter<bool> getIsCurrent;
@override
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
}
@ -724,12 +733,16 @@ class _CupertinoBackGestureController<T> {
_CupertinoBackGestureController({
required this.navigator,
required this.controller,
required this.getIsActive,
required this.getIsCurrent,
}) {
navigator.didStartUserGesture();
}
final AnimationController controller;
final NavigatorState navigator;
final ValueGetter<bool> getIsActive;
final ValueGetter<bool> getIsCurrent;
/// The drag gesture has changed by [fractionalDelta]. The total range of the
/// drag should be 0.0 to 1.0.
@ -745,12 +758,21 @@ class _CupertinoBackGestureController<T> {
// This curve has been determined through rigorously eyeballing native iOS
// animations.
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
final bool isCurrent = getIsCurrent();
final bool animateForward;
// If the user releases the page before mid screen with sufficient velocity,
// or after mid screen, we should animate the page out. Otherwise, the page
// should be animated back in.
if (velocity.abs() >= _kMinFlingVelocity) {
if (!isCurrent) {
// If the page has already been navigated away from, then the animation
// direction depends on whether or not it's still in the navigation stack,
// regardless of velocity or drag position. For example, if a route is
// being slowly dragged back by just a few pixels, but then a programmatic
// pop occurs, the route should still be animated off the screen.
// See https://github.com/flutter/flutter/issues/141268.
animateForward = getIsActive();
} else if (velocity.abs() >= _kMinFlingVelocity) {
// If the user releases the page before mid screen with sufficient velocity,
// or after mid screen, we should animate the page out. Otherwise, the page
// should be animated back in.
animateForward = velocity <= 0;
} else {
animateForward = controller.value > 0.5;
@ -766,8 +788,10 @@ class _CupertinoBackGestureController<T> {
);
controller.animateTo(1.0, duration: Duration(milliseconds: droppedPageForwardAnimationTime), curve: animationCurve);
} else {
// This route is destined to pop at this point. Reuse navigator's pop.
navigator.pop();
if (isCurrent) {
// This route is destined to pop at this point. Reuse navigator's pop.
navigator.pop();
}
// The popping may have finished inline if already at the target destination.
if (controller.isAnimating) {

View file

@ -2919,7 +2919,7 @@ class _RouteEntry extends RouteTransitionRecord {
initialState == _RouteLifecycle.pushReplace ||
initialState == _RouteLifecycle.replace,
),
currentState = initialState {
currentState = initialState {
// TODO(polina-c): stop duplicating code across disposables
// https://github.com/flutter/flutter/issues/137435
if (kFlutterMemoryAllocationsEnabled) {

View file

@ -377,6 +377,272 @@ void main() {
);
});
testWidgets('Back swipe less than halfway is interrupted by route pop', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/141268
final GlobalKey scaffoldKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
key: scaffoldKey,
child: Center(
child: Column(
children: <Widget>[
const Text('Page 1'),
CupertinoButton(
onPressed: () {
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 2')),
);
},
));
},
child: const Text('Push Page 2'),
),
],
),
),
),
),
);
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('Page 2'), findsNothing);
await tester.tap(find.text('Push Page 2'));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsOneWidget);
// Start a back gesture and move it less than 50% across the screen.
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
await gesture.moveBy(const Offset(100.0, 0.0));
await tester.pump();
expect( // The second route has been dragged to the right.
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
const Offset(100.0, 0.0),
);
expect( // The first route is sliding in from the left.
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
lessThan(0),
);
// Programmatically pop and observe that Page 2 was popped as if there were
// no back gesture.
Navigator.pop<void>(scaffoldKey.currentContext!);
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))),
Offset.zero,
);
expect(find.text('Page 2'), findsNothing);
});
testWidgets('Back swipe more than halfway is interrupted by route pop', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/141268
final GlobalKey scaffoldKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
key: scaffoldKey,
child: Center(
child: Column(
children: <Widget>[
const Text('Page 1'),
CupertinoButton(
onPressed: () {
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 2')),
);
},
));
},
child: const Text('Push Page 2'),
),
],
),
),
),
),
);
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('Page 2'), findsNothing);
await tester.tap(find.text('Push Page 2'));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsOneWidget);
// Start a back gesture and move it more than 50% across the screen.
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
await gesture.moveBy(const Offset(500.0, 0.0));
await tester.pump();
expect( // The second route has been dragged to the right.
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
const Offset(500.0, 0.0),
);
expect( // The first route is sliding in from the left.
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
lessThan(0),
);
// Programmatically pop and observe that Page 2 was popped as if there were
// no back gesture.
Navigator.pop<void>(scaffoldKey.currentContext!);
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))),
Offset.zero,
);
expect(find.text('Page 2'), findsNothing);
});
testWidgets('Back swipe less than halfway is interrupted by route push', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/141268
final GlobalKey scaffoldKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
key: scaffoldKey,
child: Center(
child: Column(
children: <Widget>[
const Text('Page 1'),
CupertinoButton(
onPressed: () {
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 2')),
);
},
));
},
child: const Text('Push Page 2'),
),
],
),
),
),
),
);
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('Page 2'), findsNothing);
await tester.tap(find.text('Push Page 2'));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsOneWidget);
// Start a back gesture and move it less than 50% across the screen.
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
await gesture.moveBy(const Offset(100.0, 0.0));
await tester.pump();
expect( // The second route has been dragged to the right.
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
const Offset(100.0, 0.0),
);
expect( // The first route is sliding in from the left.
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
lessThan(0),
);
// Programmatically push and observe that Page 3 was pushed as if there were
// no back gesture.
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 3')),
);
},
));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsNothing);
expect(
tester.getTopLeft(find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold))),
Offset.zero,
);
});
testWidgets('Back swipe more than halfway is interrupted by route push', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/141268
final GlobalKey scaffoldKey = GlobalKey();
await tester.pumpWidget(
CupertinoApp(
home: CupertinoPageScaffold(
key: scaffoldKey,
child: Center(
child: Column(
children: <Widget>[
const Text('Page 1'),
CupertinoButton(
onPressed: () {
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 2')),
);
},
));
},
child: const Text('Push Page 2'),
),
],
),
),
),
),
);
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('Page 2'), findsNothing);
await tester.tap(find.text('Push Page 2'));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsOneWidget);
// Start a back gesture and move it more than 50% across the screen.
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
await gesture.moveBy(const Offset(500.0, 0.0));
await tester.pump();
expect( // The second route has been dragged to the right.
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
const Offset(500.0, 0.0),
);
expect( // The first route is sliding in from the left.
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
lessThan(0),
);
// Programmatically push and observe that Page 3 was pushed as if there were
// no back gesture.
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
builder: (BuildContext context) {
return const CupertinoPageScaffold(
child: Center(child: Text('Page 3')),
);
},
));
await tester.pumpAndSettle();
expect(find.text('Page 1'), findsNothing);
expect(find.text('Page 2'), findsNothing);
expect(
tester.getTopLeft(find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold))),
Offset.zero,
);
});
testWidgets('Fullscreen route animates correct transform values over time', (WidgetTester tester) async {
await tester.pumpWidget(
CupertinoApp(