Skip to content

Commit 3280be9

Browse files
authored
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.
1 parent ac7879e commit 3280be9

File tree

3 files changed

+298
-8
lines changed

3 files changed

+298
-8
lines changed

packages/flutter/lib/src/cupertino/route.dart

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
158158

159159
/// True if an iOS-style back swipe pop gesture is currently underway for [route].
160160
///
161-
/// This just check the route's [NavigatorState.userGestureInProgress].
161+
/// This just checks the route's [NavigatorState.userGestureInProgress].
162162
///
163163
/// See also:
164164
///
@@ -247,6 +247,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
247247

248248
return _CupertinoBackGestureController<T>(
249249
navigator: route.navigator!,
250+
getIsCurrent: () => route.isCurrent,
251+
getIsActive: () => route.isActive,
250252
controller: route.controller!, // protected access
251253
);
252254
}
@@ -293,6 +295,8 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
293295
child: _CupertinoBackGestureDetector<T>(
294296
enabledCallback: () => _isPopGestureEnabled<T>(route),
295297
onStartPopGesture: () => _startPopGesture<T>(route),
298+
getIsCurrent: () => route.isCurrent,
299+
getIsActive: () => route.isActive,
296300
child: child,
297301
),
298302
);
@@ -596,6 +600,8 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
596600
required this.enabledCallback,
597601
required this.onStartPopGesture,
598602
required this.child,
603+
required this.getIsActive,
604+
required this.getIsCurrent,
599605
});
600606

