Skip to content

Commit 4c0b0be

Browse files
authored
Add ability for ModalRoutes to ignore pointers during transitions and do so on Cupertino routes (#95757)
1 parent 0052566 commit 4c0b0be

File tree

8 files changed

+508
-93
lines changed

8 files changed

+508
-93
lines changed

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

+9
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
235235
return result;
236236
}
237237

238+
@override
239+
bool get ignorePointerDuringTransitions => true;
240+
238241
// Called by _CupertinoBackGestureDetector when a pop ("back") drag start
239242
// gesture is detected. The returned controller handles all of the subsequent
240243
// drag events.
@@ -1049,6 +1052,9 @@ class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
10491052
@override
10501053
Duration get transitionDuration => _kModalPopupTransitionDuration;
10511054

1055+
@override
1056+
bool get ignorePointerDuringTransitions => true;
1057+
10521058
Animation<double>? _animation;
10531059

10541060
late Tween<Offset> _offsetTween;
@@ -1349,4 +1355,7 @@ class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
13491355
barrierLabel: barrierLabel ?? CupertinoLocalizations.of(context).modalBarrierDismissLabel,
13501356
barrierColor: barrierColor ?? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context),
13511357
);
1358+
1359+
@override
1360+
bool get ignorePointerDuringTransitions => true;
13521361
}

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

+132-75
Original file line numberDiff line numberDiff line change
@@ -293,73 +293,85 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
293293
final VoidCallback? previousTrainHoppingListenerRemover = _trainHoppingListenerRemover;
294294
_trainHoppingListenerRemover = null;
295295

