navigation_node 0.3.0 copy "navigation_node: ^0.3.0" to clipboard
navigation_node: ^0.3.0 copied to clipboard

A nested Navigator for Flutter: dialogs, sheets and pushed routes stay inside the screen that opened them, and the system back reaches the innermost one first.

navigation_node #

pub version license

A nested Navigator for Flutter. It puts a navigator in the middle of the tree, and a dialog, a bottom sheet or a screen pushed through it is built below that point rather than above the whole application — so everything the screen put over its own subtree is still among the route's ancestors. The system back reaches the innermost node first.

NavigationNode(child: const ScreenBody())

One widget, no configuration, nothing to set up at the top of the application.

What it is for #

A screen usually puts something over its own content: a controller, a form's state, a repository, an InheritedWidget, whatever a state-management package of your choice provides. A route pushed on the application's navigator is built next to that screen and not under it, so none of it is among the route's ancestors — the dialog you opened from the screen cannot read the screen's own state:

MaterialApp
└─ Navigator
   ├─ Screen
   │  └─ TicketScope
   │     └─ ScreenBody
   └─ Dialog             ← pushed here: TicketScope is not an ancestor

Put a node on the screen, push through it, and the route lands inside:

MaterialApp
└─ Navigator
   └─ Screen
      └─ TicketScope
         └─ NavigationNode
            ├─ ScreenBody
            └─ Dialog    ← pushed here: TicketScope is an ancestor

The other half is the system back button. A route asks its PopEntrys before it closes, and a node is one: back closes what the node has open before it touches the route around it, and a node per tab or per screen keeps that local.

Using it #

The node goes below whatever the screen puts over its content, and above the content itself:

TicketScope(
  ticket: ticket,
  child: const NavigationNode(child: ScreenBody()),
)

From anywhere inside ScreenBody, push and open as usual:

// The dialog is built below the screen, so it reads what the screen put there.
showDialog<void>(
  context: context,
  useRootNavigator: false,
  builder: (context) => TicketDetails(TicketScope.of(context).ticket),
);

// A pushed route lands inside the node as well, ancestors and all.
Navigator.of(context).push(
  MaterialPageRoute<void>(builder: (context) => const Details()),
);

useRootNavigator: false is the one thing to remember. showDialog, showModalBottomSheet and their neighbours go to the navigator of the application by default, and a route built there is next to your screen rather than under it — which is the very thing a node exists to avoid.

The example shows all of this running, in ten lessons, with a journal that says what answered each back press.

The parameters #

child the subtree the nested navigator shows first required
onPop answers a pop the node's route is asked about null
isRoot keeps a pop it cannot handle instead of forwarding it false
enabled whether the node takes part in its route's system back true
navigatorKey reaches NodeNavigatorState from outside null
observedFromAbove inherits the observers of the navigator above true
observers observers for this node alone const []
restorationScopeId keeps the stack inside across a restart null

Each of them is documented in full in the API reference; what follows is what you need before reaching for it.

The system back #

System back first asks the node's nested navigator to close its top route — and "route" includes what a Drawer or a showBottomSheet puts on the page without pushing anything, so a node takes none of that away and stands aside for a press that closes one. Only when that navigator has nothing left to pop do onPop and isRoot decide what happens outside the node.

onPop returns true to let the pop through, false to keep the route, or a Future<bool> to decide after asking something — a confirmation dialog, typically. The context it is given is one from inside the node, so showDialog(useRootNavigator: false) puts that dialog in the node, below everything the node stands under. An asynchronous hook is asked about one press at a time, and an answer that arrives after the route has moved on takes nothing. Anything that falls over on the way is reported through FlutterError.reportError, and the press is simply not acted on.

onPop answers a pop the route is asked about: Navigator.maybePop(), the back arrow of an AppBar above the node, the system back. A Navigator.pop() of a route inside the node does not reach it — that takes the route rather than asking it. So a button of your own that has to go through the hook wants maybePop; one that must leave without being asked wants Navigator.of(context).previous?.pop(), where previous is PreviousNavigatorExtension on any navigator.

A node never empties itself. Navigator.pop() on its first page — from the back arrow of an AppBar, say — leaves the node instead of taking that page away: an ordinary node hands the pop to the navigator above it, as often as it is asked, and a root node keeps it and does nothing. That arrow is drawn on purpose: the page is the first route of its own navigator, so nothing about that navigator implies a way back, and a root node draws no arrow there at all.

Nor does a node empty the navigator above, or overrule it. Handing a pop over is asking, not taking: a PopScope the application put around the node is answered by the application, not walked past, and a node on the first route of the application has nothing to hand a pop to. isRoot is still worth setting there: it says so at the node rather than leaving it to be discovered from the stack. On a root node the hook is asked as it is anywhere else, and an answer of true still takes nothing — such a hook is there for the press itself, a "press again to exit", or a SystemNavigator.pop() the application makes on its own terms.

Named routes #

Named routes work inside a node, and they land inside it. The nested navigator borrows the route table of the navigator above — MaterialApp.routes, or its onGenerateRoute — so Navigator.of(context).pushNamed('/details') from inside a node builds /details below the node, with everything the screen put over its subtree still among its ancestors. Nested nodes chain: each borrows from the one above it.

State restoration #

restorationScopeId gives the node's navigator a name to keep its stack under, and the stack inside a node then survives the application being killed and brought back:

NavigationNode(
  restorationScopeId: 'checkout',
  child: const ScreenBody(),
)

What comes back is what Flutter can build again without the code that pushed it: restorablePush and its neighbours, which keep a reference to a static builder rather than a closure. An ordinary push is never restored — that is the framework's rule and not the node's. The builder that is kept must be a top-level or static function annotated with @pragma('vm:entry-point'): the navigator asks for it by name when it builds the route again, and the name has to survive the compiler for that. A test finds it without the annotation and an application does not, so it is an easy thing to ship broken. restorablePushNamed works from inside a node like any other name, and the route table it needs is borrowed from the navigator above when the name is built anew.

