Skip to content

Commit

Permalink
Add ability for ModalRoutes to ignore pointers during transitions a…
Browse files Browse the repository at this point in the history
…nd do so on `Cupertino` routes (#95757)
  • Loading branch information
willlockwood authored May 19, 2022
1 parent 0052566 commit 4c0b0be
Show file tree
Hide file tree
Showing 8 changed files with 508 additions and 93 deletions.
9 changes: 9 additions & 0 deletions packages/flutter/lib/src/cupertino/route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ mixin CupertinoRouteTransitionMixin<T> on PageRoute<T> {
return result;
}

@override
bool get ignorePointerDuringTransitions => true;

// Called by _CupertinoBackGestureDetector when a pop ("back") drag start
// gesture is detected. The returned controller handles all of the subsequent
// drag events.
Expand Down Expand Up @@ -1049,6 +1052,9 @@ class CupertinoModalPopupRoute<T> extends PopupRoute<T> {
@override
Duration get transitionDuration => _kModalPopupTransitionDuration;

@override
bool get ignorePointerDuringTransitions => true;

Animation<double>? _animation;

late Tween<Offset> _offsetTween;
Expand Down Expand Up @@ -1349,4 +1355,7 @@ class CupertinoDialogRoute<T> extends RawDialogRoute<T> {
barrierLabel: barrierLabel ?? CupertinoLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: barrierColor ?? CupertinoDynamicColor.resolve(kCupertinoModalBarrierColor, context),
);

@override
bool get ignorePointerDuringTransitions => true;
}
207 changes: 132 additions & 75 deletions packages/flutter/lib/src/widgets/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -293,73 +293,85 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
final VoidCallback? previousTrainHoppingListenerRemover = _trainHoppingListenerRemover;
_trainHoppingListenerRemover = null;

if (nextRoute is TransitionRoute<dynamic> && canTransitionTo(nextRoute) && nextRoute.canTransitionFrom(this)) {
final Animation<double>? current = _secondaryAnimation.parent;
if (current != null) {
final Animation<double> currentTrain = (current is TrainHoppingAnimation ? current.currentTrain : current)!;
final Animation<double> nextTrain = nextRoute._animation!;
if (
currentTrain.value == nextTrain.value ||
nextTrain.status == AnimationStatus.completed ||
nextTrain.status == AnimationStatus.dismissed
) {
_setSecondaryAnimation(nextTrain, nextRoute.completed);
} else {
// Two trains animate at different values. We have to do train hopping.
// There are three possibilities of train hopping:
// 1. We hop on the nextTrain when two trains meet in the middle using
// TrainHoppingAnimation.
// 2. There is no chance to hop on nextTrain because two trains never
// cross each other. We have to directly set the animation to
// nextTrain once the nextTrain stops animating.
// 3. A new _updateSecondaryAnimation is called before train hopping
// finishes. We leave a listener remover for the next call to
// properly clean up the existing train hopping.
TrainHoppingAnimation? newAnimation;
void jumpOnAnimationEnd(AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
case AnimationStatus.dismissed:
// The nextTrain has stopped animating without train hopping.
// Directly sets the secondary animation and disposes the
// TrainHoppingAnimation.
_setSecondaryAnimation(nextTrain, nextRoute.completed);
if (nextRoute is TransitionRoute<dynamic>) {
if (canTransitionTo(nextRoute) && nextRoute.canTransitionFrom(this)) {
final Animation<double>? current = _secondaryAnimation.parent;
if (current != null) {
final Animation<double> currentTrain = (current is TrainHoppingAnimation ? current.currentTrain : current)!;
final Animation<double> nextTrain = nextRoute._animation!;
if (
currentTrain.value == nextTrain.value ||
nextTrain.status == AnimationStatus.completed ||
nextTrain.status == AnimationStatus.dismissed
) {
_setSecondaryAnimation(nextTrain, nextRoute.completed);
} else {
// Two trains animate at different values. We have to do train hopping.
// There are three possibilities of train hopping:
// 1. We hop on the nextTrain when two trains meet in the middle using
// TrainHoppingAnimation.
// 2. There is no chance to hop on nextTrain because two trains never
// cross each other. We have to directly set the animation to
// nextTrain once the nextTrain stops animating.
// 3. A new _updateSecondaryAnimation is called before train hopping
// finishes. We leave a listener remover for the next call to
// properly clean up the existing train hopping.
TrainHoppingAnimation? newAnimation;
void jumpOnAnimationEnd(AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
case AnimationStatus.dismissed:
// The nextTrain has stopped animating without train hopping.
// Directly sets the secondary animation and disposes the
// TrainHoppingAnimation.
_setSecondaryAnimation(nextTrain, nextRoute.completed);
if (_trainHoppingListenerRemover != null) {
_trainHoppingListenerRemover!();
_trainHoppingListenerRemover = null;
}
break;
case AnimationStatus.forward:
case AnimationStatus.reverse:
break;
}
}
_trainHoppingListenerRemover = () {
nextTrain.removeStatusListener(jumpOnAnimationEnd);
newAnimation?.dispose();
};
nextTrain.addStatusListener(jumpOnAnimationEnd);
newAnimation = TrainHoppingAnimation(
currentTrain,
nextTrain,
onSwitchedTrain: () {
assert(_secondaryAnimation.parent == newAnimation);
assert(newAnimation!.currentTrain == nextRoute._animation);
// We can hop on the nextTrain, so we don't need to listen to
// whether the nextTrain has stopped.
_setSecondaryAnimation(newAnimation!.currentTrain, nextRoute.completed);
if (_trainHoppingListenerRemover != null) {
_trainHoppingListenerRemover!();
_trainHoppingListenerRemover = null;
}
break;
case AnimationStatus.forward:
case AnimationStatus.reverse:
break;
}
},
);
_setSecondaryAnimation(newAnimation, nextRoute.completed);
}
_trainHoppingListenerRemover = () {
nextTrain.removeStatusListener(jumpOnAnimationEnd);
newAnimation?.dispose();
};
nextTrain.addStatusListener(jumpOnAnimationEnd);
newAnimation = TrainHoppingAnimation(
currentTrain,
nextTrain,
onSwitchedTrain: () {
assert(_secondaryAnimation.parent == newAnimation);
assert(newAnimation!.currentTrain == nextRoute._animation);
// We can hop on the nextTrain, so we don't need to listen to
// whether the nextTrain has stopped.
_setSecondaryAnimation(newAnimation!.currentTrain, nextRoute.completed);
if (_trainHoppingListenerRemover != null) {
_trainHoppingListenerRemover!();
_trainHoppingListenerRemover = null;
}
},
);
_setSecondaryAnimation(newAnimation, nextRoute.completed);
} else { // This route has no secondary animation.
_setSecondaryAnimation(nextRoute._animation, nextRoute.completed);
}
} else {
_setSecondaryAnimation(nextRoute._animation, nextRoute.completed);
// This route cannot coordinate transitions with nextRoute, so it should
// have no visible secondary animation. By using an AnimationMin, the
// animation's value will always be zero, but it will have nextRoute.animation's
// status until it finishes, allowing this route to wait until all visible
// transitions are complete to stop ignoring pointers.
_setSecondaryAnimation(
AnimationMin<double>(kAlwaysDismissedAnimation, nextRoute._animation!),
nextRoute.completed,
);
}
} else {
} else { // The next route is not a TransitionRoute.
_setSecondaryAnimation(kAlwaysDismissedAnimation);
}
// Finally, we dispose any previous train hopping animation because it
Expand Down Expand Up @@ -396,9 +408,9 @@ abstract class TransitionRoute<T> extends OverlayRoute<T> {
/// the [nextRoute] is popped off of this route, the
/// `secondaryAnimation` will run from 1.0 - 0.0.
///
/// If false, this route's [ModalRoute.buildTransitions] `secondaryAnimation` parameter
/// value will be [kAlwaysDismissedAnimation]. In other words, this route
/// will not animate when [nextRoute] is pushed on top of it or when
/// If false, this route's [ModalRoute.buildTransitions] `secondaryAnimation`
/// will proxy an animation with a constant value of 0. In other words, this
/// route will not animate when [nextRoute] is pushed on top of it or when
/// [nextRoute] is popped off of it.
///
/// Returns true by default.
Expand Down Expand Up @@ -846,17 +858,19 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
context,
widget.route.animation!,
widget.route.secondaryAnimation!,
// This additional AnimatedBuilder is include because if the
// value of the userGestureInProgressNotifier changes, it's
// only necessary to rebuild the IgnorePointer widget and set
// the focus node's ability to focus.
// _listenable updates when this route's animations change
// values, but the _ignorePointerNotifier can also update
// when the status of animations on popping routes change,
// even when this route's animations' values don't. Also,
// when the value of the _ignorePointerNotifier changes,
// it's only necessary to rebuild the IgnorePointer
// widget and set the focus node's ability to focus.
AnimatedBuilder(
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
animation: widget.route._ignorePointerNotifier,
builder: (BuildContext context, Widget? child) {
final bool ignoreEvents = _shouldIgnoreFocusRequest;
focusScopeNode.canRequestFocus = !ignoreEvents;
focusScopeNode.canRequestFocus = !_shouldIgnoreFocusRequest;
return IgnorePointer(
ignoring: ignoreEvents,
ignoring: widget.route._ignorePointer,
child: child,
);
},
Expand Down Expand Up @@ -1140,11 +1154,36 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
return child;
}

/// Whether this route should ignore pointers when transitions are in progress.
///
/// Pointers always are ignored when [isCurrent] is false (e.g., when a route
/// has a new route pushed on top of it, or during a route's exit transition
/// after popping). Override this value to also ignore pointers on pages during
/// transitions where this route is the current route (e.g., after the route
/// above this route pops, or during this route's entrance transition).
///
/// Returns false by default.
///
/// See also:
///
/// * [CupertinoRouteTransitionMixin], [CupertinoModalPopupRoute], and
/// [CupertinoDialogRoute], which use this property to specify that
/// Cupertino routes ignore pointers during transitions.
@protected
bool get ignorePointerDuringTransitions => false;

@override
void install() {
super.install();
_animationProxy = ProxyAnimation(super.animation);
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation);
_animationProxy = ProxyAnimation(super.animation)
..addStatusListener(_handleAnimationStatusChanged);
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation)
..addStatusListener(_handleAnimationStatusChanged);
navigator!.userGestureInProgressNotifier.addListener(_maybeUpdateIgnorePointer);
}

void _handleAnimationStatusChanged(AnimationStatus status) {
_maybeUpdateIgnorePointer();
}

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

bool get _ignorePointer => _ignorePointerNotifier.value;
final ValueNotifier<bool> _ignorePointerNotifier = ValueNotifier<bool>(false);

void _maybeUpdateIgnorePointer() {
bool isTransitioning(Animation<double>? animation) {
return animation?.status == AnimationStatus.forward || animation?.status == AnimationStatus.reverse;
}
_ignorePointerNotifier.value = !isCurrent ||
(navigator?.userGestureInProgress ?? false) ||
(ignorePointerDuringTransitions &&
(isTransitioning(animation) || isTransitioning(secondaryAnimation)));
}

final List<WillPopCallback> _willPopCallbacks = <WillPopCallback>[];

/// Returns [RoutePopDisposition.doNotPop] if any of callbacks added with
Expand Down Expand Up @@ -1598,9 +1650,14 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
child: barrier,
);
}
barrier = IgnorePointer(
ignoring: animation!.status == AnimationStatus.reverse || // changedInternalState is called when animation.status updates
animation!.status == AnimationStatus.dismissed, // dismissed is possible when doing a manual pop gesture
barrier = AnimatedBuilder(
animation: _ignorePointerNotifier,
builder: (BuildContext context, Widget? child) {
return IgnorePointer(
ignoring: _ignorePointer,
child: child,
);
},
child: barrier,
);
if (semanticsDismissible && barrierDismissible) {
Expand Down
29 changes: 14 additions & 15 deletions packages/flutter/test/cupertino/action_sheet_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ void main() {
);

await tester.tap(find.text('Go'));
await tester.pump();
await tester.pumpAndSettle();

expect(find.text('Action Sheet'), findsOneWidget);
expect(find.byType(CupertinoActionSheet), findsOneWidget);

await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump();
expect(find.text('Action Sheet'), findsNothing);
await tester.tap(find.byType(ModalBarrier).last);
await tester.pumpAndSettle();
expect(find.byType(CupertinoActionSheet), findsNothing);
});

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

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

// Exit animation
// Attempt to dismiss
await tester.tapAt(const Offset(20.0, 20.0));
await tester.pump(const Duration(milliseconds: 60));

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

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

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

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

Expand Down Expand Up @@ -952,7 +951,7 @@ void main() {
);

await tester.tap(find.text('Go'));
await tester.pump();
await tester.pumpAndSettle();

expect(
semantics,
Expand Down
2 changes: 2 additions & 0 deletions packages/flutter/test/cupertino/dialog_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,8 @@ void main() {
transition = tester.firstWidget(fadeTransitionFinder);
expect(transition.opacity.value, moreOrLessEquals(1.0, epsilon: 0.001));

await tester.pumpAndSettle();

await tester.tap(find.text('Delete'));

// Exit animation, look at reverse FadeTransition.
Expand Down
Loading

0 comments on commit 4c0b0be

Please sign in to comment.