Unverified Commit 97b2c986 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Navigator pushAndRemoveUntil Fix (#35223)

* Initial work

* ++

* Updated tests

* Moved TestObserver out for access across tests.

* ++

* Added hero tests

* Review feedback

* simplified preceding route overlay

* Review feedback

* trailing doc slash
parent 41c7f0a9
...@@ -1154,14 +1154,14 @@ class Navigator extends StatefulWidget { ...@@ -1154,14 +1154,14 @@ class Navigator extends StatefulWidget {
/// The removed routes are removed without being completed, so this method /// The removed routes are removed without being completed, so this method
/// does not take a return value argument. /// does not take a return value argument.
/// ///
/// The new route and the route below the bottommost removed route (which /// The newly pushed route and its preceding route are notified for
/// becomes the route below the new route) are notified (see [Route.didPush] /// [Route.didPush]. After removal, the new route and its new preceding route,
/// and [Route.didChangeNext]). If the [Navigator] has any /// (the route below the bottommost removed route) are notified through
/// [Navigator.observers], they will be notified as well (see /// [Route.didChangeNext]). If the [Navigator] has any [Navigator.observers],
/// [NavigatorObservers.didPush] and [NavigatorObservers.didRemove]). The /// they will be notified as well (see [NavigatorObservers.didPush] and
/// removed routes are disposed, without being notified, once the new route /// [NavigatorObservers.didRemove]). The removed routes are disposed of and
/// has finished animating. The futures that had been returned from pushing /// notified, once the new route has finished animating. The futures that had
/// those routes will not complete. /// been returned from pushing those routes will not complete.
/// ///
/// Ongoing gestures within the current route are canceled when a new route is /// Ongoing gestures within the current route are canceled when a new route is
/// pushed. /// pushed.
...@@ -1195,7 +1195,7 @@ class Navigator extends StatefulWidget { ...@@ -1195,7 +1195,7 @@ class Navigator extends StatefulWidget {
/// context with a new route. /// context with a new route.
/// ///
/// {@template flutter.widgets.navigator.replace} /// {@template flutter.widgets.navigator.replace}
/// The old route must not be current visible, as this method skips the /// The old route must not be currently visible, as this method skips the
/// animations and therefore the removal would be jarring if it was visible. /// animations and therefore the removal would be jarring if it was visible.
/// To replace the top-most route, consider [pushReplacement] instead, which /// To replace the top-most route, consider [pushReplacement] instead, which
/// _does_ animate the new route, and delays removing the old route until the /// _does_ animate the new route, and delays removing the old route until the
...@@ -1881,6 +1881,12 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -1881,6 +1881,12 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
Future<T> pushAndRemoveUntil<T extends Object>(Route<T> newRoute, RoutePredicate predicate) { Future<T> pushAndRemoveUntil<T extends Object>(Route<T> newRoute, RoutePredicate predicate) {
assert(!_debugLocked); assert(!_debugLocked);
assert(() { _debugLocked = true; return true; }()); assert(() { _debugLocked = true; return true; }());
// The route that is being pushed on top of
final Route<dynamic> precedingRoute = _history.isNotEmpty ? _history.last : null;
final OverlayEntry precedingRouteOverlay = _currentOverlayEntry;
// Routes to remove
final List<Route<dynamic>> removedRoutes = <Route<dynamic>>[]; final List<Route<dynamic>> removedRoutes = <Route<dynamic>>[];
while (_history.isNotEmpty && !predicate(_history.last)) { while (_history.isNotEmpty && !predicate(_history.last)) {
final Route<dynamic> removedRoute = _history.removeLast(); final Route<dynamic> removedRoute = _history.removeLast();
...@@ -1888,26 +1894,33 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -1888,26 +1894,33 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
assert(removedRoute.overlayEntries.isNotEmpty); assert(removedRoute.overlayEntries.isNotEmpty);
removedRoutes.add(removedRoute); removedRoutes.add(removedRoute);
} }
// Push new route
assert(newRoute._navigator == null); assert(newRoute._navigator == null);
assert(newRoute.overlayEntries.isEmpty); assert(newRoute.overlayEntries.isEmpty);
final Route<dynamic> oldRoute = _history.isNotEmpty ? _history.last : null; final Route<dynamic> newPrecedingRoute = _history.isNotEmpty ? _history.last : null;
newRoute._navigator = this; newRoute._navigator = this;
newRoute.install(_currentOverlayEntry); newRoute.install(precedingRouteOverlay);
_history.add(newRoute); _history.add(newRoute);
newRoute.didPush().whenCompleteOrCancel(() { newRoute.didPush().whenCompleteOrCancel(() {
if (mounted) { if (mounted) {
for (Route<dynamic> route in removedRoutes) for (Route<dynamic> removedRoute in removedRoutes) {
route.dispose(); for (NavigatorObserver observer in widget.observers)
observer.didRemove(removedRoute, newPrecedingRoute);
removedRoute.dispose();
}
if (newPrecedingRoute != null)
newPrecedingRoute.didChangeNext(newRoute);
} }
}); });
// Notify for newRoute
newRoute.didChangeNext(null); newRoute.didChangeNext(null);
if (oldRoute != null) for (NavigatorObserver observer in widget.observers)
oldRoute.didChangeNext(newRoute); observer.didPush(newRoute, precedingRoute);
for (NavigatorObserver observer in widget.observers) {
observer.didPush(newRoute, oldRoute);
for (Route<dynamic> removedRoute in removedRoutes)
observer.didRemove(removedRoute, oldRoute);
}
assert(() { _debugLocked = false; return true; }()); assert(() { _debugLocked = false; return true; }());
_afterNavigation(newRoute); _afterNavigation(newRoute);
return newRoute.popped; return newRoute.popped;
......
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'observer_tester.dart';
void main() { void main() {
testWidgets('Back during pushReplacement', (WidgetTester tester) async { testWidgets('Back during pushReplacement', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
...@@ -39,42 +41,185 @@ void main() { ...@@ -39,42 +41,185 @@ void main() {
expect(find.text('home'), findsOneWidget); expect(find.text('home'), findsOneWidget);
}); });
testWidgets('pushAndRemoveUntil', (WidgetTester tester) async { group('pushAndRemoveUntil', () {
await tester.pumpWidget(MaterialApp( testWidgets('notifies appropriately', (WidgetTester tester) async {
home: const Material(child: Text('home')), final TestObserver observer = TestObserver();
routes: <String, WidgetBuilder>{ final Widget myApp = MaterialApp(
'/a': (BuildContext context) => const Material(child: Text('a')), home: const Material(child: Text('home')),
'/b': (BuildContext context) => const Material(child: Text('b')), routes: <String, WidgetBuilder>{
}, '/a': (BuildContext context) => const Material(child: Text('a')),
)); '/b': (BuildContext context) => const Material(child: Text('b')),
},
final NavigatorState navigator = tester.state(find.byType(Navigator)); navigatorObservers: <NavigatorObserver>[observer],
navigator.pushNamed('/a'); );
await tester.pumpAndSettle();
await tester.pumpWidget(myApp);
expect(find.text('home', skipOffstage: false), findsOneWidget);
expect(find.text('a', skipOffstage: false), findsOneWidget); final NavigatorState navigator = tester.state(find.byType(Navigator));
expect(find.text('b', skipOffstage: false), findsNothing); final List<String> log = <String>[];
observer
navigator.pushNamedAndRemoveUntil('/b', (Route<dynamic> route) => false); ..onPushed = (Route<dynamic> route, Route<dynamic> previousRoute) {
await tester.pumpAndSettle(); log.add('${route.settings.name} pushed, previous route: ${previousRoute.settings.name}');
}
expect(find.text('home', skipOffstage: false), findsNothing); ..onRemoved = (Route<dynamic> route, Route<dynamic> previousRoute) {
expect(find.text('a', skipOffstage: false), findsNothing); log.add('${route.settings.name} removed, previous route: ${previousRoute?.settings?.name}');
expect(find.text('b', skipOffstage: false), findsOneWidget); };
navigator.pushNamed('/');
await tester.pumpAndSettle(); navigator.pushNamed('/a');
await tester.pumpAndSettle();
expect(find.text('home', skipOffstage: false), findsOneWidget);
expect(find.text('a', skipOffstage: false), findsNothing); expect(find.text('home', skipOffstage: false), findsOneWidget);
expect(find.text('b', skipOffstage: false), findsOneWidget); expect(find.text('a', skipOffstage: false), findsOneWidget);
expect(find.text('b', skipOffstage: false), findsNothing);
navigator.pushNamedAndRemoveUntil('/a', ModalRoute.withName('/b'));
await tester.pumpAndSettle(); // Remove all routes below
navigator.pushNamedAndRemoveUntil('/b', (Route<dynamic> route) => false);
expect(find.text('home', skipOffstage: false), findsNothing); await tester.pumpAndSettle();
expect(find.text('a', skipOffstage: false), findsOneWidget);
expect(find.text('b', skipOffstage: false), findsOneWidget); expect(find.text('home', skipOffstage: false), findsNothing);
expect(find.text('a', skipOffstage: false), findsNothing);
expect(find.text('b', skipOffstage: false), findsOneWidget);
expect(log, equals(<String>[
'/a pushed, previous route: /',
'/b pushed, previous route: /a',
'/a removed, previous route: null',
'/ removed, previous route: null',
]));
log.clear();
navigator.pushNamed('/');
await tester.pumpAndSettle();
expect(find.text('home', skipOffstage: false), findsOneWidget);
expect(find.text('a', skipOffstage: false), findsNothing);
expect(find.text('b', skipOffstage: false), findsOneWidget);
// Remove only some routes below
navigator.pushNamedAndRemoveUntil('/a', ModalRoute.withName('/b'));
await tester.pumpAndSettle();
expect(find.text('home', skipOffstage: false), findsNothing);
expect(find.text('a', skipOffstage: false), findsOneWidget);
expect(find.text('b', skipOffstage: false), findsOneWidget);
expect(log, equals(<String>[
'/ pushed, previous route: /b',
'/a pushed, previous route: /',
'/ removed, previous route: /b',
]));
});
testWidgets('triggers page transition animation for pushed route', (WidgetTester tester) async {
final Widget myApp = MaterialApp(
home: const Material(child: Text('home')),
routes: <String, WidgetBuilder>{
'/a': (BuildContext context) => const Material(child: Text('a')),
'/b': (BuildContext context) => const Material(child: Text('b')),
},
);
await tester.pumpWidget(myApp);
final NavigatorState navigator = tester.state(find.byType(Navigator));
navigator.pushNamed('/a');
await tester.pumpAndSettle();
navigator.pushNamedAndRemoveUntil('/b', (Route<dynamic> route) => false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// We are mid-transition, both pages are onstage
expect(find.text('a'), findsOneWidget);
expect(find.text('b'), findsOneWidget);
// Complete transition
await tester.pumpAndSettle();
expect(find.text('a'), findsNothing);
expect(find.text('b'), findsOneWidget);
});
testWidgets('Hero transition triggers when preceding route contains hero, and predicate route does not', (WidgetTester tester) async {
const String kHeroTag = 'hero';
final Widget myApp = MaterialApp(
initialRoute: '/',
routes: <String, WidgetBuilder>{
'/': (BuildContext context) => const Material(child: Text('home')),
'/a': (BuildContext context) => const Material(child: Hero(
tag: kHeroTag,
child: Text('a'),
)),
'/b': (BuildContext context) => const Material(child: Padding(
padding: EdgeInsets.all(100.0),
child: Hero(
tag: kHeroTag,
child: Text('b'),
),
)),
},
);
await tester.pumpWidget(myApp);
final NavigatorState navigator = tester.state(find.byType(Navigator));
navigator.pushNamed('/a');
await tester.pumpAndSettle();
navigator.pushNamedAndRemoveUntil('/b', ModalRoute.withName('/'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(find.text('b'), isOnstage);
// 'b' text is heroing to its new location
final Offset bOffset = tester.getTopLeft(find.text('b'));
expect(bOffset.dx, greaterThan(0.0));
expect(bOffset.dx, lessThan(100.0));
expect(bOffset.dy, greaterThan(0.0));
expect(bOffset.dy, lessThan(100.0));
await tester.pump(const Duration(seconds: 1));
expect(find.text('a'), findsNothing);
expect(find.text('b'), isOnstage);
});
testWidgets('Hero transition does not trigger when preceding route does not contain hero, but predicate route does', (WidgetTester tester) async {
const String kHeroTag = 'hero';
final Widget myApp = MaterialApp(
initialRoute: '/',
routes: <String, WidgetBuilder>{
'/': (BuildContext context) => const Material(child: Hero(
tag:kHeroTag,
child: Text('home'),
)),
'/a': (BuildContext context) => const Material(child: Text('a')),
'/b': (BuildContext context) => const Material(child: Padding(
padding: EdgeInsets.all(100.0),
child: Hero(
tag: kHeroTag,
child: Text('b'),
),
)),
},
);
await tester.pumpWidget(myApp);
final NavigatorState navigator = tester.state(find.byType(Navigator));
navigator.pushNamed('/a');
await tester.pumpAndSettle();
navigator.pushNamedAndRemoveUntil('/b', ModalRoute.withName('/'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 16));
expect(find.text('b'), isOnstage);
// 'b' text is sliding in from the right, no hero transition
final Offset bOffset = tester.getTopLeft(find.text('b'));
expect(bOffset.dx, 100.0);
expect(bOffset.dy, greaterThan(100.0));
});
}); });
} }
...@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; ...@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'observer_tester.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
class FirstWidget extends StatelessWidget { class FirstWidget extends StatelessWidget {
...@@ -90,48 +91,6 @@ class OnTapPage extends StatelessWidget { ...@@ -90,48 +91,6 @@ class OnTapPage extends StatelessWidget {
} }
} }
typedef OnObservation = void Function(Route<dynamic> route, Route<dynamic> previousRoute);
class TestObserver extends NavigatorObserver {
OnObservation onPushed;
OnObservation onPopped;
OnObservation onRemoved;
OnObservation onReplaced;
OnObservation onStartUserGesture;
@override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onPushed != null) {
onPushed(route, previousRoute);
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onPopped != null) {
onPopped(route, previousRoute);
}
}
@override
void didRemove(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onRemoved != null)
onRemoved(route, previousRoute);
}
@override
void didReplace({ Route<dynamic> oldRoute, Route<dynamic> newRoute }) {
if (onReplaced != null)
onReplaced(newRoute, oldRoute);
}
@override
void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onStartUserGesture != null)
onStartUserGesture(route, previousRoute);
}
}
void main() { void main() {
testWidgets('Can navigator navigate to and from a stateful widget', (WidgetTester tester) async { testWidgets('Can navigator navigate to and from a stateful widget', (WidgetTester tester) async {
final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{ final Map<String, WidgetBuilder> routes = <String, WidgetBuilder>{
......
// Copyright 2019 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';
typedef OnObservation = void Function(Route<dynamic> route, Route<dynamic> previousRoute);
/// A trivial observer for testing the navigator.
class TestObserver extends NavigatorObserver {
OnObservation onPushed;
OnObservation onPopped;
OnObservation onRemoved;
OnObservation onReplaced;
OnObservation onStartUserGesture;
@override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onPushed != null) {
onPushed(route, previousRoute);
}
}
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onPopped != null) {
onPopped(route, previousRoute);
}
}
@override
void didRemove(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onRemoved != null)
onRemoved(route, previousRoute);
}
@override
void didReplace({ Route<dynamic> oldRoute, Route<dynamic> newRoute }) {
if (onReplaced != null)
onReplaced(newRoute, oldRoute);
}
@override
void didStartUserGesture(Route<dynamic> route, Route<dynamic> previousRoute) {
if (onStartUserGesture != null)
onStartUserGesture(route, previousRoute);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment