skip to content

In Flutter, why do Heroes pushed inside a nested Navigator not fly, and how do HeroController and HeroControllerScope fix it?

level: seniorimportance: nice to knowfreq 22%

answer

  1. a NavigatorObserver per Navigator
  2. MaterialApp's scope serves one Navigator
  3. the root claims it and bars the rest
  4. HeroControllerScope around the nested one
  5. one controller cannot serve two

basics

~20 s

Hero 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 s

A `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 lines
dart
import '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

for a junior

Recall that MaterialApp sets up hero flights for its main Navigator, and that a Navigator you create yourself may need its own setup.

for a middle

Explain that HeroController is a NavigatorObserver, and how HeroControllerScope hands one controller to the first Navigator and bars those below it.

for a senior

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.

for a principal

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.