Layouts
Tab layouts
// lib/app/(tabs)/layout.dart
const tabs = ['(home)', 'search', 'profile'];
class TabsLayout extends StatelessWidget {
const TabsLayout({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) => Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (i) => navigationShell.goBranch(
i,
initialLocation: i == navigationShell.currentIndex,
),
destinations: [ … ],
),
);
}A layout.dart that asks for a StatefulNavigationShell (named navigationShell or shell, or by that type) instead of a Widget child is a tab layout. It becomes a go_router StatefulShellRoute.indexedStack (or, with a container, your own navigatorContainerBuilder), so each tab keeps its own navigation stack and state while you look at another one. Asking for both a child and a shell is an error. A tab layout can also ask for segments and query parameters like any other layout.
Which tabs. A tab (a branch) is the layout folder's own page.dart, if it has one, then each direct subfolder that holds routes: a static or dynamic folder, or a (group). Whatever is below a subfolder (nested pages, data, guards, transitions, more layouts) stays inside its tab.
- Branches follow folder order, which is alphabetical, unless
tabslists them. tabsis a top-levelconstlist of string literals naming each folder as written, and'.'for the folder's own page. It must list every branch exactly once: an unknown, missing or repeated name is an error. Name any other list of destinations something else.- A tab layout folder without its own
page.darthas no route at its own path: link to one of its tabs' routes instead.
Outside the tabs. Routes outside the layout's folder are in no tab, so they cover the whole screen: in examples/tabs, /settings has no navigation bar. To cover the screen while the URL stays in a tab (/profile/edit), use navigator.dart.
Two errors. go_router opens a tab on its first route, which can't have a :segment in its own path. So a tab made only of dynamic routes, or a tab layout placed directly in a $folder, is an error. Put the layout in a (group) below that folder instead (a tab's initialLocation, below, also fixes the first case).
Since 0.9.0, package:fespalier_adaptive can draw the bar from the menu, as a bar, a rail or a drawer by window width: see A bar, a rail or a drawer.
Nested tab layouts. A tab layout can sit inside a tab of another one: put a layout.dart that takes a StatefulNavigationShell in a folder that is a branch of the outer layout.
lib/app/(tabs)/
layout.dart const tabs = ['(home)', 'search', 'library']; takes a shell
(home)/page.dart
search/page.dart
library/ the third outer tab...
layout.dart ...is itself a tab layout: const tabs = ['books', 'authors'];
books/page.dart /library/books
authors/page.dart /library/authorsEach layout has its own tabs list, stacks and StatefulNavigationShell, and the outer layout keeps the whole inner one alive, so an inner tab's state survives switching outer tabs. The rules above apply at each level. library/ has no page of its own here, so its inner layout is what the outer tab shows; give it a page.dart and that page becomes the inner layout's first tab.
Tab options. A tab layout can set go_router's StatefulShellBranch options per tab in a top-level const tabOptions map, next to tabs. Keys are the tab names tabs uses ('.' for the layout's own page); each value is a TabOptions from package:fespalier/fespalier.dart:
const tabOptions = {
'search': TabOptions(preload: true),
'profile': TabOptions(initialLocation: '/profile/edit'),
};preload: truebuilds the tab as soon as the layout first shows, instead of on its first visit.initialLocationis where the tab opens the first time, and where tapping its current tab goes withgoBranch(i, initialLocation: true), instead of the tab's first route. It is an app location such as/profile/edit(with?queryif you like), written as a string literal, and must be a route inside that tab;fspchecks that (dynamic routes match any value:/items/1foritems/$id). It lets a tab that has only dynamic routes work, since go_router then needs no first route without a:segment. When mounted withAppRoutes.mount(at: '/x'), the mount point is added for you.
Like tabs, tabOptions is read from the source, not run: a map literal with string-literal keys and TabOptions(...) values with true/false and string-literal arguments. Unknown tabs, repeated tabs, unknown options and other values are errors that point at the offending entry. List only the tabs that need options.
Container. By default the tabs' navigators sit in an IndexedStack. Export a top-level container function from the tab layout to arrange them yourself, say to cross-fade or slide between tabs:
// lib/app/(tabs)/layout.dart
Widget container(BuildContext context, StatefulNavigationShell shell, List<Widget> children) =>
CrossFadeContainer(currentIndex: shell.currentIndex, children: children);The generated route is then StatefulShellRoute(navigatorContainerBuilder: _i1.container, …) instead of .indexedStack(…).
- The three parameters are positional and their types are fixed (
BuildContext,StatefulNavigationShell,List<Widget>; the names are yours). A wrong type, a missing or an extra parameter, or a return type that isn'tWidgetis an error at the parameter. childrenholds one navigator per tab, in tab order. Keep them all in the tree (Offstage,Opacityor aStack, asIndexedStackdoes) or the tabs lose their state.- Wrap the ones you don't show in
TickerMode(enabled: false), as go_router's container does: that is how a shared element (since 0.8.1) knows its tab is hidden. - A
containerin a layout that isn't a tab layout is ignored with a warning.
Menus and breadcrumbs: nav.dart
Since 0.8.1. A drawer, a bottom bar, a tab bar and a breadcrumb row all list the same thing: the folders of the app and where they go. A nav.dart in a folder says how that folder shows up, and fsp gen writes AppMenu at the end of lib/app.g.dart from all of them, so a menu is not a second list to keep in step with the routes.
// lib/app/products/nav.dart
import 'package:fespalier/nav.dart';
import 'package:flutter/material.dart';
const nav = Nav(
label: 'Products', // without a BuildContext (`fsp routes`, tests), and the fallback
icon: Icons.storefront_outlined,
selectedIcon: Icons.storefront,
order: 1, // siblings sort by `order`, then by their place as a tab, then by folder name
);
/// Optional: the label shown, localized. It can ask for the segments of its folder and above.
String label(BuildContext context) => AppLocalizations.of(context)!.products;Nav and NavItem live in package:fespalier/nav.dart, not in the barrel, so they collide with nothing you have. nav must be a const Nav(...) call and order a whole-number literal: fsp reads both from the source. A folder with a page.dart or redirect.dart is an entry that goes there; a folder without one is a heading that holds the entries of the folders below it, and is left out (with a warning) when there are none.
// lib/app/orders/$id/nav.dart: a breadcrumb, not a menu entry
const nav = Nav(label: 'Order', inMenu: false);
String label(BuildContext context, {required int id}) => 'Order #$id';Reading it. Call these in build, in a layout or a page:
AppMenu.watch(ref)returns the entries at the current location, nested as the folders are, asNavItems.AppMenu.breadcrumbs(ref)returns the entries from the top down to the page.
They read the router's location, so the widget rebuilds when it changes (above the router, in MaterialApp.builder, there is none).
class AppDrawer extends ConsumerWidget {
const AppDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => ListView(
children: [
for (final item in AppMenu.watch(ref))
ListTile(
leading: Icon(item.icon), // selectedIcon while selected
title: Text(item.label(context)),
selected: item.selected,
enabled: item.enabled, // false: its guards refuse it, with `NavRefused.disable`
onTap: () => item.go(context),
),
],
);
}
// a tab bar: AppMenu.watch(ref, under: '(tabs)') lists the entries at or below that folder,
// and each one's `tab` is its index in the tab layout of that folder
final tabs = AppMenu.watch(ref, under: '(tabs)');
NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (i) => navigationShell.goBranch(i),
destinations: [for (final t in tabs) NavigationDestination(icon: Icon(t.icon), label: t.label(context))],
);
// breadcrumbs
Text(AppMenu.breadcrumbs(ref).map((c) => c.label(context)).join(' › '));Which entries, and in what order.
- The app folder's own
nav.dart, and the one of a tab layout's ownpage.dart, are flat: they sit beside the entries below them instead of holding them, and are selected on their own route only. Any other entry holds the entries of the folders below it (children). - Siblings sort by
order, then by their index as a tab (seeNavItem.tab), then by folder name. under:takes a folder ('(tabs)',r'teams/$teamId',''for everything) and returns the topmost entries at or below it. It is an assertion in debug when nonav.dartis there.- An entry whose folder has segments (
orders/$id) is listed only at a location that has them (/orders/7,/orders/7/refund): its route is built from that location. Elsewhere it is left out, with the entries below it. inMenu: falseleaves an entry out ofwatch, and its children with it; the breadcrumbs keep it.- A selected entry is the one of the page or of a folder above it.
Guards. An entry is shown only if the guards that would run for a navigation to it let it through: those of its folder and the ones above it, asked with the entry's own location. The entry's whenRefused says what the menu does with a refused one: NavRefused.hide (the default) leaves it out, disable lists it with enabled == false, and show lists it without asking its guards at all.
- A guard that answers at once (the usual
ref.watch(session) ? null : '/login') is in the first frame of the widget that asks. ARefguard is followed: when what itref.watches changes, the entry changes in the next frame, with no navigation. - A guard that returns a
Futuremakes the entry pending: listed and enabled (NavItem.access == NavAccess.pending) until the answer is in, then allowed or refused. Nothing waits for it, and no timer is used. A guard that throws is reported (FlutterError.reportError) and the entry stays pending. - A guard written with
ProviderContainer c(the older form) is called with theRef's container and read once when the menu asks, as a navigation does: the menu does not follow it. - The menu runs your guards, each once per entry while a menu with it is on screen (and again when what they watch changes), so a guard has to be cheap and free of side effects. The answers are kept in one provider per entry that goes with the widgets that watch it: a menu that leaves the screen lets go of everything its guards watch.
Labels. label() gets the BuildContext, so it can read AppLocalizations, the locale or a provider-backed setting, and the segments it names (required int id), typed like any other file in the folder. It is not given data: a breadcrumb that shows a product's name reads the product in the widget (ProductRoute.watch(ref, id: item.params['id'] as int)).
Not built: more than one menu per app (use under: and inMenu), labels from data.dart. fsp new orders --nav writes a nav.dart for a folder, and fsp routes --json has a nav key on the routes whose folder has one (file, label when it is a string literal, order).
A bar, a rail or a drawer: fespalier_adaptive
Since 0.9.0. The menu is one list; what changes with the screen is the component that shows it. package:fespalier_adaptive draws AppMenu.watch around a layout's body as a NavigationBar on a phone, a NavigationRail on a tablet and a permanent NavigationDrawer on a wide window. The bar is no longer a second list of destinations that can drift from the routes, and guards hide or disable entries as they do in any menu. Add it next to fespalier, with the same url and the same ref (Companion packages says why):
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_adaptive:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_adaptive
ref: v0.25.0The tab layout of Tab layouts, with a nav.dart in each tab's folder for its label and icon, becomes:
// lib/app/(tabs)/layout.dart
import 'package:fespalier/fespalier.dart';
import 'package:fespalier_adaptive/material.dart';
import 'package:flutter/material.dart';
import 'package:my_app/app.g.dart';
class TabsLayout extends StatelessWidget {
const TabsLayout({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) => AdaptiveNavScaffold(
shell: navigationShell,
// under: the tab layout's folder, so each entry knows which tab it is
menu: (ref) => AppMenu.watch(ref, under: '(tabs)'),
breakpoints: const NavBreakpoints(rail: 840),
);
}tabs, tabOptions and container stay as they are. A plain layout (one that takes a Widget child) passes child: child instead of shell:, and exactly one of the two is asserted.
Breakpoints. The component follows the window's width in logical pixels (MediaQuery.sizeOf(context).width):
| Width | Component | NavMode |
|---|---|---|
| under 600 | NavigationBar at the bottom | bar |
| 600 to 1199 | NavigationRail at the start, every label shown | rail |
| 1200 and wider | a permanent NavigationDrawer, with the nested entries | drawer |
Those are NavBreakpoints(rail: 600, drawer: 1200), Material 3's compact, medium and large window size classes (WindowSizeClass.of(width) names all five). Both are configurable, and null means never: NavBreakpoints.noDrawer, or NavBreakpoints(rail: 840), which examples/tabs uses so that a bar serves up to small tablets in portrait (and so that Flutter's default 800 × 600 test window keeps the bar). A drawer below the rail is an assertion. A layout inside a split view that needs the width of its own box builds an AdaptiveNav itself, under a LayoutBuilder (see Your own widgets, below).
Which entries.
- The bar and the rail list the top-level entries that go somewhere: an entry with a route or, in a tab layout, an entry that is a tab, even a heading. Library in
examples/tabsis a folder with nopage.dart, and it is the fourth destination. A heading that is not a tab is replaced by the entries below it. - The drawer lists every entry that goes somewhere, depth first. A heading is the title of a section over the entries below it (Books and Authors under Library), and a route's own children stay in its section, so a tree of three levels or more is flattened under one level of headings.
- Pass
under:the tab layout's folder. That is what gives each entry itstab: the selected destination is the one whosetabis the shell's current index (the drawer's: the deepest selected entry with a route), tapping a tab isgoBranch, and tapping the current one goes back to its first page. Any other entry isitem.go(context). Withoutunder:no entry has a tab, so the entries go by the page: a heading is no destination and the current tab does not reset. - Guards are whatever
AppMenu.watchanswers. ANavRefused.hideentry is absent, and the destinations are mapped through their tab, so a tap never lands on the wrong page after another entry went away. ANavRefused.disableentry is listed and turned off, and apendingone is on. The menu runs your guards while the layout is on screen, which is always: keep them cheap, as Guards says.
The phone's bar is hidden on a page no entry covers. A NavigationBar cannot show "nothing selected": it asserts a selected index. When no destination is the current tab (a plain layout: none is selected), or there are fewer than two destinations, the bar is not built and the body is shown alone. A debug build says so once:
fespalier_adaptive: no menu entry is the current tab (3) of the tab layout, so the navigation bar is hidden. Give the tab's folder a nav.dart (not inMenu: false), and pass AppMenu.watch(ref, under: <the tab layout's folder>).Fix it in the layout's source: give the page's folder (the tab's) a nav.dart, put the page below an entry, or render the menu yourself with an AdaptiveNavBuilder. A rail and a drawer have no such limit: they show the page with no destination selected.
State. The body (the shell or the child) sits at one place in the tree in every mode, so a tab keeps its stack and state when the window is resized or a tablet is rotated. No timer, no listener: the width is read when the layout builds.
Slots. icon: builds each destination's icon (wrap it in a Badge). leading: and trailing: go above and below the destinations of the rail and the drawer (a logo, a button) and are not shown with the bar. floatingActionButton: is the scaffold's, asked in every mode: return null where the button went into leading.
AdaptiveNavScaffold(
shell: navigationShell,
menu: (ref) => AppMenu.watch(ref, under: '(tabs)'),
icon: (context, item, selected) => Badge(
isLabelVisible: item.folder.endsWith('inbox'),
child: defaultNavIcon(context, item, selected),
),
leading: (context, nav) => const FlutterLogo(),
)Your own widgets. AdaptiveNavBuilder hands the model, an AdaptiveNav (destinations, sections, selectedIndex, visible, enabled(i) and select(context, i)), to a builder you write: chips, Cupertino widgets, or package:material_ui's. The Library layout of examples/tabs draws its two inner tabs as chips this way.
AdaptiveNavBuilder(
menu: (ref) => AppMenu.watch(ref, under: '(tabs)/library'),
shell: navigationShell,
builder: (context, nav) => Row(children: [
for (final (i, item) in nav.destinations.indexed)
ChoiceChip(
label: Text(item.label(context)),
selected: nav.selectedIndex == i,
onSelected: (_) => nav.select(context, i),
),
]),
)Two libraries. package:fespalier_adaptive/fespalier_adaptive.dart (the model, NavBreakpoints, AdaptiveNavBuilder) imports no Material. package:fespalier_adaptive/material.dart (the scaffold, and a re-export of the model) draws Flutter's Material widgets, which do not read package:material_ui's theme (see go_router 18 and Material): an app on material_ui renders the model itself, or copies the scaffold (one file) with the other import.
Testing. A test sizes the window with tester.view.physicalSize (and devicePixelRatio = 1, and addTearDown(tester.view.reset)). Flutter's default test window is 800 × 600 logical pixels, which is a rail with the default breakpoints and a bar with rail: 840.
Transitions
// lib/app/transition.dart: every route fades in, unless a folder overrides it
Page<void> transition(LocalKey key, Widget child) => Transitions.fade(key, child);transition.dart says how a route animates in. It applies to its folder's route and every route below it, and the nearest one wins. A transition.dart at the root is the app-wide default; any folder or (group) folder can override it for its own routes.
The function returns a Page, and takes the page's key as LocalKey key, the page itself as Widget child, and optionally GoRouterState state. Transitions has ready-made ones: fade, slide, none, material, cupertino, sharedAxis and fadeThrough (since 0.19.0, below), and dialog, sheet and fullscreenDialog (below), and custom for a motion of your own (since 0.19.0, see "A transition of your own" below). Every one but dialog and sheet takes heroes: (shared elements, since 0.8.1).
The key is go_router's state.pageKey (the path template) for a route's page, unless the route remounts (since 0.6.0): then it is a key that changes with the URL, and a change makes the page a new one, which this transition plays for.
A layout's shell is a page too. The ShellRoute a layout.dart makes, and a tab layout's StatefulShellRoute, take the nearest transition.dart (the layout folder's own included) as their pageBuilder. So a layout moves like any other page when a route on the root navigator opens over it.
- The shell's page key is
ValueKey<String>('layout:(tabs)/'), made from the layout's folder. It is the same on every launch (its restoration id, see State restoration) and while you switch routes inside the shell, so only entering or leaving the shell animates it, not going from one page of the layout to another. - A layout with no
transition.dartabove it keepslayoutPage(…).fsp initwrites a roottransition.dart, so most apps' shells have apageBuilderof their transition's making.
A transition() that needs to tell a shell from a route (to wrap a route's page in something its shell shouldn't get) can take bool shell (or isShell): true for a layout's shell, false for a route's page. It is the only extra parameter besides key, child and state.
Page names (since 0.9.0). Each pageBuilder: the generated file writes is wrapped in namedPage('/products/:id', () => ...). That covers a route with a transition.dart or a present.dart, a remount route, and a layout's shell. The pages Transitions.*, layoutPage and remountPage build while it runs are named by the route's pattern (RouteSettings.name).
- A
NavigatorObserver(Sentry's, Firebase Analytics', PostHog's) sees/products/:idinstead ofnull. - A layout's shell is named by its section's pattern:
/for a root layout or a(tabs)/group,/shopforshop/layout.dart. go_router forwards a shell navigator's pushes to the root navigator's observers, so an observer given toAppRoutes.router(observers: ...)sees the pages inside a shell too. - A route with no
pageBuilder:(a barebuilder:) is go_router's own page, namedstate.name ?? state.path: a roottransition.dart, whichfsp initwrites, names every page. - A
Pageof your own in atransition.dartreads the name fromTransitions.pageName(null outside the generated page builders):MaterialPage(key: key, name: Transitions.pageName, child: child). - Keys, restoration ids, transitions and heroes are the same.
namedPageruns its builder once, synchronously, and brings the previous name back when it returns or throws.
A transition of your own (since 0.19.0). Transitions.custom(key, child, transitionsBuilder: ...) builds a CustomTransitionPage the way the presets do: named by the route's pattern (Transitions.pageName), with the restoration id of the key, and heroes: if you give them. So a page with a motion of your own is a first-class one: an observer sees its name, its state is restored, its heroes fly.
Page<void> transition(LocalKey key, Widget child) => Transitions.custom(
key,
child,
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
ScaleTransition(scale: animation, child: child),
);duration(300 ms) andreverseDuration(the same by default),heroes,opaque,fullscreenDialogand the barrier options are the ones ofCustomTransitionPage. Like the presets, the page'stransitionsBuilderis the latest page's, so keep it free of state of its own.- A
Pagethat does not go throughcustom(aMaterialPageof yours) gets the same restoration id fromTransitions.restorationIdOf(key): the key's value for go_router'sValueKey<String>, null for any other key. Together withname: Transitions.pageNamethat is allcustomadds. - Every page
Transitionsbuilds carries aPageMotion(fade,slide,none,material,cupertino,dialog,sheet,fullscreenDialog,sharedAxis,fadeThrough;customforTransitions.customunless you passmotion:). It changes nothing on screen: it is what a test reads withmotionOf, andPageMotion.of(page)reads it from aPage. Values are only ever added.
Material motion: shared axis and fade through (since 0.19.0). The two transitions of Material 3's motion spec, written over package:flutter/widgets.dart alone (no Material or Cupertino import, no animations package, which reads a Material theme and would tie core to one kit).
// lib/app/transition.dart: pages slide 30 dp sideways while they fade
Page<void> transition(LocalKey key, Widget child) => Transitions.sharedAxis(key, child);
// lib/app/settings/transition.dart: unrelated destinations fade through each other
Page<void> transition(LocalKey key, Widget child) => Transitions.fadeThrough(key, child);Transitions.sharedAxis(key, child, {axis, duration, fill, heroes}):SharedAxis.x(the default, horizontal),.y(vertical) or.z(depth). The outgoing page fades out over the first 35% and moves 30 dp away; the incoming page fades in from 30% and moves in from 30 dp. Onzthe incoming page scales from 0.8 to 1 and the outgoing one from 1 to 1.1. On a pop the roles mirror.xis mirrored in a right-to-leftDirectionality.Transitions.fadeThrough(key, child, {duration, fill, heroes}): the outgoing page fades out over the first 35%, then the incoming page fades in and scales from 0.92 to 1. Both last 300 ms by default; the movement uses M3's emphasized easing.- The outgoing half belongs to the page below, and plays under an opaque page only. A route's page is animated by the route's own
animationand, for the page it covers, by thesecondaryAnimationthat route drives. So the page below fades out and moves only when it is itself asharedAxis(orfadeThrough) page, and only while the page above is an opaque page route. AsharedAxispage pushed over anoneormaterialpage still enters correctly; that page keeps doing whatever its own transition does when covered. A see-through page (Transitions.custom(opaque: false)), a dialog or a sheet over it leaves it exactly as it was. - The fill. Both pages fade, so in the middle of the transition nothing opaque is left: without a fill you see what is under the Navigator (often black), a
Scaffoldincluded, because it fades with its page. The colour behind the pages is, in order: the preset'sfill:; aMotionFillabove the router; otherwise transparent. SetMotionFillonce inMaterialApp.router(builder:), or passfill:. The fill fades only with the page's own animation, so the covered page's content fades out over its fill and the fill never slides or scales:
MaterialApp.router(
routerConfig: router,
builder: (context, child) =>
MotionFill(color: Theme.of(context).colorScheme.surface, child: child!),
);- Under the system's reduced motion both become a plain cross-fade of the same duration: no slide, no scale, and the page below keeps still.
- The widgets are public for a
Transitions.customof your own:SharedAxisPageTransition(animation:, secondaryAnimation:, axis:, fill:, child:)andFadeThroughPageTransition(...). They start no timer or ticker and add no listener of their own.
Predictive back (since 0.19.0; what each part does on a device is UNCHECKED per Android version). Transitions.material gets Android's predictive back peek from the theme: it is the default on Flutter 3.38 and later; on 3.35 to 3.37 set pageTransitionsTheme with PredictiveBackPageTransitionsBuilder for Android, and the manifest needs android:enableOnBackInvokedCallback="true" (Android 14 requires it, later Android versions may default to it). Every CustomTransitionPage preset (fade, slide, sharedAxis, fadeThrough, custom) pops when the gesture is released, with its own reverse transition: there is no peek, the page leaves as a button back would (UNCHECKED on a device, #228). Heroes(onBackGesture: true) covers heroes during the gesture of material and cupertino; a CustomTransitionPage preset has no gesture in progress. fespalier does not wrap the presets in PredictiveBackPageTransitionsBuilder: that would replace them with Material's own peek.
Reduced motion (since 0.19.0). When the system asks for reduced motion (MediaQuery.disableAnimations: "remove animations" on Android, "reduce motion" on iOS), every Transitions page and RouteHero honours it, with nothing to configure. This changes every app that upgrades: a slide that used to move now fades. accessibleNavigation is not motion and is ignored. The flag is read from the route's own context when the route is built or plays, so a page follows the setting while the app runs (dialog and sheet, below, do not).
slideis a cross-fade of the sametransitionDuration, which Flutter itself plays at a twentieth while animations are disabled: no offset, the opacity animates.material,cupertinoandfullscreenDialogkeep their page types (MaterialPage,CupertinoPage), so the Cupertino edge swipe back keeps working (the predictive back gesture should too, because the route keeps its Material mixin; UNCHECKED on a device, #226), and fade in place instead of sliding, zooming or moving the page below.dialogandsheetopen withAnimationStyle.noAnimation, unless you pass ananimationStyle:, which wins. They read the flag once, when they open: an open dialog or sheet keeps its style, and one opened under reduced motion also closes at once (a sheet still drags to dismiss, and the barrier appears at once).- A
RouteHerostops flying: the page appears and the element is simply there. sharedAxisandfadeThroughare a cross-fade too, and the page below keeps still.fadeandnoneare unchanged, and thePageMotiontag stays the preset's own.Transitions.custom'stransitionsBuilderis yours, so fespalier does not change it: read the flag in it, for examplefinal reduced = MediaQuery.maybeDisableAnimationsOf(context) == true;, and return aFadeTransitionfor it.- There is no app-wide opt-out. A page that wants motion anyway is a
Pageof your own, such asMaterialPage(...)orCustomTransitionPage(...)in yourtransition.dart.
Dialogs and sheets. Transitions.dialog, Transitions.sheet and Transitions.fullscreenDialog make a route open over the previous page instead of replacing it. The page's widget is what shows up:
dialog: the dialog itself (anAlertDialog, aDialogor your own card, as inshowDialog's builder).sheet: the sheet's content (wrapped in aMaterial).fullscreenDialog: a Material page that slides up, with a close button in itsAppBar.
// lib/app/photos/$id/transition.dart: /photos/:id is a dialog over /photos
Page<void> transition(LocalKey key, Widget child) => Transitions.dialog(key, child);
// lib/app/photos/sort/transition.dart
Page<void> transition(LocalKey key, Widget child) =>
Transitions.sheet(key, child, showDragHandle: true);They are real Navigator routes (a DialogRoute and a ModalBottomSheetRoute made by the page), so everything works as it does for showDialog: context.pop(), the back button and the barrier pop the route, and dialog and sheet take options such as barrierDismissible, isScrollControlled and enableDrag. A few things to know:
- Put the route below a page. The page underneath stays built and visible. go_router builds a deep link's stack from the parents that have a page, so with
photos/page.dartabovephotos/$id/,/photos/7opens the dialog over/photos. Without a parent page, the dialog opens over an empty screen. (A route withnest = falseis not below the page of the folder above it, so it opens over the page above that one.) - They cover their own navigator only. Inside a tab, a dialog covers that tab's navigator, not the tab layout's navigation bar; the same goes for a
layout.dart's body. Put the route outside the layout's folder to cover the whole screen, or give it apresent.dart(which puts it on the root navigator) or anavigator.dartbeside itstransition.dart. - They need
MaterialLocalizations, likeshowDialogandshowModalBottomSheet: aMaterialApp(or aLocalizationswith the Material delegate) above the router. - The route's
transition.dartalso covers routes below it, so give a dialog route its own folder.
Routes with no transition.dart above them keep go_router's default for your app type: the platform transition under a Material or Cupertino app, none otherwise (see the go_router 18 note in Getting started). Scaffold one with fsp new … --transition.
Shared elements (heroes)
Since 0.8.1, a shared element that flies from a list to a detail page is one line on each side: route.hero(name, child: ...) on every typed route.
// lib/app/products/page.dart: in the row of each product
leading: ProductRoute(id: p.id).hero('avatar', child: CircleAvatar(child: Text(p.name[0]))),
// lib/app/products/$id/page.dart
ProductRoute(id: product.id).hero('avatar', child: CircleAvatar(radius: 40, child: Text(product.name[0]))),- The tag is the route's path (its location without the query, mount prefix included) and the name, so both sides agree when they name the same route and the same element.
ProductsRoute()andProductsRoute(sort: Sort.name)share theirs. - A name is any object: an app's own
enumkeeps it typo-proof. herois an extension (RouteHeroes) of the typed routes, not a member, so a route with a query parameter calledherostill compiles (it shadows the extension;RouteHeroes(route).hero(...)still reaches it).route.heroTag(name)is the tag alone (aRouteHeroTag), for aHeroof your own.- Nothing is generated:
app.g.dartdoesn't change.
hero builds a RouteHero, which is Flutter's Hero with one difference: it stays out of flights while its tab is not shown (TickerMode is off for it, as go_router's tab container and examples/tabs' set it on the tabs they hide). Two tabs can then show the same tag, and a route on the root navigator that opens over the tab bar flies from the tab that is shown. With a plain Hero, that is Flutter's "There are multiple heroes that share the same tag within a subtree" assertion in debug.
The flight style is declared in transition.dart: Transitions.fade, slide, none, material, cupertino, fullscreenDialog, sharedAxis, fadeThrough and custom take heroes:, a Heroes with three options.
// lib/app/transition.dart: iOS-style pages, and heroes that follow the back swipe
Page<void> transition(LocalKey key, Widget child) =>
Transitions.cupertino(key, child, heroes: const Heroes(onBackGesture: true));Heroes option | What it does |
|---|---|
onBackGesture | heroes also fly while the user swipes back (Hero.transitionOnUserGestures); false by default |
path | HeroFlightPath.platform (the navigator's: an arc in a Material app, a line in a Cupertino one), arc or straight |
shuttle | what is shown while flying (Hero.flightShuttleBuilder); the destination's child by default |
heroes: wraps the page's child in a RouteHeroScope, which every RouteHero below it reads.
- The nearest
transition.dartthat passesheroes:decides; one that doesn't leaves the tree as it was (heroes are opt-in). - A layout's shell takes it too, so the scope covers every page inside the layout.
- A
RouteHero's ownonBackGesture:,path:andshuttle:override it for that hero. - A
present.dartpage, or aPageof your own, wraps its child in aRouteHeroScope(heroes: ..., child: ...)itself.
What flies, and what doesn't:
| Situation | What happens |
|---|---|
List to detail in one navigator (the app, a layout.dart, a tab) | flies on push and on pop |
A route on the root navigator (navigator.dart, a present.dart that builds a PageRoute, a route outside the layout) over a page in a shell or tab | flies from the tab that is shown |
| A hero in a tab that is not shown | out of flights. A custom tab container must wrap the tabs it doesn't show in TickerMode(enabled: false), as go_router's and examples/tabs' do |
Switching tabs (goBranch) | nothing flies: no route is pushed, the container is the tab animation |
Transitions.dialog, sheet, or a present.dart that builds a PopupRoute | nothing flies: Flutter flies heroes between page routes only. Use fullscreenDialog, material or a PageRoute |
| A remounted page | it is a new route: a tag made from the segments differs between the two pages, so nothing flies, while a tag that is the same on both pages flies |
A page with data.dart or a deferred one | flies when the page is in the destination's first frame: preload it (RouteLink(preload: Preload.intent), route.preload), or loading.dart shows and nothing flies |
| A back swipe or predictive back | flies only with onBackGesture: true on both pages (Flutter checks each side): set it in the root transition.dart |
A ResponsiveImage hero | without ResponsiveImage.flightShuttle the shuttle measures itself at every size of the flight and asks for a URL; with it (route.imageHero, since 0.9.0) it shows what is loaded and asks for nothing |
| One tag twice on one page | Flutter's assertion: give the second one another name, or wrap it in HeroMode(enabled: false) |
A hero name declared in a deferred page.dart would make the list page import that page and load it eagerly (and the deferred type rule applies to enums), so keep an enum of names in a file of its own. examples/shop (the product avatar, list to detail) and examples/tabs (the profile avatar, over the tab bar) use it.
State restoration
Pass a scope id to the router, and give the app one too. Flutter then saves what the user was doing when the OS kills the app, and puts it back on the next launch:
MaterialApp.router(
restorationScopeId: 'app',
routerConfig: AppRoutes.router(restorationScopeId: 'router'),
);Without the ids nothing changes. With them, the location comes back (also for routes deep in a stack), and so does everything below:
- Tabs. Each tab layout and each of its tabs gets a stable
restorationScopeIdfrom its folder (layout:(tabs)/,tab:(tabs)/search;.is the layout's own page), so the selected tab and the stack of every tab you visited are restored, nested tab layouts included. - Layouts. A plain layout's Navigator gets one too (
layout:(account)/). - Pages. What a page keeps in a
RestorationMixin(aRestorableIntfor a form field or a scroll offset) comes back if the page has arestorationId.- go_router's own pages have one; the ones
Transitions.*build take it from the page key. - A
Pageyou build in atransition.dartshould passrestorationId: Transitions.restorationIdOf(key)too (since 0.19.0; the key's value for go_router'sValueKey<String>), or its state won't be restored. - A page that remounts has the segments or the location in its key, so each URL it starts again at restores its own state.
- go_router's own pages have one; the ones
extra. An object passed withcontext.go(…, extra: …)is saved with the location if the router has anextraCodecthat knows its type (the same one the browser's history uses on the web).
Why layouts need generated pages. go_router keys the page of a ShellRoute or StatefulShellRoute by the route object's hashCode and uses it as the restoration id. That changes on every launch, so nothing under it can be found again. The generated router builds these pages with an id from the layout's folder instead: with a transition.dart above the layout, its Page under a ValueKey made of that id; otherwise layoutPage(...), a Material page (a Cupertino one inside a CupertinoApp) with the id, under the same ValueKey.
Ids come from folder names, so renaming a folder drops what was saved under the old one, once. In a restoration test, build the router in a State, not a final: a router remembers where it went (examples/tabs/test/restoration_test.dart uses tester.restartAndRestore()).
Scroll restoration
Since 0.8.1. Flutter builds a page from nothing when the browser's back or forward button brings it back, so a long list starts at the top again. scroll_restoration: true in the fespalier: section of pubspec.yaml (off by default) gives each page's scroll offsets back:
fespalier:
scroll_restoration: true// lib/app/products/page.dart
ListView.builder(
key: const PageStorageKey<String>('products'), // without this key, nothing is restored
itemBuilder: (context, i) => ...,
)Only a scrollable under a PageStorageKey is restored. Flutter keeps an offset in the nearest PageStorage, by key, and stores nothing for a scrollable that has none; fespalier does not make keys up.
- Two scrollables on one page can't swap offsets: give each its own key (a carousel in a list,
PageStorageKey('featured')andPageStorageKey('feed')). - A
PageViewand anExpansionTilekeep their position in the same place, so the same key restores them.
With the key on, the generated router wraps the page's own view, inside its transition and under its layouts, in RouteScrollMemory:
GoRoute(
path: 'about',
builder: (context, state) => RouteScrollMemory(
state: state,
child: _i4.page(),
),
),data.dart, deferred, remount and segment pages are wrapped the same way; layouts, redirects and not-found views are not. RouteScrollMemory gives the page a PageStorage bucket of its own per history entry, and what it does with it depends on how the page was reached:
- The browser brought the entry back (back, forward, or a reload of the tab): the page gets the bucket the entry had, so its keyed scrollables return to where they were. fespalier tells from go_router: it keeps the history state the platform hands over, and replaces it with a marker of its own for every navigation the app starts.
- Anything the app starts (
go,push,replace, aRouteLink, the first route) gets a fresh bucket, so the page starts at the top, and the bucket replaces the one kept for that location. - A location the platform reports without history state (a link opened from outside) counts as the app's, so it starts at the top too.
An entry is its matched location, plus the query for the page that is the top of the location (/search?q=a and /search?q=b are two entries; /products below /products/42 is /products).
Things to know:
- A page that stays mounted keeps its live scroll. The page below a child route, a tab, and a page whose URL changes in place (
remount: never,/c/1to/c/2, or?page=2throughcopyWith) are the same widgets throughout, so nothing is restored for them; the bucket just moves to the new location. On Android and iOS back is a pop and the page below is still mounted. - The memory is in memory. A router keeps the buckets of its 64 most recent entries (the oldest is forgotten first), each router has its own, and a reload of the browser tab starts empty. The same location twice in the history is one entry: A, B, A, B and back twice restores the second A's offset.
- A list that grows as it scrolls (infinite scroll) is rebuilt with its first items only, and Flutter clamps the saved offset to what is there. A list that waits for
data.dartis fine: its offset is applied when the list is first built, afterloading.dart. - Off, the generated file has no
RouteScrollMemoryat all.
Testing. Play the browser: the entry has to come back with the history state the app gave the platform (routeInformationUpdated), as it does in a browser. A pushRouteInformation with a location alone is a location from outside, and starts at the top. examples/features/test/scroll_restoration_test.dart records the state with a mock handler on SystemChannels.navigation and sends it back:
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
'flutter/navigation',
const JSONMethodCodec().encodeMethodCall(
MethodCall('pushRouteInformation', {'location': '/feed', 'state': saved.state}),
),
(_) {},
);
await tester.pumpAndSettle();