An ordinary push costs more than itself. The framework reads the stack from the node's page upwards and stops writing anything down at the first route it cannot rebuild, so a restorable push made over an ordinary one goes with it:

navigator.restorablePush(_details);  // comes back
navigator.push(...);                 // never does
navigator.restorablePush(_summary);  // and now neither does this one

A stack meant to survive has to be restorable the whole way up.

On the web restorablePush restores nothing at all: the handle it keeps is marked unrestorable there (flutter#33615), and no annotation changes that. restorablePushNamed is unaffected — a name needs no handle.

A name has to be unique among everything claiming a bucket from the same scope. Two nodes standing side by side on one route need two names; a node nested inside another claims from the page of the node above it and needs nothing of the sort. A debug build says so itself — Multiple owners claimed child RestorationBuckets with the same IDs — but the check is an assertion, so a release build drops one of the two stacks in silence.

And none of this happens unless restoration reaches the node, which it does through the whole chain above it. MaterialApp.restorationScopeId, or a RootRestorationScope of its own, turns it on for the application; every Navigator between that and the node — another NavigationNode included — needs a restorationScopeId too. One link without a name hands the node below an empty bucket, and its stack comes back empty, without a word anywhere.

Observers #

Observers of the application see the navigation inside a node. The navigator a node builds reports to MaterialApp.navigatorObservers — a RouteObserver, an analytics observer, a logger of your own — the way the navigator of the application does, so a node costs nothing to put on a screen that is already watched. What reaches them is a retelling: an observer instance belongs to one navigator and one only, so the instances an application has already declared cannot be handed to a node as well, and the node's own observer repeats to them instead.

The price is that NavigatorObserver.navigator never names the node — retelling binds nothing. An observer that reads it is asking about somewhere else; HeroController is the framework's own, and has no business in observers:. A delegate that raises is reported through FlutterError.reportError and stepped over, so one failing observer takes neither the rest of the audience nor the navigator of the node with it.

observedFromAbove: false keeps a node's navigation to itself, and observers: names observers for one node. Do not name one that already stands above: it would be told twice, and an assertion says so, wherever up the chain the other mention is. Nodes chain, each inheriting the whole audience of the navigator above it — so an outer node that is not observed cuts the nodes inside it off from the application, though not from its own observers:. The chain is made of nodes and nothing else: a plain nested Navigator of your own standing between a node and the application has no proxy to pass anything on with, and its own observers are all a node below it can find.

The page a node builds for itself is announced to nobody when the node mounts — the navigator above has announced the route it stands for already. Everything after that is passed on as it is, so a RouteAware on the node's first page is told when a route pushed inside covers it, through a RouteObserver<PageRoute> or wider: the node builds a PageRoute, not a MaterialPageRoute. What it is never told is that the application covered it, and a node leaving the tree says nothing about the routes it takes with it.

What a node does not do #

A Hero does not fly between routes pushed inside a node. That is Flutter's own doing rather than the node's: a Navigator hides the HeroControllerScope above it from its own subtree, so every nested navigator is left without a hero controller until the application puts one there. Wrap the node in a HeroControllerScope of your own if you want the animation.

popUntil stops on the node's own page. The walk it makes is about the stack of one navigator, and a node never empties itself, so a predicate matching nothing inside the node ends there rather than leaving.

Replacing that page, or removing it, is not something a caller does. It is the node. pushReplacement on it pushes over it instead, since there is nothing there to replace; pushAndRemoveUntil stops on it the way popUntil does. Both hold for the restorable twins — restorablePushReplacement, restorablePushReplacementNamed, restorablePushAndRemoveUntil and restorablePushNamedAndRemoveUntil — and for the named forms that go through them. Without that floor the framework asserts, naming a pages API the caller never touched: the page a node starts with is page-based, and no page-based route is completed imperatively.

One node per tab: NavigationNode(enabled:) #

A node takes part in the system back of the route it stands on. That is the whole point of it, and it is fine as long as one node stands on a route.

Tabs break that assumption. The usual shape keeps a node per tab in an IndexedStack, which builds every branch and shows one, so all of them are on the route at once. A route asks each of its PopEntrys and calls each of them back, so one back press unwinds the stack of every tab, the hidden ones included: you press back on tab B and tab A quietly loses a screen.

Which tab is the one on screen is not something a node can work out — a hidden branch of an IndexedStack reads exactly as a shown one. The application knows, and enabled is where it says so:

IndexedStack(
  index: _tab,
  children: [
    for (var i = 0; i < tabs.length; i++)
      NavigationNode(
        enabled: i == _tab,
        child: tabs[i],
      ),
  ],
)

A disabled node takes no place on the route: it is not asked and it is not called back. Everything else goes on working — its nested navigator keeps its stack, and Navigator.of(context) from inside still pushes and pops there, so switching back to the tab finds it where it was left.

Nodes inside nodes never need this: an inner node registers on the page of the navigator above it rather than on the route both stand on.

Where it comes from #

This package was part of scopo until 0.11.0, and the two are made for each other: a scope over a screen is exactly the thing a pushed route loses, and a node is what keeps it. Neither depends on the other — this package imports nothing but Flutter — so a node is worth having whatever puts state over your screens.

0
likes
160
points
142
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A nested Navigator for Flutter: dialogs, sheets and pushed routes stay inside the screen that opened them, and the system back reaches the innermost one first.

Repository (GitHub)
View/report issues

Topics

#navigation #navigator #routing #widget

License

MIT (license)

Dependencies

flutter

More

Packages that depend on navigation_node