296-
if (nextRoute is TransitionRoute<dynamic> && canTransitionTo(nextRoute) && nextRoute.canTransitionFrom(this)) {
297-
final Animation<double>? current = _secondaryAnimation.parent;
298-
if (current != null) {
299-
final Animation<double> currentTrain = (current is TrainHoppingAnimation ? current.currentTrain : current)!;
300-
final Animation<double> nextTrain = nextRoute._animation!;
301-
if (
302-
currentTrain.value == nextTrain.value ||
303-
nextTrain.status == AnimationStatus.completed ||
304-
nextTrain.status == AnimationStatus.dismissed
305-
) {
306-
_setSecondaryAnimation(nextTrain, nextRoute.completed);
307-
} else {
308-
// Two trains animate at different values. We have to do train hopping.
309-
// There are three possibilities of train hopping:
310-
// 1. We hop on the nextTrain when two trains meet in the middle using
311-
// TrainHoppingAnimation.
312-
// 2. There is no chance to hop on nextTrain because two trains never
313-
// cross each other. We have to directly set the animation to
314-
// nextTrain once the nextTrain stops animating.
315-
// 3. A new _updateSecondaryAnimation is called before train hopping
316-
// finishes. We leave a listener remover for the next call to
317-
// properly clean up the existing train hopping.
318-
TrainHoppingAnimation? newAnimation;
319-
void jumpOnAnimationEnd(AnimationStatus status) {
320-
switch (status) {
321-
case AnimationStatus.completed:
322-
case AnimationStatus.dismissed:
323-
// The nextTrain has stopped animating without train hopping.
324-
// Directly sets the secondary animation and disposes the
325-
// TrainHoppingAnimation.
326-
_setSecondaryAnimation(nextTrain, nextRoute.completed);
296+
if (nextRoute is TransitionRoute<dynamic>) {
297+
if (canTransitionTo(nextRoute) && nextRoute.canTransitionFrom(this)) {
298+
final Animation<double>? current = _secondaryAnimation.parent;
299+
if (current != null) {
300+
final Animation<double> currentTrain = (current is TrainHoppingAnimation ? current.currentTrain : current)!;
301+
final Animation<double> nextTrain = nextRoute._animation!;
302+
if (
303+
currentTrain.value == nextTrain.value ||
304+
nextTrain.status == AnimationStatus.completed ||
305+
nextTrain.status == AnimationStatus.dismissed
306+
) {
307+
_setSecondaryAnimation(nextTrain, nextRoute.completed);
308+
} else {
309+
// Two trains animate at different values. We have to do train hopping.
310+
// There are three possibilities of train hopping:
311+
// 1. We hop on the nextTrain when two trains meet in the middle using
312+
// TrainHoppingAnimation.
313+
// 2. There is no chance to hop on nextTrain because two trains never
314+
// cross each other. We have to directly set the animation to
315+
// nextTrain once the nextTrain stops animating.
316+
// 3. A new _updateSecondaryAnimation is called before train hopping
317+
// finishes. We leave a listener remover for the next call to
318+
// properly clean up the existing train hopping.
319+
TrainHoppingAnimation? newAnimation;
320+
void jumpOnAnimationEnd(AnimationStatus status) {
321+
switch (status) {
322+
case AnimationStatus.completed:
323+
case AnimationStatus.dismissed:
324+
// The nextTrain has stopped animating without train hopping.
325+
// Directly sets the secondary animation and disposes the
326+
// TrainHoppingAnimation.
327+
_setSecondaryAnimation(nextTrain, nextRoute.completed);
328+
if (_trainHoppingListenerRemover != null) {
329+
_trainHoppingListenerRemover!();
330+
_trainHoppingListenerRemover = null;
331+
}
332+
break;
333+
case AnimationStatus.forward:
334+
case AnimationStatus.reverse:
335+
break;
336+
}
337+
}
338+
_trainHoppingListenerRemover = () {
339+
nextTrain.removeStatusListener(jumpOnAnimationEnd);
340+
newAnimation?.dispose();
341+
};
342+
nextTrain.addStatusListener(jumpOnAnimationEnd);
343+
newAnimation = TrainHoppingAnimation(
344+
currentTrain,
345+
nextTrain,
346+
onSwitchedTrain: () {
347+
assert(_secondaryAnimation.parent == newAnimation);
348+
assert(newAnimation!.currentTrain == nextRoute._animation);
349+
// We can hop on the nextTrain, so we don't need to listen to
350+
// whether the nextTrain has stopped.
351+
_setSecondaryAnimation(newAnimation!.currentTrain, nextRoute.completed);
327352
if (_trainHoppingListenerRemover != null) {
328353
_trainHoppingListenerRemover!();
329354
_trainHoppingListenerRemover = null;
330355
}
331-
break;
332-
case AnimationStatus.forward:
333-
case AnimationStatus.reverse:
334-
break;
335-
}
356+
},
357+
);
358+
_setSecondaryAnimation(newAnimation, nextRoute.completed);
336359
}
337-
_trainHoppingListenerRemover = () {
338-
nextTrain.removeStatusListener(jumpOnAnimationEnd);
339-
newAnimation?.dispose();
340-
};
341-
nextTrain.addStatusListener(jumpOnAnimationEnd);
342-
newAnimation = TrainHoppingAnimation(
343-
currentTrain,
344-
nextTrain,
345-
onSwitchedTrain: () {
346-
assert(_secondaryAnimation.parent == newAnimation);
347-
assert(newAnimation!.currentTrain == nextRoute._animation);
348-
// We can hop on the nextTrain, so we don't need to listen to
349-
// whether the nextTrain has stopped.
350-
_setSecondaryAnimation(newAnimation!.currentTrain, nextRoute.completed);
351-
if (_trainHoppingListenerRemover != null) {
352-
_trainHoppingListenerRemover!();
353-
_trainHoppingListenerRemover = null;
354-
}
355-
},
356-
);
357-
_setSecondaryAnimation(newAnimation, nextRoute.completed);
360+
} else { // This route has no secondary animation.
361+
_setSecondaryAnimation(nextRoute._animation, nextRoute.completed);
358362
}
359363
} else {
360-
_setSecondaryAnimation(nextRoute._animation, nextRoute.completed);
364+
// This route cannot coordinate transitions with nextRoute, so it should
365+
// have no visible secondary animation. By using an AnimationMin, the
366+
// animation's value will always be zero, but it will have nextRoute.animation's
367+
// status until it finishes, allowing this route to wait until all visible
368+
// transitions are complete to stop ignoring pointers.
369+
_setSecondaryAnimation(
370+
AnimationMin<double>(kAlwaysDismissedAnimation, nextRoute._animation!),
371+
nextRoute.completed,
372+
);
361373
}
362-
} else {
374+
} else { // The next route is not a TransitionRoute.
363375
_setSecondaryAnimation(kAlwaysDismissedAnimation);
364376
}
365377
// Finally, we dispose any previous train hopping animation because it
@@ -396,9 +408,9 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
396408
/// the [nextRoute] is popped off of this route, the
397409
/// `secondaryAnimation` will run from 1.0 - 0.0.
398410
///
399-
/// If false, this route's [ModalRoute.buildTransitions] `secondaryAnimation` parameter
400-
/// value will be [kAlwaysDismissedAnimation]. In other words, this route
401-
/// will not animate when [nextRoute] is pushed on top of it or when
411+
/// If false, this route's [ModalRoute.buildTransitions] `secondaryAnimation`
412+
/// will proxy an animation with a constant value of 0. In other words, this
413+
/// route will not animate when [nextRoute] is pushed on top of it or when
402414
/// [nextRoute] is popped off of it.
403415
///
404416
/// Returns true by default.
@@ -846,17 +858,19 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
846858
context,
847859
widget.route.animation!,
848860
widget.route.secondaryAnimation!,
849-
// This additional AnimatedBuilder is include because if the
850-
// value of the userGestureInProgressNotifier changes, it's
851-
// only necessary to rebuild the IgnorePointer widget and set
852-
// the focus node's ability to focus.
861+
// _listenable updates when this route's animations change
862+
// values, but the _ignorePointerNotifier can also update
863+
// when the status of animations on popping routes change,
864+
// even when this route's animations' values don't. Also,
865+
// when the value of the _ignorePointerNotifier changes,
866+
// it's only necessary to rebuild the IgnorePointer
867+
// widget and set the focus node's ability to focus.
853868
AnimatedBuilder(
854-
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
869+
animation: widget.route._ignorePointerNotifier,
855870
builder: (BuildContext context, Widget? child) {
856-
final bool ignoreEvents = _shouldIgnoreFocusRequest;
857-
focusScopeNode.canRequestFocus = !ignoreEvents;
871+
focusScopeNode.canRequestFocus = !_shouldIgnoreFocusRequest;
858872
return IgnorePointer(
859-
ignoring: ignoreEvents,
873+
ignoring: widget.route._ignorePointer,
860874
child: child,
861875
);
862876
},
@@ -1140,11 +1154,36 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
11401154
return child;
11411155
}
11421156

1157+
/// Whether this route should ignore pointers when transitions are in progress.
1158+
///
1159+
/// Pointers always are ignored when [isCurrent] is false (e.g., when a route
1160+
/// has a new route pushed on top of it, or during a route's exit transition
1161+
/// after popping). Override this value to also ignore pointers on pages during
1162+
/// transitions where this route is the current route (e.g., after the route
1163+
/// above this route pops, or during this route's entrance transition).
1164+
///
1165+
/// Returns false by default.
1166+
///
1167+
/// See also:
1168+
///
1169+
/// * [CupertinoRouteTransitionMixin], [CupertinoModalPopupRoute], and
1170+
/// [CupertinoDialogRoute], which use this property to specify that
1171+
/// Cupertino routes ignore pointers during transitions.
1172+
@protected
1173+
bool get ignorePointerDuringTransitions => false;
1174+
11431175
@override
11441176
void install() {
11451177
super.install();
1146-
_animationProxy = ProxyAnimation(super.animation);
1147-
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation);
1178+
_animationProxy = ProxyAnimation(super.animation)
1179+
..addStatusListener(_handleAnimationStatusChanged);
1180+
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation)
1181+
..addStatusListener(_handleAnimationStatusChanged);
1182+
navigator!.userGestureInProgressNotifier.addListener(_maybeUpdateIgnorePointer);
1183+
}
1184+
1185+
void _handleAnimationStatusChanged(AnimationStatus status) {
1186+
_maybeUpdateIgnorePointer();
11481187
}
11491188

11501189
@override
@@ -1380,6 +1419,19 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
13801419
Animation<double>? get secondaryAnimation => _secondaryAnimationProxy;
13811420
ProxyAnimation? _secondaryAnimationProxy;
13821421

1422+
bool get _ignorePointer => _ignorePointerNotifier.value;
1423+
final ValueNotifier<bool> _ignorePointerNotifier = ValueNotifier<bool>(false);
1424+
1425+
void _maybeUpdateIgnorePointer() {
1426+
bool isTransitioning(Animation<double>? animation) {
1427+
return animation?.status == AnimationStatus.forward || animation?.status == AnimationStatus.reverse;
1428+
}
1429+
_ignorePointerNotifier.value = !isCurrent ||
1430+
(navigator?.userGestureInProgress ?? false) ||
1431+
(ignorePointerDuringTransitions &&
1432+
(isTransitioning(animation) || isTransitioning(secondaryAnimation)));
1433+
}
1434+
13831435
final List<WillPopCallback> _willPopCallbacks = <WillPopCallback>[];
13841436

13851437
/// Returns [RoutePopDisposition.doNotPop] if any of callbacks added with
@@ -1598,9 +1650,14 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
15981650
child: barrier,
15991651
);
16001652
}
1601-
barrier = IgnorePointer(
1602-
ignoring: animation!.status == AnimationStatus.reverse || // changedInternalState is called when animation.status updates
1603-
animation!.status == AnimationStatus.dismissed, // dismissed is possible when doing a manual pop gesture
1653+
barrier = AnimatedBuilder(
1654+
animation: _ignorePointerNotifier,
1655+
builder: (BuildContext context, Widget? child) {
1656+
return IgnorePointer(
1657+
ignoring: _ignorePointer,
1658+
child: child,
1659+
);
1660+
},
16041661
child: barrier,
16051662
);
16061663
if (semanticsDismissible && barrierDismissible) {

packages/flutter/test/cupertino/action_sheet_test.dart

+14-15
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@ void main() {
2929
);
3030

3131
await tester.tap(find.text('Go'));
32-
await tester.pump();
32+
await tester.pumpAndSettle();
3333

34-
expect(find.text('Action Sheet'), findsOneWidget);
34+
expect(find.byType(CupertinoActionSheet), findsOneWidget);
3535

36-
await tester.tapAt(const Offset(20.0, 20.0));
37-
await tester.pump();
38-
expect(find.text('Action Sheet'), findsNothing);
36+
await tester.tap(find.byType(ModalBarrier).last);
37+
await tester.pumpAndSettle();
38+
expect(find.byType(CupertinoActionSheet), findsNothing);
3939
});
4040

