// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 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 Android page transition', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.android), home: const Material(child: Text('Page 1')), 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')).ancestorWidgetOfExactType(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')).ancestorWidgetOfExactType(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); }); testWidgets('test iOS page transition', (WidgetTester tester) async { final Key page2Key = UniqueKey(); await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), 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)) .ancestorRenderObjectOfType(const TypeMatcher<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); // The shadow should be drawn to one screen width to the left of where // the page 2 box is. `paints` tests relative to the painter's given canvas // rather than relative to the screen so assert that it's one screen // width to the left of 0 offset box rect and nothing is drawn inside the // box's rect. expect(box, paints..rect( rect: const Rect.fromLTWH(-800.0, 0.0, 800.0, 600.0) )); 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); }); testWidgets('test iOS fullscreen dialog transition', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), home: const 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); }); testWidgets('test no back gesture on Android', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.android), 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); }); testWidgets('test back gesture on iOS', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), 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)); }); testWidgets('back gesture while OS changes', (WidgetTester tester) async { final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ '/': (BuildContext context) => Material( child: FlatButton( child: const Text('PUSH'), onPressed: () { Navigator.of(context).pushNamed('/b'); }, ), ), '/b': (BuildContext context) => Container(child: 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)), 3); 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); }); testWidgets('test no back gesture on iOS fullscreen dialogs', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), home: const 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); }); testWidgets('test adaptable transitions switch during execution', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.android), 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( theme: ThemeData(platform: TargetPlatform.iOS), 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); }); testWidgets('throws when builder returns null', (WidgetTester tester) async { await tester.pumpWidget(const MaterialApp( home: Text('Home'), )); // No exceptions yet. expect(tester.takeException(), isNull); tester .state<NavigatorState>(find.byType(Navigator)) .push(MaterialPageRoute<void>( settings: const RouteSettings(name: 'broken'), builder: (BuildContext context) => null, )); await tester.pumpAndSettle(); // An exception should've been thrown because the `builder` returned null. expect(tester.takeException(), isInstanceOf<FlutterError>()); }); testWidgets('test iOS edge swipe then drop back at starting point works', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), 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); }); testWidgets('test iOS edge swipe then drop back at ending point works', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.iOS), 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); }); 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( theme: ThemeData(platform: TargetPlatform.iOS), home: Scaffold( key: scaffoldKey, body: Center( child: RaisedButton( 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), ); await tester.tap(find.text('push')); await tester.pumpAndSettle(); expect(find.text('route'), findsOneWidget); expect(find.text('push'), findsNothing); }); }