From db51873250fc51d2023c396f04d705a29eabb693 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 6 Apr 2022 18:39:14 -0700 Subject: [PATCH] Add onDismiss to AnimatedModalBarrier, update tests (#100162) I noticed that AnimatedModalBarrier didn't have the onDismiss callback that ModalBarrier does, and so I added that, and while I was at it, I updated the unit tests to perform all of the tests that are done on ModalBarrier also on AnimatedModalBarrier. The tests are unchanged, other than using AnimatedModalBarrier instead. --- .../lib/src/widgets/modal_barrier.dart | 7 + .../test/widgets/modal_barrier_test.dart | 1261 ++++++++++++----- 2 files changed, 874 insertions(+), 394 deletions(-) diff --git a/packages/flutter/lib/src/widgets/modal_barrier.dart b/packages/flutter/lib/src/widgets/modal_barrier.dart index c70df59de1..04cc5e4e80 100644 --- a/packages/flutter/lib/src/widgets/modal_barrier.dart +++ b/packages/flutter/lib/src/widgets/modal_barrier.dart @@ -60,6 +60,7 @@ class ModalBarrier extends StatelessWidget { /// [ModalBarrier] built by [ModalRoute] pages. final bool dismissible; + /// {@template flutter.widgets.ModalBarrier.onDismiss} /// Called when the barrier is being dismissed. /// /// If non-null [onDismiss] will be called in place of popping the current @@ -68,6 +69,7 @@ class ModalBarrier extends StatelessWidget { /// If null, the ambient [Navigator]'s current route will be popped. /// /// This field is ignored if [dismissible] is false. + /// {@endtemplate} final VoidCallback? onDismiss; /// Whether the modal barrier semantics are included in the semantics tree. @@ -172,6 +174,7 @@ class AnimatedModalBarrier extends AnimatedWidget { this.dismissible = true, this.semanticsLabel, this.barrierSemanticsDismissible, + this.onDismiss, }) : super(key: key, listenable: color); /// If non-null, fill the barrier with this color. @@ -208,6 +211,9 @@ class AnimatedModalBarrier extends AnimatedWidget { /// the [ModalBarrier] built by [ModalRoute] pages. final bool? barrierSemanticsDismissible; + /// {@macro flutter.widgets.ModalBarrier.onDismiss} + final VoidCallback? onDismiss; + @override Widget build(BuildContext context) { return ModalBarrier( @@ -215,6 +221,7 @@ class AnimatedModalBarrier extends AnimatedWidget { dismissible: dismissible, semanticsLabel: semanticsLabel, barrierSemanticsDismissible: barrierSemanticsDismissible, + onDismiss: onDismiss, ); } } diff --git a/packages/flutter/test/widgets/modal_barrier_test.dart b/packages/flutter/test/widgets/modal_barrier_test.dart index 72d0664400..f2de85788d 100644 --- a/packages/flutter/test/widgets/modal_barrier_test.dart +++ b/packages/flutter/test/widgets/modal_barrier_test.dart @@ -15,9 +15,11 @@ void main() { late bool hovered; late Widget tapTarget; late Widget hoverTarget; + late Animation colorAnimation; setUp(() { tapped = false; + colorAnimation = const AlwaysStoppedAnimation(Colors.red); tapTarget = GestureDetector( onTap: () { tapped = true; @@ -42,130 +44,8 @@ void main() { ); }); - testWidgets('ModalBarrier prevents interactions with widgets behind it', (WidgetTester tester) async { - final Widget subject = Stack( - textDirection: TextDirection.ltr, - children: [ - tapTarget, - const ModalBarrier(dismissible: false), - ], - ); - - await tester.pumpWidget(subject); - await tester.tap(find.text('target'), warnIfMissed: false); - await tester.pumpWidget(subject); - expect(tapped, isFalse, reason: 'because the tap is not prevented by ModalBarrier'); - }); - - testWidgets('ModalBarrier prevents hover interactions with widgets behind it', (WidgetTester tester) async { - final Widget subject = Stack( - textDirection: TextDirection.ltr, - children: [ - hoverTarget, - const ModalBarrier(dismissible: false), - ], - ); - - final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(gesture.removePointer); - // Start out of hoverTarget - await gesture.moveTo(const Offset(100, 100)); - - await tester.pumpWidget(subject); - // Move into hoverTarget and tap - await gesture.down(const Offset(5, 5)); - await tester.pumpWidget(subject); - await gesture.up(); - await tester.pumpWidget(subject); - - // Move out - await gesture.moveTo(const Offset(100, 100)); - await tester.pumpWidget(subject); - - expect(hovered, isFalse, reason: 'because the hover is not prevented by ModalBarrier'); - }); - - testWidgets('ModalBarrier does not prevent interactions with widgets in front of it', (WidgetTester tester) async { - final Widget subject = Stack( - textDirection: TextDirection.ltr, - children: [ - const ModalBarrier(dismissible: false), - tapTarget, - ], - ); - - await tester.pumpWidget(subject); - await tester.tap(find.text('target')); - await tester.pumpWidget(subject); - expect(tapped, isTrue, reason: 'because the tap is prevented by ModalBarrier'); - }); - - testWidgets('ModalBarrier does not prevent interactions with translucent widgets in front of it', (WidgetTester tester) async { - bool dragged = false; - final Widget subject = Stack( - textDirection: TextDirection.ltr, - children: [ - const ModalBarrier(dismissible: false), - GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (_) { - dragged = true; - }, - child: const Center( - child: Text('target', textDirection: TextDirection.ltr), - ), - ), - ], - ); - - await tester.pumpWidget(subject); - await tester.dragFrom( - tester.getBottomRight(find.byType(GestureDetector)) - const Offset(10, 10), - const Offset(-20, 0), - ); - await tester.pumpWidget(subject); - expect(dragged, isTrue, reason: 'because the drag is prevented by ModalBarrier'); - }); - - testWidgets('ModalBarrier does not prevent hover interactions with widgets in front of it', (WidgetTester tester) async { - final Widget subject = Stack( - textDirection: TextDirection.ltr, - children: [ - const ModalBarrier(dismissible: false), - hoverTarget, - ], - ); - - final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); - addTearDown(gesture.removePointer); - // Start out of hoverTarget - await gesture.moveTo(const Offset(100, 100)); - await tester.pumpWidget(subject); - expect(hovered, isFalse); - - // Move into hoverTarget - await gesture.moveTo(const Offset(5, 5)); - await tester.pumpWidget(subject); - expect(hovered, isTrue, reason: 'because the hover is prevented by ModalBarrier'); - hovered = false; - - // Move out - await gesture.moveTo(const Offset(100, 100)); - await tester.pumpWidget(subject); - expect(hovered, isTrue, reason: 'because the hover is prevented by ModalBarrier'); - hovered = false; - }); - - testWidgets('ModalBarrier plays system alert sound when user tries to dismiss it', (WidgetTester tester) async { - final List playedSystemSounds = []; - try { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { - if (methodCall.method == 'SystemSound.play') { - playedSystemSounds.add(methodCall.arguments as String); - } - return null; - }); - + group('ModalBarrier', () { + testWidgets('prevents interactions with widgets behind it', (WidgetTester tester) async { final Widget subject = Stack( textDirection: TextDirection.ltr, children: [ @@ -177,295 +57,854 @@ void main() { await tester.pumpWidget(subject); await tester.tap(find.text('target'), warnIfMissed: false); await tester.pumpWidget(subject); - } finally { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); - } - expect(playedSystemSounds, hasLength(1)); - expect(playedSystemSounds[0], SystemSoundType.alert.toString()); - }); + expect(tapped, isFalse, reason: 'because the tap is not prevented by ModalBarrier'); + }); - testWidgets('ModalBarrier pops the Navigator when dismissed by primary tap', (WidgetTester tester) async { - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => const SecondWidget(), - }; - - await tester.pumpWidget(MaterialApp(routes: routes)); - - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - // Press the barrier; it shouldn't dismiss yet - final TestGesture gesture = await tester.press( - find.byKey(const ValueKey('barrier')), - ); - await tester.pumpAndSettle(); // begin transition - expect(find.byKey(const ValueKey('barrier')), findsOneWidget); - - // Release the pointer; the barrier should be dismissed - await gesture.up(); - await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition - expect( - find.byKey(const ValueKey('barrier')), - findsNothing, - reason: 'The route should have been dismissed by tapping the barrier.', - ); - }); - - testWidgets('ModalBarrier pops the Navigator when dismissed by non-primary tap', (WidgetTester tester) async { - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => const SecondWidget(), - }; - - await tester.pumpWidget(MaterialApp(routes: routes)); - - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - // Press the barrier; it shouldn't dismiss yet - final TestGesture gesture = await tester.press( - find.byKey(const ValueKey('barrier')), - buttons: kSecondaryButton, - ); - await tester.pumpAndSettle(); // begin transition - expect(find.byKey(const ValueKey('barrier')), findsOneWidget); - - // Release the pointer; the barrier should be dismissed - await gesture.up(); - await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition - expect( - find.byKey(const ValueKey('barrier')), - findsNothing, - reason: 'The route should have been dismissed by tapping the barrier.', - ); - }); - - testWidgets('ModalBarrier may pop the Navigator when competing with other gestures', (WidgetTester tester) async { - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => const SecondWidgetWithCompetence(), - }; - - await tester.pumpWidget(MaterialApp(routes: routes)); - - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - // Tap on the barrier to dismiss it - await tester.tap(find.byKey(const ValueKey('barrier'))); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - expect( - find.byKey(const ValueKey('barrier')), - findsNothing, - reason: 'The route should have been dismissed by tapping the barrier.', - ); - }); - - testWidgets('ModalBarrier does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async { - bool willPopCalled = false; - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => Stack( + testWidgets('prevents hover interactions with widgets behind it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, children: [ - const SecondWidget(), - WillPopScope( - child: const SizedBox(), - onWillPop: () async { - willPopCalled = true; - return false; + hoverTarget, + const ModalBarrier(dismissible: false), + ], + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + // Start out of hoverTarget + await gesture.moveTo(const Offset(100, 100)); + + await tester.pumpWidget(subject); + // Move into hoverTarget and tap + await gesture.down(const Offset(5, 5)); + await tester.pumpWidget(subject); + await gesture.up(); + await tester.pumpWidget(subject); + + // Move out + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + + expect(hovered, isFalse, reason: 'because the hover is not prevented by ModalBarrier'); + }); + + testWidgets('does not prevent interactions with widgets in front of it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + const ModalBarrier(dismissible: false), + tapTarget, + ], + ); + + await tester.pumpWidget(subject); + await tester.tap(find.text('target')); + await tester.pumpWidget(subject); + expect(tapped, isTrue, reason: 'because the tap is prevented by ModalBarrier'); + }); + + testWidgets('does not prevent interactions with translucent widgets in front of it', (WidgetTester tester) async { + bool dragged = false; + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + const ModalBarrier(dismissible: false), + GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) { + dragged = true; }, + child: const Center( + child: Text('target', textDirection: TextDirection.ltr), + ), ), ], - ), - }; + ); - await tester.pumpWidget(MaterialApp(routes: routes)); + await tester.pumpWidget(subject); + await tester.dragFrom( + tester.getBottomRight(find.byType(GestureDetector)) - const Offset(10, 10), + const Offset(-20, 0), + ); + await tester.pumpWidget(subject); + expect(dragged, isTrue, reason: 'because the drag is prevented by ModalBarrier'); + }); - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - expect(willPopCalled, isFalse); - - // Tap on the barrier to attempt to dismiss it - await tester.tap(find.byKey(const ValueKey('barrier'))); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - expect( - find.byKey(const ValueKey('barrier')), - findsOneWidget, - reason: 'The route should still be present if the pop is vetoed.', - ); - - expect(willPopCalled, isTrue); - }); - - testWidgets('ModalBarrier pops the Navigator with a WillPopScope that returns true', (WidgetTester tester) async { - bool willPopCalled = false; - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => Stack( + testWidgets('does not prevent hover interactions with widgets in front of it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, children: [ - const SecondWidget(), - WillPopScope( - child: const SizedBox(), - onWillPop: () async { - willPopCalled = true; - return true; - }, - ), + const ModalBarrier(dismissible: false), + hoverTarget, ], - ), - }; + ); - await tester.pumpWidget(MaterialApp(routes: routes)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + // Start out of hoverTarget + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + expect(hovered, isFalse); - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); + // Move into hoverTarget + await gesture.moveTo(const Offset(5, 5)); + await tester.pumpWidget(subject); + expect(hovered, isTrue, reason: 'because the hover is prevented by ModalBarrier'); + hovered = false; - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition + // Move out + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + expect(hovered, isTrue, reason: 'because the hover is prevented by ModalBarrier'); + hovered = false; + }); - expect(willPopCalled, isFalse); + testWidgets('plays system alert sound when user tries to dismiss it', (WidgetTester tester) async { + final List playedSystemSounds = []; + try { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'SystemSound.play') { + playedSystemSounds.add(methodCall.arguments as String); + } + return null; + }); - // Tap on the barrier to attempt to dismiss it - await tester.tap(find.byKey(const ValueKey('barrier'))); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - - expect( - find.byKey(const ValueKey('barrier')), - findsNothing, - reason: 'The route should not be present if the pop is permitted.', - ); - - expect(willPopCalled, isTrue); - }); - - testWidgets('ModalBarrier will call onDismiss callback', (WidgetTester tester) async { - bool dismissCallbackCalled = false; - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => SecondWidget(onDismiss: () { - dismissCallbackCalled = true; - }), - }; - - await tester.pumpWidget(MaterialApp(routes: routes)); - - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - expect(find.byKey(const ValueKey('barrier')), findsOneWidget); - expect(dismissCallbackCalled, false); - - // Tap on the barrier - await tester.tap(find.byKey(const ValueKey('barrier'))); - await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition - expect(dismissCallbackCalled, true); - }); - - testWidgets('ModalBarrier will not pop when given an onDismiss callback', (WidgetTester tester) async { - final Map routes = { - '/': (BuildContext context) => const FirstWidget(), - '/modal': (BuildContext context) => SecondWidget(onDismiss: () {}), - }; - - await tester.pumpWidget(MaterialApp(routes: routes)); - - // Initially the barrier is not visible - expect(find.byKey(const ValueKey('barrier')), findsNothing); - - // Tapping on X routes to the barrier - await tester.tap(find.text('X')); - await tester.pump(); // begin transition - await tester.pump(const Duration(seconds: 1)); // end transition - expect(find.byKey(const ValueKey('barrier')), findsOneWidget); - - // Tap on the barrier - await tester.tap(find.byKey(const ValueKey('barrier'))); - await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition - expect( - find.byKey(const ValueKey('barrier')), - findsOneWidget, - reason: 'The route should not have been dismissed by tapping the barrier, as there was a onDismiss callback given.', - ); - }); - - testWidgets('Undismissible ModalBarrier hidden in semantic tree', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(const ModalBarrier(dismissible: false)); - - final TestSemantics expectedSemantics = TestSemantics.root(); - expect(semantics, hasSemantics(expectedSemantics)); - - semantics.dispose(); - }); - - testWidgets('Dismissible ModalBarrier includes button in semantic tree on iOS', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: ModalBarrier( - semanticsLabel: 'Dismiss', - ), - )); - - final TestSemantics expectedSemantics = TestSemantics.root( - children: [ - TestSemantics.rootChild( - rect: TestSemantics.fullScreen, - actions: [SemanticsAction.tap, SemanticsAction.dismiss], - label: 'Dismiss', + final Widget subject = Stack( textDirection: TextDirection.ltr, + children: [ + tapTarget, + const ModalBarrier(dismissible: false), + ], + ); + + await tester.pumpWidget(subject); + await tester.tap(find.text('target'), warnIfMissed: false); + await tester.pumpWidget(subject); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + } + expect(playedSystemSounds, hasLength(1)); + expect(playedSystemSounds[0], SystemSoundType.alert.toString()); + }); + + testWidgets('pops the Navigator when dismissed by primary tap', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const SecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Press the barrier; it shouldn't dismiss yet + final TestGesture gesture = await tester.press( + find.byKey(const ValueKey('barrier')), + ); + await tester.pumpAndSettle(); // begin transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Release the pointer; the barrier should be dismissed + await gesture.up(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('pops the Navigator when dismissed by non-primary tap', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const SecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Press the barrier; it shouldn't dismiss yet + final TestGesture gesture = await tester.press( + find.byKey(const ValueKey('barrier')), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); // begin transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Release the pointer; the barrier should be dismissed + await gesture.up(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('may pop the Navigator when competing with other gestures', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const SecondWidgetWithCompetence(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Tap on the barrier to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async { + bool willPopCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + Stack( + children: [ + const SecondWidget(), + WillPopScope( + child: const SizedBox(), + onWillPop: () async { + willPopCalled = true; + return false; + }, + ), + ], + ), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(willPopCalled, isFalse); + + // Tap on the barrier to attempt to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsOneWidget, + reason: 'The route should still be present if the pop is vetoed.', + ); + + expect(willPopCalled, isTrue); + }); + + testWidgets('pops the Navigator with a WillPopScope that returns true', (WidgetTester tester) async { + bool willPopCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + Stack( + children: [ + const SecondWidget(), + WillPopScope( + child: const SizedBox(), + onWillPop: () async { + willPopCalled = true; + return true; + }, + ), + ], + ), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(willPopCalled, isFalse); + + // Tap on the barrier to attempt to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should not be present if the pop is permitted.', + ); + + expect(willPopCalled, isTrue); + }); + + testWidgets('will call onDismiss callback', (WidgetTester tester) async { + bool dismissCallbackCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + SecondWidget(onDismiss: () { + dismissCallbackCalled = true; + }), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + expect(dismissCallbackCalled, false); + + // Tap on the barrier + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect(dismissCallbackCalled, true); + }); + + testWidgets('will not pop when given an onDismiss callback', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => SecondWidget(onDismiss: () {}), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Tap on the barrier + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsOneWidget, + reason: 'The route should not have been dismissed by tapping the barrier, as there was a onDismiss callback given.', + ); + }); + + testWidgets('Undismissible ModalBarrier hidden in semantic tree', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(const ModalBarrier(dismissible: false)); + + final TestSemantics expectedSemantics = TestSemantics.root(); + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); + + testWidgets('Dismissible ModalBarrier includes button in semantic tree on iOS', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(const Directionality( + textDirection: TextDirection.ltr, + child: ModalBarrier( + semanticsLabel: 'Dismiss', ), - ], - ); - expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); + )); - semantics.dispose(); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics.rootChild( + rect: TestSemantics.fullScreen, + actions: [SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); - testWidgets('Dismissible ModalBarrier is hidden on Android (back button is used to dismiss)', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(const ModalBarrier()); + semantics.dispose(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS})); - final TestSemantics expectedSemantics = TestSemantics.root(); - expect(semantics, hasSemantics(expectedSemantics)); + testWidgets( + 'Dismissible ModalBarrier is hidden on Android (back button is used to dismiss)', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(const ModalBarrier()); - semantics.dispose(); + final TestSemantics expectedSemantics = TestSemantics.root(); + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); + }); + group('AnimatedModalBarrier', () { + testWidgets('prevents interactions with widgets behind it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + tapTarget, + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + ], + ); + + await tester.pumpWidget(subject); + await tester.tap(find.text('target'), warnIfMissed: false); + await tester.pumpWidget(subject); + expect(tapped, isFalse, reason: 'because the tap is not prevented by ModalBarrier'); + }); + + testWidgets('prevents hover interactions with widgets behind it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + hoverTarget, + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + ], + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + // Start out of hoverTarget + await gesture.moveTo(const Offset(100, 100)); + + await tester.pumpWidget(subject); + // Move into hoverTarget and tap + await gesture.down(const Offset(5, 5)); + await tester.pumpWidget(subject); + await gesture.up(); + await tester.pumpWidget(subject); + + // Move out + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + + expect(hovered, isFalse, reason: 'because the hover is not prevented by AnimatedModalBarrier'); + }); + + testWidgets('does not prevent interactions with widgets in front of it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + tapTarget, + ], + ); + + await tester.pumpWidget(subject); + await tester.tap(find.text('target')); + await tester.pumpWidget(subject); + expect(tapped, isTrue, reason: 'because the tap is prevented by AnimatedModalBarrier'); + }); + + testWidgets('does not prevent interactions with translucent widgets in front of it', (WidgetTester tester) async { + bool dragged = false; + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) { + dragged = true; + }, + child: const Center( + child: Text('target', textDirection: TextDirection.ltr), + ), + ), + ], + ); + + await tester.pumpWidget(subject); + await tester.dragFrom( + tester.getBottomRight(find.byType(GestureDetector)) - const Offset(10, 10), + const Offset(-20, 0), + ); + await tester.pumpWidget(subject); + expect(dragged, isTrue, reason: 'because the drag is prevented by AnimatedModalBarrier'); + }); + + testWidgets('does not prevent hover interactions with widgets in front of it', (WidgetTester tester) async { + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + hoverTarget, + ], + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + // Start out of hoverTarget + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + expect(hovered, isFalse); + + // Move into hoverTarget + await gesture.moveTo(const Offset(5, 5)); + await tester.pumpWidget(subject); + expect(hovered, isTrue, reason: 'because the hover is prevented by AnimatedModalBarrier'); + hovered = false; + + // Move out + await gesture.moveTo(const Offset(100, 100)); + await tester.pumpWidget(subject); + expect(hovered, isTrue, reason: 'because the hover is prevented by AnimatedModalBarrier'); + hovered = false; + }); + + testWidgets('plays system alert sound when user tries to dismiss it', (WidgetTester tester) async { + final List playedSystemSounds = []; + try { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'SystemSound.play') { + playedSystemSounds.add(methodCall.arguments as String); + } + return null; + }); + + final Widget subject = Stack( + textDirection: TextDirection.ltr, + children: [ + tapTarget, + AnimatedModalBarrier(dismissible: false, color: colorAnimation), + ], + ); + + await tester.pumpWidget(subject); + await tester.tap(find.text('target'), warnIfMissed: false); + await tester.pumpWidget(subject); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null); + } + expect(playedSystemSounds, hasLength(1)); + expect(playedSystemSounds[0], SystemSoundType.alert.toString()); + }); + + testWidgets('pops the Navigator when dismissed by primary tap', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const AnimatedSecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Press the barrier; it shouldn't dismiss yet + final TestGesture gesture = await tester.press( + find.byKey(const ValueKey('barrier')), + ); + await tester.pumpAndSettle(); // begin transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Release the pointer; the barrier should be dismissed + await gesture.up(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('pops the Navigator when dismissed by non-primary tap', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const AnimatedSecondWidget(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Press the barrier; it shouldn't dismiss yet + final TestGesture gesture = await tester.press( + find.byKey(const ValueKey('barrier')), + buttons: kSecondaryButton, + ); + await tester.pumpAndSettle(); // begin transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Release the pointer; the barrier should be dismissed + await gesture.up(); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('may pop the Navigator when competing with other gestures', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => const AnimatedSecondWidgetWithCompetence(), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + // Tap on the barrier to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should have been dismissed by tapping the barrier.', + ); + }); + + testWidgets('does not pop the Navigator with a WillPopScope that returns false', (WidgetTester tester) async { + bool willPopCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + Stack( + children: [ + const AnimatedSecondWidget(), + WillPopScope( + child: const SizedBox(), + onWillPop: () async { + willPopCalled = true; + return false; + }, + ), + ], + ), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(willPopCalled, isFalse); + + // Tap on the barrier to attempt to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsOneWidget, + reason: 'The route should still be present if the pop is vetoed.', + ); + + expect(willPopCalled, isTrue); + }); + + testWidgets('pops the Navigator with a WillPopScope that returns true', (WidgetTester tester) async { + bool willPopCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + Stack( + children: [ + const AnimatedSecondWidget(), + WillPopScope( + child: const SizedBox(), + onWillPop: () async { + willPopCalled = true; + return true; + }, + ), + ], + ), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect(willPopCalled, isFalse); + + // Tap on the barrier to attempt to dismiss it + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + + expect( + find.byKey(const ValueKey('barrier')), + findsNothing, + reason: 'The route should not be present if the pop is permitted.', + ); + + expect(willPopCalled, isTrue); + }); + + testWidgets('will call onDismiss callback', (WidgetTester tester) async { + bool dismissCallbackCalled = false; + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => + AnimatedSecondWidget(onDismiss: () { + dismissCallbackCalled = true; + }), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + expect(dismissCallbackCalled, false); + + // Tap on the barrier + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect(dismissCallbackCalled, true); + }); + + testWidgets('will not pop when given an onDismiss callback', (WidgetTester tester) async { + final Map routes = { + '/': (BuildContext context) => const FirstWidget(), + '/modal': (BuildContext context) => AnimatedSecondWidget(onDismiss: () {}), + }; + + await tester.pumpWidget(MaterialApp(routes: routes)); + + // Initially the barrier is not visible + expect(find.byKey(const ValueKey('barrier')), findsNothing); + + // Tapping on X routes to the barrier + await tester.tap(find.text('X')); + await tester.pump(); // begin transition + await tester.pump(const Duration(seconds: 1)); // end transition + expect(find.byKey(const ValueKey('barrier')), findsOneWidget); + + // Tap on the barrier + await tester.tap(find.byKey(const ValueKey('barrier'))); + await tester.pumpAndSettle(const Duration(seconds: 1)); // end transition + expect( + find.byKey(const ValueKey('barrier')), + findsOneWidget, + reason: 'The route should not have been dismissed by tapping the barrier, as there was a onDismiss callback given.', + ); + }); + + testWidgets('Undismissible AnimatedModalBarrier hidden in semantic tree', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(AnimatedModalBarrier(dismissible: false, color: colorAnimation)); + + final TestSemantics expectedSemantics = TestSemantics.root(); + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); + + testWidgets('Dismissible AnimatedModalBarrier includes button in semantic tree on iOS', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: AnimatedModalBarrier( + semanticsLabel: 'Dismiss', + color: colorAnimation, + ), + )); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics.rootChild( + rect: TestSemantics.fullScreen, + actions: [SemanticsAction.tap, SemanticsAction.dismiss], + label: 'Dismiss', + textDirection: TextDirection.ltr, + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreId: true)); + + semantics.dispose(); + }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS})); + + testWidgets( + 'Dismissible AnimatedModalBarrier is hidden on Android (back button is used to dismiss)', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(AnimatedModalBarrier(color: colorAnimation)); + + final TestSemantics expectedSemantics = TestSemantics.root(); + expect(semantics, hasSemantics(expectedSemantics)); + + semantics.dispose(); + }); }); - testWidgets('ModalBarrier uses default mouse cursor', (WidgetTester tester) async { + testWidgets('uses default mouse cursor', (WidgetTester tester) async { await tester.pumpWidget(Stack( textDirection: TextDirection.ltr, children: const [ @@ -485,7 +924,7 @@ void main() { } class FirstWidget extends StatelessWidget { - const FirstWidget({ Key? key }) : super(key: key); + const FirstWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( @@ -498,7 +937,7 @@ class FirstWidget extends StatelessWidget { } class SecondWidget extends StatelessWidget { - const SecondWidget({ Key? key, this.onDismiss }) : super(key: key); + const SecondWidget({Key? key, this.onDismiss}) : super(key: key); final VoidCallback? onDismiss; @@ -511,8 +950,23 @@ class SecondWidget extends StatelessWidget { } } +class AnimatedSecondWidget extends StatelessWidget { + const AnimatedSecondWidget({Key? key, this.onDismiss}) : super(key: key); + + final VoidCallback? onDismiss; + + @override + Widget build(BuildContext context) { + return AnimatedModalBarrier( + key: const ValueKey('barrier'), + color: const AlwaysStoppedAnimation(Colors.red), + onDismiss: onDismiss, + ); + } +} + class SecondWidgetWithCompetence extends StatelessWidget { - const SecondWidgetWithCompetence({ Key? key }) : super(key: key); + const SecondWidgetWithCompetence({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Stack( @@ -529,3 +983,22 @@ class SecondWidgetWithCompetence extends StatelessWidget { ); } } +class AnimatedSecondWidgetWithCompetence extends StatelessWidget { + const AnimatedSecondWidgetWithCompetence({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const AnimatedModalBarrier( + key: ValueKey('barrier'), + color: AlwaysStoppedAnimation(Colors.red), + ), + GestureDetector( + onVerticalDragStart: (_) {}, + behavior: HitTestBehavior.translucent, + child: Container(), + ), + ], + ); + } +}