Tutorials
Build modular navigation tabs
Create a NavigationBar with independent Catalog and Profile Navigator stacks, retained state, and explicit branch activation.
Build modular navigation tabs
You will build a shell with Catalog and Profile. Open Product inside Catalog, switch to Profile, then return to Catalog. Product remains on top because each tab owns an independent Navigator stack.
The final Scope tree
root
└── shell
├── catalog-tab [Catalog, Product]
└── profile-tab [Profile]
Give every Navigator stack a Scope ID
const rootScopeId = NavigationScopeId('root');
const shellScopeId = NavigationScopeId('shell');
const catalogScopeId = NavigationScopeId('catalog-tab');
const profileScopeId = NavigationScopeId('profile-tab');
These IDs describe ownership, not tab order. The UI may reorder tabs without renaming Scopes.
Export one Module from each feature
final catalogModule = NavigationModule(
id: const NavigationModuleId('catalog'),
routes: <NavigationRouteDefinitionBase>[catalogRoute, productRoute],
);
final profileModule = NavigationModule(
id: const NavigationModuleId('profile'),
routes: <NavigationRouteDefinitionBase>[profileRoute],
);
Feature modules contain route definitions. They do not know about NavigationBar or the final URL prefix.
Compose Scopes and Mounts in the app
scopes: const <NavigationScopeDefinition>[
NavigationScopeDefinition(id: rootScopeId),
NavigationScopeDefinition(id: shellScopeId, parentId: rootScopeId),
NavigationScopeDefinition(id: catalogScopeId, parentId: shellScopeId),
NavigationScopeDefinition(id: profileScopeId, parentId: shellScopeId),
],
mounts: <NavigationMount>[
shellMount,
NavigationMount(
id: const NavigationMountId('catalog-tab'),
moduleId: catalogModule.id,
scopeId: catalogScopeId,
pathPrefix: '/catalog',
),
NavigationMount(
id: const NavigationMountId('profile-tab'),
moduleId: profileModule.id,
scopeId: profileScopeId,
pathPrefix: '/profile',
),
],
Mounts are application choices. Another app can reuse the same Modules at other prefixes or Scopes.
Initialize each tab stack once
Future<void> initializeBranches(BuildContext context) async {
final navigation = context.readNavigation;
await navigation.setStack(
const <NavigationTarget<Object?>>[
NavigationTarget<Object?>(
destination: CatalogDestination(),
mountId: NavigationMountId('catalog-tab'),
scopeId: catalogScopeId,
),
],
scopeId: catalogScopeId,
);
await navigation.setStack(
const <NavigationTarget<Object?>>[
NavigationTarget<Object?>(
destination: ProfileDestination(),
mountId: NavigationMountId('profile-tab'),
scopeId: profileScopeId,
),
],
scopeId: profileScopeId,
);
await navigation.activateBranch(catalogScopeId);
await navigation.activateBranch(shellScopeId);
}
Call this once from didChangeDependencies, not from every build().
Render the active tab and wire NavigationBar
@override
Widget build(BuildContext context) {
final active = context.navigation.value
.scope(shellScopeId).activeChildScopeId;
final selectedIndex = active == profileScopeId ? 1 : 0;
return Scaffold(
body: const NavigationActiveOutlet(parentScopeId: shellScopeId),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) {
context.readNavigation.activateBranch(
index == 0 ? catalogScopeId : profileScopeId,
);
},
destinations: const <NavigationDestination>[
NavigationDestination(icon: Icon(Icons.store), label: 'Catalog'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
context.navigation subscribes the shell to Snapshot changes. readNavigation performs the action without adding another subscription.
Verify retained stacks
Push Product with scopeId: catalogScopeId, switch to Profile, and switch back. Do not call resetBranch during ordinary tab selection.
Use this only when state must be destroyed:
await context.readNavigation.resetBranch(profileScopeId);
For example, reset private tabs after sign-out.