In Flutter, why do Heroes pushed inside a nested Navigator not fly, and how do HeroController and HeroControllerScope fix it?
answer
- a NavigatorObserver per Navigator
- MaterialApp's scope serves one Navigator
- the root claims it and bars the rest
- HeroControllerScope around the nested one
- one controller cannot serve two
basics
~20 sHero flights are started by a HeroController observing one Navigator. MaterialApp's HeroControllerScope is claimed by the root Navigator, which bars Navigators below it, so a nested Navigator has none. Give it its own controller through HeroControllerScope or observers.
solid answer
~40 sA `HeroController` is a `NavigatorObserver`: it only sees route changes on the Navigator it observes. `MaterialApp` and `CupertinoApp` put one in a `HeroControllerScope` above their root Navigator. The first Navigator below the scope claims that controller and bars Navigators in its subtree from receiving it - so a nested Navigator, such as one per tab of a furniture store's shop section, has no hero controller, and pushes inside it never fly. Wrap the nested Navigator in `HeroControllerScope(controller: MaterialApp.createMaterialHeroController(), child: Navigator(...))`, or pass a `HeroController` in its `observers`. A controller can serve only one Navigator; sharing one between parallel Navigators reports *A HeroController can not be shared by multiple Navigators*. A controller you create yourself must be disposed.
code
dart · 35 linesimport 'package:flutter/material.dart';
class ShopTab extends StatefulWidget {
const ShopTab({super.key, required this.onGenerateRoute});
final RouteFactory onGenerateRoute;
@override
State<ShopTab> createState() => _ShopTabState();
}
class _ShopTabState extends State<ShopTab> {
late final HeroController _heroController;
@override
void initState() {
super.initState();
_heroController = MaterialApp.createMaterialHeroController();
}
@override
void dispose() {
_heroController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The root Navigator already claimed MaterialApp's controller and bars
// Navigators below it, so this nested one gets a controller of its own.
return HeroControllerScope(
controller: _heroController,
child: Navigator(onGenerateRoute: widget.onGenerateRoute),
);
}
}go deeper
Recall that MaterialApp sets up hero flights for its main Navigator, and that a Navigator you create yourself may need its own setup.
Explain that HeroController is a NavigatorObserver, and how HeroControllerScope hands one controller to the first Navigator and bars those below it.
Wire a HeroController per nested Navigator with correct ownership and disposal, and tell apart root-level flights, which work, from in-tab flights, which need setup.
Decide how an app's shell and tab navigators are structured so shared-element transitions are available where the product wants them, without controllers leaking.
## Who starts a hero flight Hero widgets do nothing by themselves. Flights are started by a **`HeroController`**, which extends **`NavigatorObserver`**. When the Navigator it observes changes its top route, the controller collects `Hero` widgets on the outgoing and incoming `PageRoute`s, pairs them by tag, and flies each pair in that Navigator's `Overlay`. A Navigator **without** a hero controller changes routes perfectly well - it just never flies heroes. ## How MaterialApp provides one `MaterialApp` creates a controller with `MaterialApp.createMaterialHeroController()` (which uses a `MaterialRectArcTween` path) and places it in a **`HeroControllerScope`** - an inherited widget - above its root Navigator. `CupertinoApp` does the same with a linear controller. The scope's rules, from the framework: 1. A Navigator below the scope **picks up** its controller. 2. Once a Navigator has picked it up, it **bars any Navigator below its subtree** from receiving the same controller. 3. A controller can subscribe to **only one** Navigator at a time; two Navigators in parallel under one scope trigger the error *A HeroController can not be shared by multiple Navigators*, which suggests a `HeroControllerScope` per Navigator or `HeroControllerScope.none` to opt a subtree out. So in an app with a root Navigator and, inside one of its pages, a nested `Navigator` - one per bottom-navigation tab, say - the root takes the app's controller and the nested one gets nothing. ## Two different nested-navigator cases | Transition | Which controller runs it | Works by default? | |---|---|---| | Push on the **root** Navigator, with heroes inside a nested Navigator's current page | the root's controller | yes - heroes in the top-most `PageRoute` of a nested Navigator are included | | Push **inside** the nested Navigator (tab list to tab detail) | the nested Navigator's own controller | only if you provide one | The first case is handled by the root controller's collection logic: when it meets a `Hero` whose nearest Navigator is not the transitioning one, it still includes it if the hero's route is current and a `PageRoute`. The second case is the one that silently fails. ## The fix ```dart HeroControllerScope( controller: _heroController, // created in initState, disposed in dispose child: Navigator( key: _shopNavigatorKey, onGenerateRoute: _onGenerateShopRoute, ), ) ``` Notes: - Create the controller **once** (for example in `initState`) with `MaterialApp.createMaterialHeroController()` to keep the arc path, or `HeroController()` for a linear one. - **Dispose** it in the owning `State`'s `dispose`; `HeroController` has a `dispose()` method that releases in-flight resources. - Passing the controller in `Navigator(observers: [...])` is the older, equivalent route; the scope form keeps it out of the observer list and makes ownership explicit. - Two sibling tab Navigators need **two** controllers, never one shared instance. ## Routing packages Routing packages that build nested Navigators for tabbed shells apply the same rule: each nested Navigator needs a controller of its own. How a given package wires that - for example through an observers list on its shell route - is part of that package's API rather than Flutter's hero system. ## What interviewers listen for - That a hero flight belongs to a **Navigator**, not to the app. - The *root claims, bars descendants* rule of `HeroControllerScope`. - One controller per Navigator, created once and disposed. ## Diagnosing it The symptom is specific: flights work when a page is pushed over the whole app, but not when a page is pushed inside a tab. That split points straight at controller wiring rather than at tags or timing. Confirm it by checking which Navigator the push goes to - `Navigator.of(context)` from inside a tab returns the nested one - and whether that Navigator sits under a `HeroControllerScope` of its own or lists a `HeroController` in `observers`.
- Why not reuse MaterialApp's HeroController for the nested Navigator?A `HeroController` can observe only one Navigator. The root Navigator already holds MaterialApp's controller, and the scope rules bar Navigators below it from receiving the same one. Forcing it onto a second Navigator reports that a HeroController cannot be shared by multiple Navigators. Each Navigator needs its own instance.
- A hero on the current page of a nested tab flies when the root Navigator pushes a full-screen page. Why does that case work without extra setup?That transition happens on the root Navigator, whose controller runs it. While collecting heroes, the root controller includes a `Hero` from a nested Navigator if that hero's route is the nested Navigator's current route and is a `PageRoute`. Only pushes inside the nested Navigator need its own controller.
saying these in an interview costs you the question
- MaterialApp gives every Navigator in the app its own HeroController.
- One HeroController can be shared by several sibling Navigators.
- Heroes fly by themselves without any controller observing the Navigator.
- A HeroController created in a State needs no dispose call.
- Heroes inside a nested Navigator can never take part in a root-level transition.