Unverified Commit 734782f5 authored by chunhtai's avatar chunhtai Committed by GitHub

clean up hero controller scope (#60655)

parent 0fdb21f3
...@@ -293,18 +293,6 @@ class _CupertinoAppState extends State<CupertinoApp> { ...@@ -293,18 +293,6 @@ class _CupertinoAppState extends State<CupertinoApp> {
_heroController = CupertinoApp.createCupertinoHeroController(); _heroController = CupertinoApp.createCupertinoHeroController();
} }
@override
void didUpdateWidget(CupertinoApp oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.navigatorKey != oldWidget.navigatorKey) {
// If the Navigator changes, we have to create a new observer, because the
// old Navigator won't be disposed (and thus won't unregister with its
// observers) until after the new one has been created (because the
// Navigator has a GlobalKey).
_heroController = CupertinoApp.createCupertinoHeroController();
}
}
// Combine the default localization for Cupertino with the ones contributed // Combine the default localization for Cupertino with the ones contributed
// by the localizationsDelegates parameter, if any. Only the first delegate // by the localizationsDelegates parameter, if any. Only the first delegate
// of a particular LocalizationsDelegate.type is loaded so the // of a particular LocalizationsDelegate.type is loaded so the
...@@ -370,7 +358,6 @@ class _CupertinoAppState extends State<CupertinoApp> { ...@@ -370,7 +358,6 @@ class _CupertinoAppState extends State<CupertinoApp> {
}, },
shortcuts: widget.shortcuts, shortcuts: widget.shortcuts,
actions: widget.actions, actions: widget.actions,
), ),
); );
}, },
......
...@@ -586,18 +586,6 @@ class _MaterialAppState extends State<MaterialApp> { ...@@ -586,18 +586,6 @@ class _MaterialAppState extends State<MaterialApp> {
_heroController = MaterialApp.createMaterialHeroController(); _heroController = MaterialApp.createMaterialHeroController();
} }
@override
void didUpdateWidget(MaterialApp oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.navigatorKey != oldWidget.navigatorKey) {
// If the Navigator changes, we have to create a new observer, because the
// old Navigator won't be disposed (and thus won't unregister with its
// observers) until after the new one has been created (because the
// Navigator has a GlobalKey).
_heroController = MaterialApp.createMaterialHeroController();
}
}
// Combine the Localizations for Material with the ones contributed // Combine the Localizations for Material with the ones contributed
// by the localizationsDelegates parameter, if any. Only the first delegate // by the localizationsDelegates parameter, if any. Only the first delegate
// of a particular LocalizationsDelegate.type is loaded so the // of a particular LocalizationsDelegate.type is loaded so the
......
...@@ -624,26 +624,30 @@ class NavigatorObserver { ...@@ -624,26 +624,30 @@ class NavigatorObserver {
/// An inherited widget to host a hero controller. /// An inherited widget to host a hero controller.
/// ///
/// This class should not be used directly. The [MaterialApp] and [CupertinoApp]
/// use this class to host the [HeroController], and they should be the only
/// exception to use this class. If you want to subscribe your own
/// [HeroController], use the [Navigator.observers] instead.
///
/// The hosted hero controller will be picked up by the navigator in the /// The hosted hero controller will be picked up by the navigator in the
/// [child] subtree. Once a navigator picks up this controller, the navigator /// [child] subtree. Once a navigator picks up this controller, the navigator
/// will bar any navigator below its subtree from receiving this controller. /// will bar any navigator below its subtree from receiving this controller.
/// ///
/// See also: /// The hero controller inside the [HeroControllerScope] can only subscribe to
/// /// one navigator at a time. An assertion will be thrown if the hero controller
/// * [Navigator.observers], which is the standard way of providing a /// subscribes to more than one navigators. This can happen when there are
/// [HeroController]. /// multiple navigators under the same [HeroControllerScope] in parallel.
class HeroControllerScope extends InheritedWidget { class HeroControllerScope extends InheritedWidget {
/// Creates a widget to host the input [controller]. /// Creates a widget to host the input [controller].
const HeroControllerScope({ const HeroControllerScope({
Key key, Key key,
this.controller, this.controller,
Widget child, Widget child,
}) : super(key: key, child: child); }) : assert(controller != null),
super(key: key, child: child);
/// Creates a widget to prevent the subtree from receiving the hero controller
/// above.
const HeroControllerScope.none({
Key key,
Widget child,
}) : controller = null,
super(key: key, child: child);
/// The hero controller that is hosted inside this widget. /// The hero controller that is hosted inside this widget.
final HeroController controller; final HeroController controller;
...@@ -2803,8 +2807,30 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -2803,8 +2807,30 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
void _updateHeroController(HeroController newHeroController) { void _updateHeroController(HeroController newHeroController) {
if (_heroControllerFromScope != newHeroController) { if (_heroControllerFromScope != newHeroController) {
_heroControllerFromScope?._navigator = null; if (newHeroController != null) {
newHeroController?._navigator = this; // Makes sure the same hero controller is not shared between two navigators.
assert(() {
// It is possible that the hero controller subscribes to an existing
// navigator. We are fine as long as that navigator gives up the hero
// controller at the end of the build.
if (newHeroController.navigator != null) {
final NavigatorState previousOwner = newHeroController.navigator;
ServicesBinding.instance.addPostFrameCallback((Duration timestamp) {
// We only check if this navigator still owns the hero controller.
if (_heroControllerFromScope == newHeroController) {
assert(_heroControllerFromScope._navigator == this);
assert(previousOwner._heroControllerFromScope != newHeroController);
}
});
}
return true;
}());
newHeroController._navigator = this;
}
// Only unsubscribe the hero controller when it is currently subscribe to
// this navigator.
if (_heroControllerFromScope?._navigator == this)
_heroControllerFromScope?._navigator = null;
_heroControllerFromScope = newHeroController; _heroControllerFromScope = newHeroController;
_updateEffectiveObservers(); _updateEffectiveObservers();
} }
...@@ -2865,6 +2891,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -2865,6 +2891,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
_debugLocked = true; _debugLocked = true;
return true; return true;
}()); }());
_updateHeroController(null);
for (final NavigatorObserver observer in _effectiveObservers) for (final NavigatorObserver observer in _effectiveObservers)
observer._navigator = null; observer._navigator = null;
focusScopeNode.dispose(); focusScopeNode.dispose();
...@@ -4051,7 +4078,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin { ...@@ -4051,7 +4078,7 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
// Hides the HeroControllerScope for the widget subtree so that the other // Hides the HeroControllerScope for the widget subtree so that the other
// nested navigator underneath will not pick up the hero controller above // nested navigator underneath will not pick up the hero controller above
// this level. // this level.
return HeroControllerScope( return HeroControllerScope.none(
child: Listener( child: Listener(
onPointerDown: _handlePointerDown, onPointerDown: _handlePointerDown,
onPointerUp: _handlePointerUpOrCancel, onPointerUp: _handlePointerUpOrCancel,
......
...@@ -131,4 +131,20 @@ void main() { ...@@ -131,4 +131,20 @@ void main() {
expect(find.text('regular page one'), findsNothing); expect(find.text('regular page one'), findsNothing);
expect(find.text('regular page two'), findsNothing); expect(find.text('regular page two'), findsNothing);
}); });
testWidgets('CupertinoApp.navigatorKey can be updated', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
await tester.pumpWidget(CupertinoApp(
navigatorKey: key1,
home: const Placeholder(),
));
expect(key1.currentState, isA<NavigatorState>());
final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
await tester.pumpWidget(CupertinoApp(
navigatorKey: key2,
home: const Placeholder(),
));
expect(key2.currentState, isA<NavigatorState>());
expect(key1.currentState, isNull);
});
} }
...@@ -843,6 +843,22 @@ void main() { ...@@ -843,6 +843,22 @@ void main() {
); );
expect(tween, isA<MaterialRectArcTween>()); expect(tween, isA<MaterialRectArcTween>());
}); });
testWidgets('MaterialApp.navigatorKey can be updated', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(
navigatorKey: key1,
home: const Placeholder(),
));
expect(key1.currentState, isA<NavigatorState>());
final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
await tester.pumpWidget(MaterialApp(
navigatorKey: key2,
home: const Placeholder(),
));
expect(key2.currentState, isA<NavigatorState>());
expect(key1.currentState, isNull);
});
} }
class MockAccessibilityFeature implements AccessibilityFeatures { class MockAccessibilityFeature implements AccessibilityFeatures {
......
...@@ -304,6 +304,103 @@ Future<void> main() async { ...@@ -304,6 +304,103 @@ Future<void> main() async {
expect(find.byKey(thirdKey), isInCard); expect(find.byKey(thirdKey), isInCard);
}); });
testWidgets('Heroes still animate after hero controller is swapped.', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key = GlobalKey<NavigatorState>();
final UniqueKey heroKey = UniqueKey();
await tester.pumpWidget(
HeroControllerScope(
controller: HeroController(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
key: key,
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return Hero(
tag: 'hero',
child: Container(),
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return Container(key: heroKey);
},
);
},
settings: s,
);
},
),
),
),
);
key.currentState.push(MaterialPageRoute<void>(
builder: (BuildContext c) {
return Hero(
tag: 'hero',
child: Container(),
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return Container(key: heroKey);
},
);
},
));
expect(find.byKey(heroKey), findsNothing);
// Begins the navigation
await tester.pump();
await tester.pump(const Duration(milliseconds: 30));
expect(find.byKey(heroKey), isOnstage);
// Pumps a new hero controller.
await tester.pumpWidget(
HeroControllerScope(
controller: HeroController(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
key: key,
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return Hero(
tag: 'hero',
child: Container(),
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return Container(key: heroKey);
},
);
},
settings: s,
);
},
),
),
),
);
// The original animation still flies.
expect(find.byKey(heroKey), isOnstage);
// Waits for the animation finishes.
await tester.pumpAndSettle();
expect(find.byKey(heroKey), findsNothing);
});
testWidgets('Heroes animate should hide original hero', (WidgetTester tester) async { testWidgets('Heroes animate should hide original hero', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(routes: routes)); await tester.pumpWidget(MaterialApp(routes: routes));
// Checks initial state. // Checks initial state.
......
...@@ -2064,6 +2064,258 @@ void main() { ...@@ -2064,6 +2064,258 @@ void main() {
expect(observations[1].previous, 'top1'); expect(observations[1].previous, 'top1');
}); });
testWidgets('hero controller can correctly transfer subscription - replacing navigator', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
final List<NavigatorObservation> observations = <NavigatorObservation>[];
final HeroControllerSpy spy = HeroControllerSpy()
..onPushed = (Route<dynamic> route, Route<dynamic> previousRoute) {
observations.add(
NavigatorObservation(
current: route?.settings?.name,
previous: previousRoute?.settings?.name,
operation: 'didPush'
)
);
};
await tester.pumpWidget(
HeroControllerScope(
controller: spy,
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
key: key1,
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
),
)
)
);
// Transfer the subscription to another navigator
await tester.pumpWidget(
HeroControllerScope(
controller: spy,
child: Directionality(
textDirection: TextDirection.ltr,
child: Navigator(
key: key2,
initialRoute: 'navigator2',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
),
)
)
);
observations.clear();
key2.currentState.push(MaterialPageRoute<void>(
settings: const RouteSettings(name:'new route'),
builder: (BuildContext context) => const Text('new route')
));
await tester.pumpAndSettle();
expect(find.text('new route'), findsOneWidget);
// It should record from the new navigator.
expect(observations.length, 1);
expect(observations[0].current, 'new route');
expect(observations[0].previous, 'navigator2');
});
testWidgets('hero controller can correctly transfer subscription - swapping navigator', (WidgetTester tester) async {
final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
final List<NavigatorObservation> observations1 = <NavigatorObservation>[];
final HeroControllerSpy spy1 = HeroControllerSpy()
..onPushed = (Route<dynamic> route, Route<dynamic> previousRoute) {
observations1.add(
NavigatorObservation(
current: route?.settings?.name,
previous: previousRoute?.settings?.name,
operation: 'didPush'
)
);
};
final List<NavigatorObservation> observations2 = <NavigatorObservation>[];
final HeroControllerSpy spy2 = HeroControllerSpy()
..onPushed = (Route<dynamic> route, Route<dynamic> previousRoute) {
observations2.add(
NavigatorObservation(
current: route?.settings?.name,
previous: previousRoute?.settings?.name,
operation: 'didPush'
)
);
};
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
HeroControllerScope(
controller: spy1,
child: Navigator(
key: key1,
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
)
),
HeroControllerScope(
controller: spy2,
child: Navigator(
key: key2,
initialRoute: 'navigator2',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
)
),
],
),
),
);
expect(observations1.length, 1);
expect(observations1[0].current, 'navigator1');
expect(observations1[0].previous, isNull);
expect(observations2.length, 1);
expect(observations2[0].current, 'navigator2');
expect(observations2[0].previous, isNull);
// Swaps the spies.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
HeroControllerScope(
controller: spy2,
child: Navigator(
key: key1,
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
)
),
HeroControllerScope(
controller: spy1,
child: Navigator(
key: key2,
initialRoute: 'navigator2',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
)
),
],
),
),
);
// Pushes a route to navigator2.
key2.currentState.push(MaterialPageRoute<void>(
settings: const RouteSettings(name:'new route2'),
builder: (BuildContext context) => const Text('new route2')
));
await tester.pumpAndSettle();
expect(find.text('new route2'), findsOneWidget);
// The spy1 should record the push in navigator2.
expect(observations1.length, 2);
expect(observations1[1].current, 'new route2');
expect(observations1[1].previous, 'navigator2');
// The spy2 should not record anything.
expect(observations2.length, 1);
// Pushes a route to navigator1
key1.currentState.push(MaterialPageRoute<void>(
settings: const RouteSettings(name:'new route1'),
builder: (BuildContext context) => const Text('new route1')
));
await tester.pumpAndSettle();
expect(find.text('new route1'), findsOneWidget);
// The spy1 should not record anything.
expect(observations1.length, 2);
// The spy2 should record the push in navigator1.
expect(observations2.length, 2);
expect(observations2[1].current, 'new route1');
expect(observations2[1].previous, 'navigator1');
});
testWidgets('hero controller subscribes to multiple navigators does throw', (WidgetTester tester) async {
final HeroControllerSpy spy = HeroControllerSpy();
await tester.pumpWidget(
HeroControllerScope(
controller: spy,
child: Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
Navigator(
initialRoute: 'navigator1',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
),
Navigator(
initialRoute: 'navigator2',
onGenerateRoute: (RouteSettings s) {
return MaterialPageRoute<void>(
builder: (BuildContext c) {
return const Placeholder();
},
settings: s,
);
},
),
],
),
),
),
);
expect(tester.takeException(), isAssertionError);
});
group('Page api', (){ group('Page api', (){
Widget buildNavigator({ Widget buildNavigator({
List<Page<dynamic>> pages, List<Page<dynamic>> pages,
......
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