601607
final Widget child;
@@ -604,6 +610,9 @@ class _CupertinoBackGestureDetector<T> extends StatefulWidget {
604610

605611
final ValueGetter<_CupertinoBackGestureController<T>> onStartPopGesture;
606612

613+
final ValueGetter<bool> getIsActive;
614+
final ValueGetter<bool> getIsCurrent;
615+
607616
@override
608617
_CupertinoBackGestureDetectorState<T> createState() => _CupertinoBackGestureDetectorState<T>();
609618
}
@@ -724,12 +733,16 @@ class _CupertinoBackGestureController<T> {
724733
_CupertinoBackGestureController({
725734
required this.navigator,
726735
required this.controller,
736+
required this.getIsActive,
737+
required this.getIsCurrent,
727738
}) {
728739
navigator.didStartUserGesture();
729740
}
730741

731742
final AnimationController controller;
732743
final NavigatorState navigator;
744+
final ValueGetter<bool> getIsActive;
745+
final ValueGetter<bool> getIsCurrent;
733746

734747
/// The drag gesture has changed by [fractionalDelta]. The total range of the
735748
/// drag should be 0.0 to 1.0.
@@ -745,12 +758,21 @@ class _CupertinoBackGestureController<T> {
745758
// This curve has been determined through rigorously eyeballing native iOS
746759
// animations.
747760
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
761+
final bool isCurrent = getIsCurrent();
748762
final bool animateForward;
749763

750-
// If the user releases the page before mid screen with sufficient velocity,
751-
// or after mid screen, we should animate the page out. Otherwise, the page
752-
// should be animated back in.
753-
if (velocity.abs() >= _kMinFlingVelocity) {
764+
if (!isCurrent) {
765+
// If the page has already been navigated away from, then the animation
766+
// direction depends on whether or not it's still in the navigation stack,
767+
// regardless of velocity or drag position. For example, if a route is
768+
// being slowly dragged back by just a few pixels, but then a programmatic
769+
// pop occurs, the route should still be animated off the screen.
770+
// See https://github.com/flutter/flutter/issues/141268.
771+
animateForward = getIsActive();
772+
} else if (velocity.abs() >= _kMinFlingVelocity) {
773+
// If the user releases the page before mid screen with sufficient velocity,
774+
// or after mid screen, we should animate the page out. Otherwise, the page
775+
// should be animated back in.
754776
animateForward = velocity <= 0;
755777
} else {
756778
animateForward = controller.value > 0.5;
@@ -766,8 +788,10 @@ class _CupertinoBackGestureController<T> {
766788
);
767789
controller.animateTo(1.0, duration: Duration(milliseconds: droppedPageForwardAnimationTime), curve: animationCurve);
768790
} else {
769-
// This route is destined to pop at this point. Reuse navigator's pop.
770-
navigator.pop();
791+
if (isCurrent) {
792+
// This route is destined to pop at this point. Reuse navigator's pop.
793+
navigator.pop();
794+
}
771795

772796
// The popping may have finished inline if already at the target destination.
773797
if (controller.isAnimating) {

packages/flutter/lib/src/widgets/navigator.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2919,7 +2919,7 @@ class _RouteEntry extends RouteTransitionRecord {
29192919
initialState == _RouteLifecycle.pushReplace ||
29202920
initialState == _RouteLifecycle.replace,
29212921
),
2922-
currentState = initialState {
2922+
currentState = initialState {
29232923
// TODO(polina-c): stop duplicating code across disposables
29242924
// https://github.com/flutter/flutter/issues/137435
29252925
if (kFlutterMemoryAllocationsEnabled) {

packages/flutter/test/cupertino/route_test.dart

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,272 @@ void main() {
377377
);
378378
});
379379

380+
testWidgets('Back swipe less than halfway is interrupted by route pop', (WidgetTester tester) async {
381+
// Regression test for https://github.com/flutter/flutter/issues/141268
382+
final GlobalKey scaffoldKey = GlobalKey();
383+
384+
await tester.pumpWidget(
385+
CupertinoApp(
386+
home: CupertinoPageScaffold(
387+
key: scaffoldKey,
388+
child: Center(
389+
child: Column(
390+
children: <Widget>[
391+
const Text('Page 1'),
392+
CupertinoButton(
393+
onPressed: () {
394+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
395+
builder: (BuildContext context) {
396+
return const CupertinoPageScaffold(
397+
child: Center(child: Text('Page 2')),
398+
);
399+
},
400+
));
401+
},
402+
child: const Text('Push Page 2'),
403+
),
404+
],
405+
),
406+
),
407+
),
408+
),
409+
);
410+
411+
expect(find.text('Page 1'), findsOneWidget);
412+
expect(find.text('Page 2'), findsNothing);
413+
414+
await tester.tap(find.text('Push Page 2'));
415+
await tester.pumpAndSettle();
416+
expect(find.text('Page 1'), findsNothing);
417+
expect(find.text('Page 2'), findsOneWidget);
418+
419+
// Start a back gesture and move it less than 50% across the screen.
420+
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
421+
await gesture.moveBy(const Offset(100.0, 0.0));
422+
await tester.pump();
423+
expect( // The second route has been dragged to the right.
424+
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
425+
const Offset(100.0, 0.0),
426+
);
427+
expect( // The first route is sliding in from the left.
428+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
429+
lessThan(0),
430+
);
431+
432+
// Programmatically pop and observe that Page 2 was popped as if there were
433+
// no back gesture.
434+
Navigator.pop<void>(scaffoldKey.currentContext!);
435+
await tester.pumpAndSettle();
436+
expect(
437+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))),
438+
Offset.zero,
439+
);
440+
expect(find.text('Page 2'), findsNothing);
441+
});
442+
443+
testWidgets('Back swipe more than halfway is interrupted by route pop', (WidgetTester tester) async {
444+
// Regression test for https://github.com/flutter/flutter/issues/141268
445+
final GlobalKey scaffoldKey = GlobalKey();
446+
447+
await tester.pumpWidget(
448+
CupertinoApp(
449+
home: CupertinoPageScaffold(
450+
key: scaffoldKey,
451+
child: Center(
452+
child: Column(
453+
children: <Widget>[
454+
const Text('Page 1'),
455+
CupertinoButton(
456+
onPressed: () {
457+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
458+
builder: (BuildContext context) {
459+
return const CupertinoPageScaffold(
460+
child: Center(child: Text('Page 2')),
461+
);
462+
},
463+
));
464+
},
465+
child: const Text('Push Page 2'),
466+
),
467+
],
468+
),
469+
),
470+
),
471+
),
472+
);
473+
474+
expect(find.text('Page 1'), findsOneWidget);
475+
expect(find.text('Page 2'), findsNothing);
476+
477+
await tester.tap(find.text('Push Page 2'));
478+
await tester.pumpAndSettle();
479+
expect(find.text('Page 1'), findsNothing);
480+
expect(find.text('Page 2'), findsOneWidget);
481+
482+
// Start a back gesture and move it more than 50% across the screen.
483+
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
484+
await gesture.moveBy(const Offset(500.0, 0.0));
485+
await tester.pump();
486+
expect( // The second route has been dragged to the right.
487+
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
488+
const Offset(500.0, 0.0),
489+
);
490+
expect( // The first route is sliding in from the left.
491+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
492+
lessThan(0),
493+
);
494+
495+
// Programmatically pop and observe that Page 2 was popped as if there were
496+
// no back gesture.
497+
Navigator.pop<void>(scaffoldKey.currentContext!);
498+
await tester.pumpAndSettle();
499+
expect(
500+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))),
501+
Offset.zero,
502+
);
503+
expect(find.text('Page 2'), findsNothing);
504+
});
505+
506+
testWidgets('Back swipe less than halfway is interrupted by route push', (WidgetTester tester) async {
507+
// Regression test for https://github.com/flutter/flutter/issues/141268
508+
final GlobalKey scaffoldKey = GlobalKey();
509+
510+
await tester.pumpWidget(
511+
CupertinoApp(
512+
home: CupertinoPageScaffold(
513+
key: scaffoldKey,
514+
child: Center(
515+
child: Column(
516+
children: <Widget>[
517+
const Text('Page 1'),
518+
CupertinoButton(
519+
onPressed: () {
520+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
521+
builder: (BuildContext context) {
522+
return const CupertinoPageScaffold(
523+
child: Center(child: Text('Page 2')),
524+
);
525+
},
526+
));
527+
},
528+
child: const Text('Push Page 2'),
529+
),
530+
],
531+
),
532+
),
533+
),
534+
),
535+
);
536+
537+
expect(find.text('Page 1'), findsOneWidget);
538+
expect(find.text('Page 2'), findsNothing);
539+
540+
await tester.tap(find.text('Push Page 2'));
541+
await tester.pumpAndSettle();
542+
expect(find.text('Page 1'), findsNothing);
543+
expect(find.text('Page 2'), findsOneWidget);
544+
545+
// Start a back gesture and move it less than 50% across the screen.
546+
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
547+
await gesture.moveBy(const Offset(100.0, 0.0));
548+
await tester.pump();
549+
expect( // The second route has been dragged to the right.
550+
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
551+
const Offset(100.0, 0.0),
552+
);
553+
expect( // The first route is sliding in from the left.
554+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
555+
lessThan(0),
556+
);
557+
558+
// Programmatically push and observe that Page 3 was pushed as if there were
559+
// no back gesture.
560+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
561+
builder: (BuildContext context) {
562+
return const CupertinoPageScaffold(
563+
child: Center(child: Text('Page 3')),
564+
);
565+
},
566+
));
567+
await tester.pumpAndSettle();
568+
expect(find.text('Page 1'), findsNothing);
569+
expect(find.text('Page 2'), findsNothing);
570+
expect(
571+
tester.getTopLeft(find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold))),
572+
Offset.zero,
573+
);
574+
});
575+
576+
testWidgets('Back swipe more than halfway is interrupted by route push', (WidgetTester tester) async {
577+
// Regression test for https://github.com/flutter/flutter/issues/141268
578+
final GlobalKey scaffoldKey = GlobalKey();
579+
580+
await tester.pumpWidget(
581+
CupertinoApp(
582+
home: CupertinoPageScaffold(
583+
key: scaffoldKey,
584+
child: Center(
585+
child: Column(
586+
children: <Widget>[
587+
const Text('Page 1'),
588+
CupertinoButton(
589+
onPressed: () {
590+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
591+
builder: (BuildContext context) {
592+
return const CupertinoPageScaffold(
593+
child: Center(child: Text('Page 2')),
594+
);
595+
},
596+
));
597+
},
598+
child: const Text('Push Page 2'),
599+
),
600+
],
601+
),
602+
),
603+
),
604+
),
605+
);
606+
607+
expect(find.text('Page 1'), findsOneWidget);
608+
expect(find.text('Page 2'), findsNothing);
609+
610+
await tester.tap(find.text('Push Page 2'));
611+
await tester.pumpAndSettle();
612+
expect(find.text('Page 1'), findsNothing);
613+
expect(find.text('Page 2'), findsOneWidget);
614+
615+
// Start a back gesture and move it more than 50% across the screen.
616+
final TestGesture gesture = await tester.startGesture(const Offset(5.0, 300.0));
617+
await gesture.moveBy(const Offset(500.0, 0.0));
618+
await tester.pump();
619+
expect( // The second route has been dragged to the right.
620+
tester.getTopLeft(find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold))),
621+
const Offset(500.0, 0.0),
622+
);
623+
expect( // The first route is sliding in from the left.
624+
tester.getTopLeft(find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold))).dx,
625+
lessThan(0),
626+
);
627+
628+
// Programmatically push and observe that Page 3 was pushed as if there were
629+
// no back gesture.
630+
Navigator.push<void>(scaffoldKey.currentContext!, CupertinoPageRoute<void>(
631+
builder: (BuildContext context) {
632+
return const CupertinoPageScaffold(
633+
child: Center(child: Text('Page 3')),
634+
);
635+
},
636+
));
637+
await tester.pumpAndSettle();
638+
expect(find.text('Page 1'), findsNothing);
639+
expect(find.text('Page 2'), findsNothing);
640+
expect(
641+
tester.getTopLeft(find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold))),
642+
Offset.zero,
643+
);
644+
});
645+
380646
testWidgets('Fullscreen route animates correct transform values over time', (WidgetTester tester) async {
381647
await tester.pumpWidget(
382648
CupertinoApp(

0 commit comments

Comments
 (0)