4141
testWidgets('Verify that a tap on title section (not buttons) does not dismiss an action sheet', (WidgetTester tester) async {
@@ -867,7 +867,7 @@ void main() {
867867
expect(find.byType(CupertinoActionSheet), findsNothing);
868868
});
869869

870-
testWidgets('Modal barrier is pressed during transition', (WidgetTester tester) async {
870+
testWidgets('Modal barrier cannot be dismissed during transition', (WidgetTester tester) async {
871871
await tester.pumpWidget(
872872
createAppWithButtonThatLaunchesActionSheet(
873873
CupertinoActionSheet(
@@ -906,21 +906,20 @@ void main() {
906906
await tester.pump(const Duration(milliseconds: 60));
907907
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, moreOrLessEquals(337.1, epsilon: 0.1));
908908

909-
// Exit animation
909+
// Attempt to dismiss
910910
await tester.tapAt(const Offset(20.0, 20.0));
911911
await tester.pump(const Duration(milliseconds: 60));
912912

913-
await tester.pump(const Duration(milliseconds: 60));
914-
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, moreOrLessEquals(374.3, epsilon: 0.1));
913+
// Enter animation is continuing
914+
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, moreOrLessEquals(325.4, epsilon: 0.1));
915915

916-
await tester.pump(const Duration(milliseconds: 60));
917-
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, moreOrLessEquals(470.0, epsilon: 0.1));
916+
await tester.pumpAndSettle();
918917

919-
await tester.pump(const Duration(milliseconds: 60));
920-
expect(tester.getTopLeft(find.byType(CupertinoActionSheet)).dy, 600.0);
918+
// Attempt to dismiss again
919+
await tester.tapAt(const Offset(20.0, 20.0));
920+
await tester.pumpAndSettle();
921921

922922
// Action sheet has disappeared
923-
await tester.pump(const Duration(milliseconds: 60));
924923
expect(find.byType(CupertinoActionSheet), findsNothing);
925924
});
926925

@@ -952,7 +951,7 @@ void main() {
952951
);
953952

954953
await tester.tap(find.text('Go'));
955-
await tester.pump();
954+
await tester.pumpAndSettle();
956955

957956
expect(
958957
semantics,

packages/flutter/test/cupertino/dialog_test.dart

+2
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,8 @@ void main() {
10741074
transition = tester.firstWidget(fadeTransitionFinder);
10751075
expect(transition.opacity.value, moreOrLessEquals(1.0, epsilon: 0.001));
10761076

1077+
await tester.pumpAndSettle();
1078+
10771079
await tester.tap(find.text('Delete'));
10781080

10791081
// Exit animation, look at reverse FadeTransition.

0 commit comments

Comments
 (0)