Three Approaches to Building Tab-Based Navigation in Flutter with go_router and persistent_bottom_nav_bar_v2
Using StatefulShellRoute.indexedStack for Robust Tab-Based Navigation in Flutter
Introduction
In this article, I’ll walk you through three different navigation strategies using go_router
, ValueNotifier
, and persistent_bottom_nav_bar_v2
. These methods are designed for Flutter apps that use nested navigators, custom bottom tabs, and dynamic routing. I also include tips and patterns for preserving tab state, passing parameters, and syncing state across the app.
My goal is to show how different navigation setups can adapt to real-world UI flows where tabs exist, don’t exist, or dynamically change based on user interaction.
I generally use the first and third approaches in my production apps.
Just note:
In the past, I used the first version of the persistent_bottom_nav_bar_v2
package, and I didn’t use go_router
, etc. I used its built-in methods to hide tabs on different screens, but in some cases, I hide the tab using the provider
package in a wrapper around the whole app — just by using the setHeight
method.
I’ve added comments throughout the code to help explain logic, potential issues (like param safety), and how to optimize transitions. Please read those notes carefully — they’re part of the core explanation.
I’ve used the following concepts in all three methods:
go_router
for navigationValueNotifier
for active tab trackingpersistent_bottom_nav_bar_v2
for bottom tab behaviorGlobalKey<NavigatorState>
for per-tab navigation controltabHistory
list to track visited tabs
Note: If you want a fully working version that handles more complex situations, feel free to ask me or leave a comment — I’ll be happy to help.
And in general, go_router
has some bugs — but I know how to fix them. If you run into any issues, just leave a comment and I’ll help you out.
🛠 General Setup
final parentKey = GlobalKey<NavigatorState>();
final Map<int, GlobalKey<NavigatorState>> navigatorKeys = {
0: GlobalKey<NavigatorState>(),
1: GlobalKey<NavigatorState>(),
2: GlobalKey<NavigatorState>(),
};
final List<int> tabHistory = [];
final ValueNotifier<int> activeTabNotifier = ValueNotifier<int>(0);
📝 Note:
Ex
means Example
Optional logic (used in the first sample only, but applicable to others too):
builder: (context, state) {
return ValueListenableBuilder(
valueListenable: activeTabNotifier,
builder: (context, activeTab, child) {
if (activeTab == 0) {
return MultiBlocProvider(
providers: [
BlocProvider<ExCubit>(
create: (_) => ExCubit(ExRepository()),
)
],
child: const HomeScreen(
key: ValueKey<int>(0),
useRouter: true,
),
);
}
return const SizedBox.shrink();
},
);
}
useRouter: true
// this is for using go_router or others, just a flag
I use back button overriding on related screens, some extensions, etc., in the general app. This is only part of my app’s router Dart file.
But this sample is enough to understand it generally.
Maybe when writing the article I made some typos, but in production apps, all of this is working.
If you see any problems, please add comments — I just reviewed it quickly.
For example, I didn’t fully write navigatorKey
, ValueKey,
etc. properly — these are just samples.
You can use some helpers on screens like this for tab history, but extend it for your app logic:
static void handleTabNavigation(
List<int> tabHistory, GoRouter router, bool didPop, String screen) {
if (!didPop) {
tabHistory.removeLast();
int previousTab = 0;
if (tabHistory.isNotEmpty) {
previousTab = tabHistory.last;
}
screen = _getScreenPath(previousTab, screen);
router.go(screen);
}
}
static String _getScreenPath(int tab, String screen) {
if (screen == 'extwo') {
switch (tab) {
case 0:
return "/home";
case 1:
return "/exone";
// ...
}
}
}
First Approach
Concept
This method uses a StatefulShellRoute.indexedStack
with three branches, each representing a tab (Home, ExOne, ExTwo). Tab navigation is tracked via ValueNotifier
, and custom behavior is injected using onTabChanged
.
final GoRouter goRouter = GoRouter(
initialLocation: "/",
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => PersistentTabView.router(
navigationShell: navigationShell,
tabs: tabs,
navBarBuilder: (navBarConfig) => CustomBottomNavBar(
navBarConfig: navBarConfig,
),
onTabChanged: (index) {
activeTabNotifier.value = index;
if (tabHistory.isEmpty || !tabHistory.contains(index)) {
tabHistory.add(index);
}
},
stateManagement: false,
handleAndroidBackButtonPress: false,
popAllScreensOnTapAnyTabs: true,
popAllScreensOnTapOfSelectedTab: true,
),
branches: [
StatefulShellBranch(
navigatorKey: navigatorKeys[0],
routes: <RouteBase>[
GoRoute(
name: 'home',
path: "/home",
builder: (context, state) {
return ValueListenableBuilder(
valueListenable: activeTabNotifier,
builder: (context, activeTab, child) {
if (activeTab == 0) {
return MultiBlocProvider(
providers: [
BlocProvider<ExCubit>(
create: (_) => ExCubit(ExRepository()),
)
],
child: const HomeScreen(
key: ValueKey<int>(0),
useRouter: true,
),
);
}
return const SizedBox.shrink();
},
);
},
),
],
),
StatefulShellBranch(
navigatorKey: navigatorKeys[1],
routes: <RouteBase>[
GoRoute(
path: '/tabs/exone',
name: ExOneScreen.routeName,
builder: (context, state) {
final id = (state.extra as Map?)?['id'] as int?;
if (id == null) {
return const Scaffold(
body: Center(child: Text('Missing ID')),
);
}
return MultiBlocProvider(
providers: [
BlocProvider<ExOneCubit>(
create: (_) => ExOneCubit(ExOneRepository()),
)
],
child: ExOneScreen(
useRouter: true,
id: id,
),
);
},
),
],
),
StatefulShellBranch(
navigatorKey: navigatorKeys[2],
routes: <RouteBase>[
GoRoute(
path: '/tabs/extwo',
name: ExTwoScreen.routeName,
builder: (context, state) {
return const ExTwoScreen(useRouter: true);
},
),
],
),
],
),
);
Second Approach
Concept
Since :id/exone
is parameterized, I added a separate home_two
screen to prevent errors. This allows the app to go from a no-tab home screen into a full tab-based layout.
But I used home_two
as a kind of fake screen.
final GoRouter goRouter = GoRouter(
initialLocation: "/",
GoRoute(
name: 'home',
path: "/home",
builder: (context, state) {
return const HomeScreen(
key: ValueKey<int>(0),
useRouter: true,
);
},
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => PersistentTabView.router(
navigationShell: navigationShell,
tabs: tabs,
navBarBuilder: (navBarConfig) => CustomBottomNavBar(
navBarConfig: navBarConfig,
),
stateManagement: false,
handleAndroidBackButtonPress: false,
popAllScreensOnTapAnyTabs: true,
popAllScreensOnTapOfSelectedTab: true,
),
branches: [
StatefulShellBranch(
navigatorKey: navigatorKeys[1],
routes: <RouteBase>[
GoRoute(
name: 'home_two',
path: '/',
builder: (context, state) {
return const HomeTwoScreen(
key: ValueKey<int>(1),
useRouter: true,
);
},
routes: [
GoRoute(
name: ExOneScreen.routeName,
path: ':id/exone',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return MultiBlocProvider(
providers: [
BlocProvider<ExOneCubit>(
create: (_) => ExOneCubit(ExOneRepository()),
)
],
child: ExOneScreen(
useRouter: true,
id: id,
),
);
},
routes: [
GoRoute(
name: ExOneScreenSuccess.routeName,
path: 'success',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
final args = state.extra as Map<String, dynamic>;
return ExOneScreenScreenSuccess(
useRouter: true,
id: id,
message: args['message'],
);
},
)
],
),
],
),
],
),
StatefulShellBranch(
routes: <RouteBase>[
GoRoute(
name: ExTwoScreen.routeName,
path: 'extwo',
builder: (context, state) {
return const ExTwoScreen(
useRouter: true,
);
},
),
],
),
],
),
);
Third Approach
Concept
This is similar to the first approach but allows routing into tabs directly from outside — linking into specific tabs, skipping a tab-based home screen.
GoRoute(
name: 'home',
path: "/home",
builder: (context, state) {
return const HomeScreen(
key: ValueKey<int>(0),
useRouter: true,
);
},
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => PersistentTabView.router(
navigationShell: navigationShell,
tabs: tabs,
navBarBuilder: (navBarConfig) => CustomBottomNavBar(
navBarConfig: navBarConfig,
),
stateManagement: false,
handleAndroidBackButtonPress: false,
popAllScreensOnTapAnyTabs: true,
popAllScreensOnTapOfSelectedTab: true,
),
branches: [
StatefulShellBranch(
navigatorKey: navigatorKeys[1],
routes: <RouteBase>[
GoRoute(
path: '/tabs/exone',
name: ExOneScreen.routeName,
builder: (context, state) {
final id = (state.extra as Map?)?['id'] as int?;
if (id == null) {
return const Scaffold(
body: Center(child: Text('Missing ID')),
);
}
return MultiBlocProvider(
providers: [
BlocProvider<ExOneCubit>(
create: (_) => ExOneCubit(ExOneRepository()),
)
],
child: ExOneScreen(
useRouter: true,
id: id,
),
);
},
),
],
),
StatefulShellBranch(
navigatorKey: navigatorKeys[2],
routes: <RouteBase>[
GoRoute(
path: '/tabs/extwo',
name: ExTwoScreen.routeName,
builder: (context, state) {
return const ExTwoScreen(useRouter: true);
},
),
],
),
],
);