// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @Tags(<String>['reduced-test-set']) import 'package:flutter/cupertino.dart' show CupertinoPageRoute; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; void main() { testWidgets('test page transition (_FadeUpwardsPageTransition)', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: const Material(child: Text('Page 1')), theme: ThemeData( pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), ), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return const Material(child: Text('Page 2')); }, }, ), ); final Offset widget1TopLeft = tester.getTopLeft(find.text('Page 1')); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); FadeTransition widget2Opacity = tester.element(find.text('Page 2')).findAncestorWidgetOfExactType<FadeTransition>()!; Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); final Size widget2Size = tester.getSize(find.text('Page 2')); // Android transition is vertical only. expect(widget1TopLeft.dx == widget2TopLeft.dx, true); // Page 1 is above page 2 mid-transition. expect(widget1TopLeft.dy < widget2TopLeft.dy, true); // Animation begins 3/4 of the way up the page. expect(widget2TopLeft.dy < widget2Size.height / 4.0, true); // Animation starts with page 2 being near transparent. expect(widget2Opacity.opacity.value < 0.01, true); await tester.pump(const Duration(milliseconds: 300)); // Page 2 covers page 1. expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 1)); widget2Opacity = tester.element(find.text('Page 2')).findAncestorWidgetOfExactType<FadeTransition>()!; widget2TopLeft = tester.getTopLeft(find.text('Page 2')); // Page 2 starts to move down. expect(widget1TopLeft.dy < widget2TopLeft.dy, true); // Page 2 starts to lose opacity. expect(widget2Opacity.opacity.value < 1.0, true); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets('test page transition (CupertinoPageTransition)', (WidgetTester tester) async { final Key page2Key = UniqueKey(); await tester.pumpWidget( MaterialApp( home: const Material(child: Text('Page 1')), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return Material( key: page2Key, child: const Text('Page 2'), ); }, }, ), ); final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(milliseconds: 150)); Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); final RenderDecoratedBox box = tester.element(find.byKey(page2Key)) .findAncestorRenderObjectOfType<RenderDecoratedBox>()!; // Page 1 is moving to the left. expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); // Page 1 isn't moving vertically. expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); // iOS transition is horizontal only. expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); // Page 2 is coming in from the right. expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); // As explained in _CupertinoEdgeShadowPainter.paint the shadow is drawn // as a bunch of rects. The rects are covering an area to the left of // where the page 2 box is and a width of 5% of the page 2 box width. // `paints` tests relative to the painter's given canvas // rather than relative to the screen so assert that the shadow starts at // offset.dx = 0. final PaintPattern paintsShadow = paints; for (int i = 0; i < 0.05 * 800; i += 1) { paintsShadow.rect(rect: Rect.fromLTWH(-i.toDouble() - 1.0 , 0.0, 1.0, 600)); } expect(box, paintsShadow); await tester.pumpAndSettle(); // Page 2 covers page 1. expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); widget2TopLeft = tester.getTopLeft(find.text('Page 2')); // Page 1 is coming back from the left. expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); // Page 1 isn't moving vertically. expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); // iOS transition is horizontal only. expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); // Page 2 is leaving towards the right. expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); await tester.pumpAndSettle(); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); // Page 1 is back where it started. expect(widget1InitialTopLeft == widget1TransientTopLeft, true); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('test page transition (_ZoomPageTransition) without rasterization', (WidgetTester tester) async { Iterable<Layer> findLayers(Finder of) { return tester.layerListOf( find.ancestor(of: of, matching: find.byType(SnapshotWidget)).first, ); } OpacityLayer findForwardFadeTransition(Finder of) { return findLayers(of).whereType<OpacityLayer>().first; } TransformLayer findForwardScaleTransition(Finder of) { return findLayers(of).whereType<TransformLayer>().first; } await tester.pumpWidget( MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( allowSnapshotting: false, builder: (BuildContext context) { if (settings.name == '/') { return const Material(child: Text('Page 1')); } return const Material(child: Text('Page 2')); }, ); }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); TransformLayer widget1Scale = findForwardScaleTransition(find.text('Page 1')); TransformLayer widget2Scale = findForwardScaleTransition(find.text('Page 2')); OpacityLayer widget2Opacity = findForwardFadeTransition(find.text('Page 2')); double getScale(TransformLayer layer) { return layer.transform!.storage[0]; } // Page 1 is enlarging, starts from 1.0. expect(getScale(widget1Scale), greaterThan(1.0)); // Page 2 is enlarging from the value less than 1.0. expect(getScale(widget2Scale), lessThan(1.0)); // Page 2 is becoming none transparent. expect(widget2Opacity.alpha, lessThan(255)); await tester.pump(const Duration(milliseconds: 250)); await tester.pump(const Duration(milliseconds: 1)); // Page 2 covers page 1. expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); widget1Scale = findForwardScaleTransition(find.text('Page 1')); widget2Scale = findForwardScaleTransition(find.text('Page 2')); widget2Opacity = findForwardFadeTransition(find.text('Page 2')); // Page 1 is narrowing down, but still larger than 1.0. expect(getScale(widget1Scale), greaterThan(1.0)); // Page 2 is smaller than 1.0. expect(getScale(widget2Scale), lessThan(1.0)); // Page 2 is becoming transparent. expect(widget2Opacity.alpha, lessThan(255)); await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 1)); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets('test page transition (_ZoomPageTransition) with rasterization re-rasterizes when window size changes', (WidgetTester tester) async { // Shrink the window size. late Size oldSize; try { oldSize = tester.binding.window.physicalSize; tester.binding.window.physicalSizeTestValue = const Size(1000, 1000); final Key key = GlobalKey(); await tester.pumpWidget( RepaintBoundary( key: key, child: MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( builder: (BuildContext context) { return const Material(child: SizedBox.shrink()); }, ); }, ), ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); await expectLater(find.byKey(key), matchesGoldenFile('zoom_page_transition.small.png')); // Increase the window size. tester.binding.window.physicalSizeTestValue = const Size(1000, 2000); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); await expectLater(find.byKey(key), matchesGoldenFile('zoom_page_transition.big.png')); } finally { tester.binding.window.physicalSizeTestValue = oldSize; } }, variant: TargetPlatformVariant.only(TargetPlatform.android), skip: kIsWeb); // [intended] rasterization is not used on the web. testWidgets('test fullscreen dialog transition', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Material(child: Text('Page 1')), ), ); final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); tester.state<NavigatorState>(find.byType(Navigator)).push(MaterialPageRoute<void>( builder: (BuildContext context) { return const Material(child: Text('Page 2')); }, fullscreenDialog: true, )); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); // Page 1 doesn't move. expect(widget1TransientTopLeft == widget1InitialTopLeft, true); // Fullscreen dialogs transitions vertically only. expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); // Page 2 is coming in from the bottom. expect(widget2TopLeft.dy > widget1InitialTopLeft.dy, true); await tester.pumpAndSettle(); // Page 2 covers page 1. expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); widget2TopLeft = tester.getTopLeft(find.text('Page 2')); // Page 1 doesn't move. expect(widget1TransientTopLeft == widget1InitialTopLeft, true); // Fullscreen dialogs transitions vertically only. expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); // Page 2 is leaving towards the bottom. expect(widget2TopLeft.dy > widget1InitialTopLeft.dy, true); await tester.pumpAndSettle(); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); // Page 1 is back where it started. expect(widget1InitialTopLeft == widget1TransientTopLeft, true); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('test no back gesture on Android', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: const Scaffold(body: Text('Page 1')), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return const Scaffold(body: Text('Page 2')); }, }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pumpAndSettle(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Drag from left edge to invoke the gesture. final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); await gesture.moveBy(const Offset(400.0, 0.0)); await tester.pump(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Page 2 didn't move expect(tester.getTopLeft(find.text('Page 2')), Offset.zero); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets('test back gesture', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: const Scaffold(body: Text('Page 1')), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return const Scaffold(body: Text('Page 2')); }, }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pumpAndSettle(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Drag from left edge to invoke the gesture. final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); await gesture.moveBy(const Offset(400.0, 0.0)); await tester.pump(); // Page 1 is now visible. expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), isOnstage); // The route widget position needs to track the finger position very exactly. expect(tester.getTopLeft(find.text('Page 2')), const Offset(400.0, 0.0)); await gesture.moveBy(const Offset(-200.0, 0.0)); await tester.pump(); expect(tester.getTopLeft(find.text('Page 2')), const Offset(200.0, 0.0)); await gesture.moveBy(const Offset(-100.0, 200.0)); await tester.pump(); expect(tester.getTopLeft(find.text('Page 2')), const Offset(100.0, 0.0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('back gesture while OS changes', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => Material( child: TextButton( child: const Text('PUSH'), onPressed: () { Navigator.of(context).pushNamed('/b'); }, ), ), '/b': (BuildContext context) => const Text('HELLO'), }; await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), routes: routes, ), ); await tester.tap(find.text('PUSH')); expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); expect(find.text('PUSH'), findsNothing); expect(find.text('HELLO'), findsOneWidget); final Offset helloPosition1 = tester.getCenter(find.text('HELLO')); final TestGesture gesture = await tester.startGesture(const Offset(2.5, 300.0)); await tester.pump(const Duration(milliseconds: 20)); await gesture.moveBy(const Offset(100.0, 0.0)); expect(find.text('PUSH'), findsNothing); expect(find.text('HELLO'), findsOneWidget); await tester.pump(const Duration(milliseconds: 20)); expect(find.text('PUSH'), findsOneWidget); expect(find.text('HELLO'), findsOneWidget); final Offset helloPosition2 = tester.getCenter(find.text('HELLO')); expect(helloPosition1.dx, lessThan(helloPosition2.dx)); expect(helloPosition1.dy, helloPosition2.dy); expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.iOS); await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.android), routes: routes, ), ); // Now we have to let the theme animation run through. // This takes three frames (including the first one above): // 1. Start the Theme animation. It's at t=0 so everything else is identical. // 2. Start any animations that are informed by the Theme, for example, the // DefaultTextStyle, on the first frame that the theme is not at t=0. In // this case, it's at t=1.0 of the theme animation, so this is also the // frame in which the theme animation ends. // 3. End all the other animations. expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.android); final Offset helloPosition3 = tester.getCenter(find.text('HELLO')); expect(helloPosition3, helloPosition2); expect(find.text('PUSH'), findsOneWidget); expect(find.text('HELLO'), findsOneWidget); await gesture.moveBy(const Offset(100.0, 0.0)); await tester.pump(const Duration(milliseconds: 20)); expect(find.text('PUSH'), findsOneWidget); expect(find.text('HELLO'), findsOneWidget); final Offset helloPosition4 = tester.getCenter(find.text('HELLO')); expect(helloPosition3.dx, lessThan(helloPosition4.dx)); expect(helloPosition3.dy, helloPosition4.dy); await gesture.moveBy(const Offset(500.0, 0.0)); await gesture.up(); expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 3); expect(find.text('PUSH'), findsOneWidget); expect(find.text('HELLO'), findsNothing); await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.macOS), routes: routes, ), ); await tester.tap(find.text('PUSH')); expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); expect(find.text('PUSH'), findsNothing); expect(find.text('HELLO'), findsOneWidget); final Offset helloPosition5 = tester.getCenter(find.text('HELLO')); await gesture.down(const Offset(2.5, 300.0)); await tester.pump(const Duration(milliseconds: 20)); await gesture.moveBy(const Offset(100.0, 0.0)); expect(find.text('PUSH'), findsNothing); expect(find.text('HELLO'), findsOneWidget); await tester.pump(const Duration(milliseconds: 20)); expect(find.text('PUSH'), findsOneWidget); expect(find.text('HELLO'), findsOneWidget); final Offset helloPosition6 = tester.getCenter(find.text('HELLO')); expect(helloPosition5.dx, lessThan(helloPosition6.dx)); expect(helloPosition5.dy, helloPosition6.dy); expect(Theme.of(tester.element(find.text('HELLO'))).platform, TargetPlatform.macOS); }); testWidgets('test no back gesture on fullscreen dialogs', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold(body: Text('Page 1')), ), ); tester.state<NavigatorState>(find.byType(Navigator)).push(MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold(body: Text('Page 2')); }, fullscreenDialog: true, )); await tester.pumpAndSettle(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Drag from left edge to invoke the gesture. final TestGesture gesture = await tester.startGesture(const Offset(5.0, 100.0)); await gesture.moveBy(const Offset(400.0, 0.0)); await tester.pump(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Page 2 didn't move expect(tester.getTopLeft(find.text('Page 2')), Offset.zero); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('test adaptable transitions switch during execution', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData( platform: TargetPlatform.android, pageTransitionsTheme: const PageTransitionsTheme( builders: <TargetPlatform, PageTransitionsBuilder>{ TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), }, ), ), home: const Material(child: Text('Page 1')), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return const Material(child: Text('Page 2')); }, }, ), ); final Offset widget1InitialTopLeft = tester.getTopLeft(find.text('Page 1')); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); Offset widget2TopLeft = tester.getTopLeft(find.text('Page 2')); final Size widget2Size = tester.getSize(find.text('Page 2')); // Android transition is vertical only. expect(widget1InitialTopLeft.dx == widget2TopLeft.dx, true); // Page 1 is above page 2 mid-transition. expect(widget1InitialTopLeft.dy < widget2TopLeft.dy, true); // Animation begins from the top of the page. expect(widget2TopLeft.dy < widget2Size.height, true); await tester.pump(const Duration(milliseconds: 300)); // Page 2 covers page 1. expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); // Re-pump the same app but with iOS instead of Android. await tester.pumpWidget( MaterialApp( home: const Material(child: Text('Page 1')), routes: <String, WidgetBuilder>{ '/next': (BuildContext context) { return const Material(child: Text('Page 2')); }, }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); Offset widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); widget2TopLeft = tester.getTopLeft(find.text('Page 2')); // Page 1 is coming back from the left. expect(widget1TransientTopLeft.dx < widget1InitialTopLeft.dx, true); // Page 1 isn't moving vertically. expect(widget1TransientTopLeft.dy == widget1InitialTopLeft.dy, true); // iOS transition is horizontal only. expect(widget1InitialTopLeft.dy == widget2TopLeft.dy, true); // Page 2 is leaving towards the right. expect(widget2TopLeft.dx > widget1InitialTopLeft.dx, true); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); widget1TransientTopLeft = tester.getTopLeft(find.text('Page 1')); // Page 1 is back where it started. expect(widget1InitialTopLeft == widget1TransientTopLeft, true); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('test edge swipe then drop back at starting point works', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) { final String pageNumber = settings.name == '/' ? '1' : '2'; return Center(child: Text('Page $pageNumber')); }, ); }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); await gesture.moveBy(const Offset(300, 0)); await tester.pump(); // Bring it exactly back such that there's nothing to animate when releasing. await gesture.moveBy(const Offset(-300, 0)); await gesture.up(); await tester.pump(); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('test edge swipe then drop back at ending point works', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) { final String pageNumber = settings.name == '/' ? '1' : '2'; return Center(child: Text('Page $pageNumber')); }, ); }, ), ); tester.state<NavigatorState>(find.byType(Navigator)).pushNamed('/next'); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.text('Page 1'), findsNothing); expect(find.text('Page 2'), isOnstage); final TestGesture gesture = await tester.startGesture(const Offset(5, 200)); // The width of the page. await gesture.moveBy(const Offset(800, 0)); await gesture.up(); await tester.pump(); expect(find.text('Page 1'), isOnstage); expect(find.text('Page 2'), findsNothing); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Back swipe dismiss interrupted by route push', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/28728 final GlobalKey scaffoldKey = GlobalKey(); await tester.pumpWidget( MaterialApp( home: Scaffold( key: scaffoldKey, body: Center( child: ElevatedButton( onPressed: () { Navigator.push<void>(scaffoldKey.currentContext!, MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center(child: Text('route')), ); }, )); }, child: const Text('push'), ), ), ), ), ); // Check the basic iOS back-swipe dismiss transition. Dragging the pushed // route halfway across the screen will trigger the iOS dismiss animation await tester.tap(find.text('push')); await tester.pumpAndSettle(); expect(find.text('route'), findsOneWidget); expect(find.text('push'), findsNothing); TestGesture gesture = await tester.startGesture(const Offset(5, 300)); await gesture.moveBy(const Offset(400, 0)); await gesture.up(); await tester.pump(); expect( // The 'route' route has been dragged to the right, halfway across the screen tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))), const Offset(400, 0), ); expect( // The 'push' route is sliding in from the left. tester.getTopLeft(find.ancestor(of: find.text('push'), matching: find.byType(Scaffold))).dx, lessThan(0), ); await tester.pumpAndSettle(); expect(find.text('push'), findsOneWidget); expect( tester.getTopLeft(find.ancestor(of: find.text('push'), matching: find.byType(Scaffold))), Offset.zero, ); expect(find.text('route'), findsNothing); // Run the dismiss animation 60%, which exposes the route "push" button, // and then press the button. A drag dropped animation is 400ms when dropped // exactly halfway. It follows a curve that is very steep initially. await tester.tap(find.text('push')); await tester.pumpAndSettle(); expect(find.text('route'), findsOneWidget); expect(find.text('push'), findsNothing); gesture = await tester.startGesture(const Offset(5, 300)); await gesture.moveBy(const Offset(400, 0)); // Drag halfway. await gesture.up(); await tester.pump(); // Trigger the dropped snapping animation. expect( tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))), const Offset(400, 0), ); // Let the dismissing snapping animation go 60%. await tester.pump(const Duration(milliseconds: 240)); expect( tester.getTopLeft(find.ancestor(of: find.text('route'), matching: find.byType(Scaffold))).dx, moreOrLessEquals(798, epsilon: 1), ); // Use the navigator to push a route instead of tapping the 'push' button. // The topmost route (the one that's animating away), ignores input while // the pop is underway because route.navigator.userGestureInProgress. Navigator.push<void>(scaffoldKey.currentContext!, MaterialPageRoute<void>( builder: (BuildContext context) { return const Scaffold( body: Center(child: Text('route')), ); }, )); await tester.pumpAndSettle(); expect(find.text('route'), findsOneWidget); expect(find.text('push'), findsNothing); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('During back swipe the route ignores input', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/39989 final GlobalKey homeScaffoldKey = GlobalKey(); final GlobalKey pageScaffoldKey = GlobalKey(); int homeTapCount = 0; int pageTapCount = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( key: homeScaffoldKey, body: GestureDetector( onTap: () { homeTapCount += 1; }, ), ), ), ); await tester.tap(find.byKey(homeScaffoldKey)); expect(homeTapCount, 1); expect(pageTapCount, 0); Navigator.push<void>(homeScaffoldKey.currentContext!, MaterialPageRoute<void>( builder: (BuildContext context) { return Scaffold( key: pageScaffoldKey, appBar: AppBar(title: const Text('Page')), body: Padding( padding: const EdgeInsets.all(16), child: GestureDetector( onTap: () { pageTapCount += 1; }, ), ), ); }, )); await tester.pumpAndSettle(); await tester.tap(find.byKey(pageScaffoldKey)); expect(homeTapCount, 1); expect(pageTapCount, 1); // Start the basic iOS back-swipe dismiss transition. Drag the pushed // "page" route halfway across the screen. The underlying "home" will // start sliding in from the left. final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); await gesture.moveBy(const Offset(400, 0)); await tester.pump(); expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(400, 0)); expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); // Tapping on the "page" route doesn't trigger the GestureDetector because // it's being dragged. await tester.tap(find.byKey(pageScaffoldKey), warnIfMissed: false); expect(homeTapCount, 1); expect(pageTapCount, 1); // Tapping the "page" route's back button doesn't do anything either. await tester.tap(find.byTooltip('Back'), warnIfMissed: false); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(400, 0)); expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('After a pop caused by a back-swipe, input reaches the exposed route', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/41024 final GlobalKey homeScaffoldKey = GlobalKey(); final GlobalKey pageScaffoldKey = GlobalKey(); int homeTapCount = 0; int pageTapCount = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( key: homeScaffoldKey, body: GestureDetector( onTap: () { homeTapCount += 1; }, ), ), ), ); await tester.tap(find.byKey(homeScaffoldKey)); expect(homeTapCount, 1); expect(pageTapCount, 0); final ValueNotifier<bool> notifier = Navigator.of(homeScaffoldKey.currentContext!).userGestureInProgressNotifier; expect(notifier.value, false); Navigator.push<void>(homeScaffoldKey.currentContext!, MaterialPageRoute<void>( builder: (BuildContext context) { return Scaffold( key: pageScaffoldKey, appBar: AppBar(title: const Text('Page')), body: Padding( padding: const EdgeInsets.all(16), child: GestureDetector( onTap: () { pageTapCount += 1; }, ), ), ); }, )); await tester.pumpAndSettle(); await tester.tap(find.byKey(pageScaffoldKey)); expect(homeTapCount, 1); expect(pageTapCount, 1); // Trigger the basic iOS back-swipe dismiss transition. Drag the pushed // "page" route more than halfway across the screen and then release it. final TestGesture gesture = await tester.startGesture(const Offset(5, 300)); await gesture.moveBy(const Offset(500, 0)); await tester.pump(); expect(tester.getTopLeft(find.byKey(pageScaffoldKey)), const Offset(500, 0)); expect(tester.getTopLeft(find.byKey(homeScaffoldKey)).dx, lessThan(0)); expect(notifier.value, true); await gesture.up(); await tester.pumpAndSettle(); expect(notifier.value, false); expect(find.byKey(pageScaffoldKey), findsNothing); // The back-swipe dismiss pop transition has finished and input on the // home page still works. await tester.tap(find.byKey(homeScaffoldKey)); expect(homeTapCount, 2); expect(pageTapCount, 1); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('A MaterialPageRoute should slide out with CupertinoPageTransition when a compatible PageRoute is pushed on top of it', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/44864. await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), home: Scaffold( appBar: AppBar(title: const Text('Title')), ), ), ); final Offset titleInitialTopLeft = tester.getTopLeft(find.text('Title')); tester.state<NavigatorState>(find.byType(Navigator)).push<void>( CupertinoPageRoute<void>(builder: (BuildContext context) => const Placeholder()), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 150)); final Offset titleTransientTopLeft = tester.getTopLeft(find.text('Title')); // Title of the first route slides to the left. expect(titleInitialTopLeft.dy, equals(titleTransientTopLeft.dy)); expect(titleInitialTopLeft.dx, greaterThan(titleTransientTopLeft.dx)); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('MaterialPage works', (WidgetTester tester) async { final LocalKey pageKey = UniqueKey(); final TransitionDetector detector = TransitionDetector(); List<Page<void>> myPages = <Page<void>>[ MaterialPage<void>(key: pageKey, child: const Text('first')), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: (Route<dynamic> route, dynamic result) { assert(false); // The test should never execute this. return true; }, transitionDelegate: detector, ), ); expect(detector.hasTransition, isFalse); expect(find.text('first'), findsOneWidget); myPages = <Page<void>>[ MaterialPage<void>(key: pageKey, child: const Text('second')), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: (Route<dynamic> route, dynamic result) { assert(false); // The test should never execute this. return true; }, transitionDelegate: detector, ), ); // There should be no transition because the page has the same key. expect(detector.hasTransition, isFalse); // The content does update. expect(find.text('first'), findsNothing); expect(find.text('second'), findsOneWidget); }); testWidgets('MaterialPage can toggle MaintainState', (WidgetTester tester) async { final LocalKey pageKeyOne = UniqueKey(); final LocalKey pageKeyTwo = UniqueKey(); final TransitionDetector detector = TransitionDetector(); List<Page<void>> myPages = <Page<void>>[ MaterialPage<void>(key: pageKeyOne, maintainState: false, child: const Text('first')), MaterialPage<void>(key: pageKeyTwo, child: const Text('second')), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: (Route<dynamic> route, dynamic result) { assert(false); // The test should never execute this. return true; }, transitionDelegate: detector, ), ); expect(detector.hasTransition, isFalse); // Page one does not maintain state. expect(find.text('first', skipOffstage: false), findsNothing); expect(find.text('second'), findsOneWidget); myPages = <Page<void>>[ MaterialPage<void>(key: pageKeyOne, child: const Text('first')), MaterialPage<void>(key: pageKeyTwo, child: const Text('second')), ]; await tester.pumpWidget( buildNavigator( pages: myPages, onPopPage: (Route<dynamic> route, dynamic result) { assert(false); // The test should never execute this. return true; }, transitionDelegate: detector, ), ); // There should be no transition because the page has the same key. expect(detector.hasTransition, isFalse); // Page one sets the maintain state to be true, its widget tree should be // built. expect(find.text('first', skipOffstage: false), findsOneWidget); expect(find.text('second'), findsOneWidget); }); testWidgets('MaterialPage does not lose its state when transitioning out', (WidgetTester tester) async { final GlobalKey<NavigatorState> navigator = GlobalKey<NavigatorState>(); await tester.pumpWidget(KeepsStateTestWidget(navigatorKey: navigator)); expect(find.text('subpage'), findsOneWidget); expect(find.text('home'), findsNothing); navigator.currentState!.pop(); await tester.pump(); expect(find.text('subpage'), findsOneWidget); expect(find.text('home'), findsOneWidget); }); testWidgets('MaterialPage restores its state', (WidgetTester tester) async { await tester.pumpWidget( RootRestorationScope( restorationId: 'root', child: TestDependencies( child: Navigator( onPopPage: (Route<dynamic> route, dynamic result) { return false; }, pages: const <Page<Object?>>[ MaterialPage<void>( restorationId: 'p1', child: TestRestorableWidget(restorationId: 'p1'), ), ], restorationScopeId: 'nav', onGenerateRoute: (RouteSettings settings) { return MaterialPageRoute<void>( settings: settings, builder: (BuildContext context) { return TestRestorableWidget(restorationId: settings.name!); }, ); }, ), ), ), ); expect(find.text('p1'), findsOneWidget); expect(find.text('count: 0'), findsOneWidget); await tester.tap(find.text('increment')); await tester.pump(); expect(find.text('count: 1'), findsOneWidget); tester.state<NavigatorState>(find.byType(Navigator)).restorablePushNamed('p2'); await tester.pumpAndSettle(); expect(find.text('p1'), findsNothing); expect(find.text('p2'), findsOneWidget); await tester.tap(find.text('increment')); await tester.pump(); await tester.tap(find.text('increment')); await tester.pump(); expect(find.text('count: 2'), findsOneWidget); await tester.restartAndRestore(); expect(find.text('p2'), findsOneWidget); expect(find.text('count: 2'), findsOneWidget); tester.state<NavigatorState>(find.byType(Navigator)).pop(); await tester.pumpAndSettle(); expect(find.text('p1'), findsOneWidget); expect(find.text('count: 1'), findsOneWidget); }); } class TransitionDetector extends DefaultTransitionDelegate<void> { bool hasTransition = false; @override Iterable<RouteTransitionRecord> resolve({ required List<RouteTransitionRecord> newPageRouteHistory, required Map<RouteTransitionRecord?, RouteTransitionRecord> locationToExitingPageRoute, required Map<RouteTransitionRecord?, List<RouteTransitionRecord>> pageRouteToPagelessRoutes, }) { hasTransition = true; return super.resolve( newPageRouteHistory: newPageRouteHistory, locationToExitingPageRoute: locationToExitingPageRoute, pageRouteToPagelessRoutes: pageRouteToPagelessRoutes, ); } } Widget buildNavigator({ required List<Page<dynamic>> pages, required PopPageCallback onPopPage, GlobalKey<NavigatorState>? key, TransitionDelegate<dynamic>? transitionDelegate, }) { return MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance.window), child: Localizations( locale: const Locale('en', 'US'), delegates: const <LocalizationsDelegate<dynamic>>[ DefaultMaterialLocalizations.delegate, DefaultWidgetsLocalizations.delegate, ], child: Directionality( textDirection: TextDirection.ltr, child: Navigator( key: key, pages: pages, onPopPage: onPopPage, transitionDelegate: transitionDelegate ?? const DefaultTransitionDelegate<dynamic>(), ), ), ), ); } class KeepsStateTestWidget extends StatefulWidget { const KeepsStateTestWidget({super.key, this.navigatorKey}); final Key? navigatorKey; @override State<KeepsStateTestWidget> createState() => _KeepsStateTestWidgetState(); } class _KeepsStateTestWidgetState extends State<KeepsStateTestWidget> { String? _subpage = 'subpage'; @override Widget build(BuildContext context) { return MaterialApp( home: Navigator( key: widget.navigatorKey, pages: <Page<void>>[ const MaterialPage<void>(child: Text('home')), if (_subpage != null) MaterialPage<void>(child: Text(_subpage!)), ], onPopPage: (Route<dynamic> route, dynamic result) { if (!route.didPop(result)) { return false; } setState(() { _subpage = null; }); return true; }, ), ); } } class TestRestorableWidget extends StatefulWidget { const TestRestorableWidget({super.key, required this.restorationId}); final String restorationId; @override State<StatefulWidget> createState() => _TestRestorableWidgetState(); } class _TestRestorableWidgetState extends State<TestRestorableWidget> with RestorationMixin { @override String? get restorationId => widget.restorationId; final RestorableInt counter = RestorableInt(0); @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(counter, 'counter'); } @override Widget build(BuildContext context) { return Column( children: <Widget>[ Text(widget.restorationId), Text('count: ${counter.value}'), ElevatedButton( onPressed: () { setState(() { counter.value++; }); }, child: const Text('increment'), ), ], ); } } class TestDependencies extends StatelessWidget { const TestDependencies({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context) { return Directionality( textDirection: TextDirection.ltr, child: MediaQuery( data: MediaQueryData.fromWindow(WidgetsBinding.instance.window), child: child, ), ); } }