Creating a Robust Flutter Persistent Navigation Bar with Nested Navigation using Stacked Router Service
by Isaac Adariku (LinkedIn, Twitter, Website)
I was using the Gmail App the other day and noticed that it had a persistent navigation bar with nested navigation. I noticed the navigation bar was always visible, even when I switched between the Mail and Chat screens. I also saw that when I clicked on an email or chat, it navigated to a different screen, showing the email or chat details. I was impressed by the user experience.
Mobile applications have become an integral part of our daily lives, demanding user-friendly interfaces and effective navigation are required. Navigation enables users to transition seamlessly between different screens within an application. In most cases, a navigation bar proves to be the most efficient way to navigate through an app. This article will demonstrate how to create a robust persistent navigation bar with nested navigation in Flutter using Stacked Router Service.
Understanding Navigation Bars
Flutter, a cross-platform mobile framework developed by Google, provides a built-in widget called NavigationBar for handling navigation. This widget simplifies the navigation between screens but does not manage persistent navigation and nested navigation.
- Persistent Navigation refers to the ability to maintain the state of the navigation bar on the screen, even when switching to a different screen.
- Nested Navigation entails navigating to a different screen from a screen that is already on the navigation stack. To achieve this, we will leverage the Stacked Router Service.
Getting Started
To get started, we will be using a production-ready framework for Flutter called Stacked. Stacked provides us with its powerful CLI, the Stacked CLI, a command-line interface to generate production boilerplate code for our application.
Check out the full source code here.
To install the CLI, run the following command in your terminal:
dart pub global activate stacked_cli
To create a new Flutter project, run:
stacked create app flutter_stacked_nested_navigation -t web
This command generates a Flutter project named flutter_stacked_nested_navigation
. The -t web
flag is used to create a stack architecture starter template with web support, a responsive app setup, and the necessary boilerplate code. This setup will allow us to utilize the StackedRouter in Router Service provided by stacked_services. The Router Service is built on top of Flutter’s Navigator 2.0 APIs.
If you open your main.dart
file, it should have the following code:
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return ResponsiveApp(
builder: (_) => MaterialApp.router(
routerDelegate: stackedRouter.delegate(),
routeInformationParser: stackedRouter.defaultRouteParser(),
),
);
}
}
Project Structure
For our example, we will follow the structure of the Gmail app, which features a persistent navigation bar with nested navigation on the Mail
and Chat
screens. The Mail
screen displays a list of emails, and when you click on an email, it navigates to the MailDetails
screen, showing email details. Similarly, the Chat
screen contains a list of chats, and clicking on a chat navigates to the ChatDetails
screen to show chat details.
The project structure should resemble this:
flutter_stacked_nested_navigation
├── lib
│ ├── app
│ │ ├── app.locator.dart
│ │ ├── app.router.dart
│ │ ├── app.dart
│ ├── main.dart
│ └── ui
│ ├── views
│ │ ├── main
│ │ │ ├── main_view.dart
│ │ │ └── main_viewmodel.dart
│ │ ├── mail
│ │ │ ├── mail_view.dart
│ │ │ └── mail_viewmodel.dart
│ │ ├── mail_details
│ │ │ ├── mail_details_view.dart
│ │ │ └── mail_details_viewmodel.dart
│ │ ├── chat
│ │ │ ├── chat_view.dart
│ │ │ └── chat_viewmodel.dart
│ │ └── chat_details
│ │ ├── chat_details_view.dart
│ │ └── chat_details_viewmodel.dart
│ └── widgets
├── pubspec.lock
└── pubspec.yaml
From the project structure, we can observe the default app
directory contains the app configuration and generated files like routing, dependency injection, etc. The ui
directory houses the views and view models for the application. The main
view includes the persistent navigation bar for the Mail
and Chat
tabs.
To create the Main
,Mail
, Chat
, MailDetails
, and ChatDetails
screens, along with their respective view models, use the Stacked CLI with the following commands:
stacked create view main -t web
stacked create view mail -t web
stacked create view mailDetails -t web
stacked create view chat -t web
stacked create view chatDetails -t web
These commands automatically generate the boilerplate code for the views and view models and register their routes in lib/app/app.dart
.
Implementation
To build this, Stacked offers convenient APIs for creating robust persistent navigation bars with nested navigation. We will use the StackedTabsScaffold
widget to set up multiple navigation stacks. This widget requires a list of NestedRouters
, each specifying a navigation stack, which in our case, is the Mail
and Chat
screen.
Let’s create a new file nested_routers.dart
in lib/app
to define the NestedRouter
classes. Update the lib/app/nested_routers.dart
file:
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
class MailNavigation extends StatelessWidget {
const MailNavigation({super.key});
@override
Widget build(BuildContext context) {
return const NestedRouter();
}
}
class ChatNavigation extends StatelessWidget {
const ChatNavigation({super.key});
@override
Widget build(BuildContext context) {
return const NestedRouter();
}
}
Next, let’s update the @StackedApp
annotation in our lib/app/app.dart
file to include the MailNavigation
and ChatNavigation
routes as children of the MainView
route:
@StackedApp(
routes: [
CustomRoute(page: StartupView, initial: true),
CustomRoute(
page: MainView,
children: [
CustomRoute(
page: MailNavigation,
children: [
CustomRoute(
page: MailView,
initial: true,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: MailDetailsView,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
],
),
CustomRoute(
page: ChatNavigation,
children: [
CustomRoute(
page: ChatView,
initial: true,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: ChatDetailsView,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
],
),
],
),
// @stacked-route
Here are a few key points to note:
- We use the
CustomRoute
API to set up our routes, providing flexibility and pre-builtTransitionsBuilders
for setting up transitions. There are other route types that you can use e.g.MaterialRoute
,AdaptiveRoute
,CupertinoRoute
etc. - The
initial
property sets the initial route for each navigation stack, which is displayed when the navigation stack is first loaded. - The
transitionsBuilder
property configures transitions for each route. TheTransitionsBuilders
API provides various transition options, such asfade
,slideLeft
,slideRight
,slideTop
,slideBottom
, etc.
The resulting route hierarchy appears as follows:
StackedRouter
|_ StartupView (initial)
|_ MainView
|_ MailNavigation
|_ MailView (initial)
|_ MailDetailsView
|_ ChatNavigation
|_ ChatView (initial)
|_ ChatDetailsView
- The
MainView
route acts as the parent route for theMailNavigation
andChatNavigation
routes. We use thechildren
property to establishMailNavigation
andChatNavigation
as children of theMainView
route, allowing us to create multiple navigation stacks.
Check out the full source code here.
The Superpower of Stacked CLI 🔌
Running the stacked generate
the command generates the necessary boilerplate code for our routes. Use the following command:
stacked generate
After generating the code, open the lib/ui/views/main/main_view.mobile.dart
file and update it with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_stacked_nested_navigation/app/app.router.dart';
import 'package:stacked/stacked.dart';
...
...
@override
Widget build(BuildContext context, MainViewModel viewModel) {
return StackedTabsScaffold(
routes: const [
MailNavigationRoute(),
ChatNavigationRoute(),
],
bottomNavigationBuilder: (_, tabsRouter) => NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
destinations: [
NavigationDestination(
icon: Icon(
tabsRouter.activeIndex == 0 //
? Icons.mail //
: Icons.mail_outlined,
),
label: 'Mail',
),
NavigationDestination(
icon: Icon(
tabsRouter.activeIndex == 1
? Icons.chat_bubble_rounded
: Icons.chat_bubble_outline_rounded,
),
label: 'Chat',
),
],
),
);
}
Note that the StackedTabsScaffold
provides a bottomNavigationBuilder
property that allows you to easily create your navigation bar using the tabsRouter
passed as a parameter. The TabsRouter
permits you to access the active index of the navigation bar with the activeIndex
property and set the active index with the setActiveIndex
method.
We use the Flutter NavigationBar
widget, which simplifies navigation UI and manages transitions automatically.
Implementation Summary
At this point, if you run the app, you will find that persistent nested navigation works seamlessly. All states are maintained when navigating between the Mail
and Chat
screens and nested navigation functions smoothly.
Handling Wider Viewports with ResponsiveBuilder and Flutter NavigationRail 🖥️📱 (Responsive UI)
Flutter is a cross-platform framework that allows us to write high-performance applications for any screen. While theNavigationBar
widget works fine on mobile devices, it is less suitable for larger screens such as tablets and desktops. To address this, we will make the UI responsive using NavigationRail and ResponsiveBuilder.
As we create views with the Stacked CLI, it automatically generates code for responsive UI using the ScreenTypeLayout builder. The ScreenTypeLayout.builder
enables us to build different UIs for various screen types, such as mobile, tablet, and desktop.
Create a NavigationRail for the desktop and tablet UI
Create a new file called main_navigation_rail.dart
in the lib/ui/views/main
directory and update with the following code:
import 'package:flutter/material.dart';
import 'package:nested_navigation/app/app.router.dart';
import 'package:stacked/stacked.dart';
class StackedScaffoldWithNavigationRail extends StatelessWidget {
const StackedScaffoldWithNavigationRail({super.key});
@override
Widget build(BuildContext context) {
return StackedTabsScaffold(
routes: const [
MailNavigationRoute(),
ChatNavigationRoute(),
],
builder: (context, child, animate) {
final tabsRouter = context.tabsRouter;
return Row(
children: [
NavigationRail(
selectedIndex: tabsRouter.activeIndex,
minExtendedWidth: 150,
onDestinationSelected: tabsRouter.setActiveIndex,
labelType: NavigationRailLabelType.all,
destinations: [
NavigationRailDestination(
icon: Icon(
tabsRouter.activeIndex == 0 //
? Icons.mail //
: Icons.mail_outlined,
),
label: const Text('Mail'),
),
NavigationRailDestination(
icon: Icon(
tabsRouter.activeIndex == 1
? Icons.chat_bubble_rounded
: Icons.chat_bubble_outline_rounded,
),
label: const Text('Chat'),
),
],
),
Expanded(child: child),
],
);
},
);
}
}
Update the widget build
override in lib/ui/views/main/main_view.desktop.dart
and lib/ui/views/main/main_view.tablet.dart
files as follows:
import 'main_navigation_rail.dart';
@override
Widget build(BuildContext context, MainViewModel viewModel) {
return const StackedScaffoldWithNavigationRail();
}
In this update, we use the StackedScaffoldWithNavigationRail
widget to create the UI for desktop and tablet devices. This widget provides a builder
property that allows you to build your own navigation bar using the tabsRouter
passed as a parameter. The tabsRouter
permits you to access the active index of the navigation bar using the activeIndex
property and set the active index using the setActiveIndex
method.
We then use the Flutter NavigationRail widget, which simplifies navigation UI and automatically handles destination switching.
🎉 And that’s it! You have successfully created a robust persistent navigation bar with nested navigation in Flutter using the Stacked Router Service.
Check out the full source code here.
Bonus 🎁
Let’s quickly address some edge cases you might encounter and answer common questions.
Handling the Back Button on Android Devices 🏃♂️
Android’s operating system automatically pops the current screen off the navigation stack when you press the back button. However, if you are at the top level of the navigation stack, the app closes. This behaviour may not be ideal for your app, especially if you are not on the first index of the navigation bar. In this case, you want the back button to only exit the app when you are on the first index of the navigation bar. To achieve this, use the WillPopScope widget.
Update the lib/ui/views/main/main_view.mobile.dart
file with the following code:
class MainViewMobile extends ViewModelWidget<MainViewModel> {
const MainViewMobile({super.key});
@override
Widget build(BuildContext context, MainViewModel viewModel) {
return StackedTabsScaffold(
routes: const [
MailNavigationRoute(),
ChatNavigationRoute(),
],
bottomNavigationBuilder: (_, tabsRouter) => WillPopScope(
onWillPop: () async {
if (tabsRouter.activeIndex != 0) {
tabsRouter.setActiveIndex(0);
return false;
}
return true;
},
child: NavigationBar(
....
....
....
),
),
);
}
}
Wrap the NavigationBar
widget with the WillPopScope
widget. The WillPopScope
widget provides a onWillPop
property, that allows you to handle the back button press. Use the tabsRouter
to check if you are on the first index of the navigation bar. If not, set the active index to the first index and return false
, preventing the app from closing. If you are on the first index, return true
, allowing the app to close.
Handling (Pop to Root) 🖥️📱
In many applications, users expect to return to the root of the navigation stack when clicking the navigation bar icon for the current screen. This is the behaviour in the Gmail web and Android app, for example. However, this is not the default behaviour of the RouterService
. To achieve this, update the onDestinationSelected
in lib/ui/views/main/main_view.mobile.dart
and lib/ui/views/main/main_navigation_rail.dart
file with the following code:
onDestinationSelected: (index) {
if (tabsRouter.activeIndex == index) {
tabsRouter.stackRouterOfIndex(index)?.popUntilRoot();
} else {
tabsRouter.setActiveIndex(index);
}
},
Use the tabsRouter
to check if you are on the same index as the selected navigation bar icon. If you are, call popUntilRoot()
on the stack router of that index to return to the root. If you are not on the same index, use setActiveIndex
to set the active index to the selected one. This behaviour achieves “Pop to Root.”
Handling Navigation on the Web 🕸️
Handling nested navigation on the web is straightforward; it works out of the box. The StackedRouter
automatically manages this, you can navigate using the browser’s back and forward buttons, as well as through URL changes. The browser remembers the navigation stack’s history.
For improved route naming, you can use the path
property of the CustomRoute
. Update the lib/app/app.dart
file with the following code:
@StackedApp(
routes: [
CustomRoute(page: StartupView, initial: true),
CustomRoute(
page: MainView,
path: '/dashboard',
children: [
CustomRoute(
page: MailNavigation,
path: 'inbox',
children: [
CustomRoute(
page: MailView,
initial: true,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: MailDetailsView,
path: ':mailId',
transitionsBuilder: TransitionsBuilders.fadeIn,
),
],
),
CustomRoute(
page: ChatNavigation,
path: 'chat',
children: [
CustomRoute(
page: ChatView,
initial: true,
transitionsBuilder: TransitionsBuilders.fadeIn,
),
CustomRoute(
page: ChatDetailsView,
path: ':chatId',
transitionsBuilder: TransitionsBuilders.fadeIn,
),
],
),
],
),
// @stacked-route
Notice The path
property also allows you to specify route parameters, such as :mailId
and :chatId
, which are used to generate dynamic URLs. For example, the URL for the MailDetails
screen will be /dashboard/inbox/:mailId
, where :mailId
is the ID of the email.
We will then run stacked generate
and update the MailDetailsView
and ChatDetailsView
with a parameter decorator @pathParam
to display the ID of the email or chat. Update the lib/ui/views/mail_details/mail_details_view.dart
and lib/ui/views/chat_details/chat_details_view.dart
files with the following code:
class ChatDetailsView extends StackedView<ChatDetailsViewModel> {
const ChatDetailsView(@pathParam this.chatId, {super.key});
final String chatId;
....
....
}
class MailDetailsView extends StackedView<MailDetailsViewModel> {
const MailDetailsView(@pathParam this.mailId, {super.key});
final String mailId;
....
....
}
Finally
We’ve explored creating a robust persistent navigation bar with nested navigation in Flutter using the Stacked Router Service. We’ve covered all the necessary steps, from project setup to handling edge cases. The end result is a versatile and user-friendly mobile application that adapts seamlessly to different screen sizes. With the Stacked Router Service, Flutter development becomes even more efficient and enjoyable. I hope it continues to improve with more clear documentation.
I hope you enjoyed this article 😊 Until next time, 💙 Happy Fluttering! 💙
Help contribute to this article by:
📢 Spreading it with your friends, family, and colleagues.
👏🏾 Tapping the clap button below to show your appreciation and motivation.
📝 Sharing your thoughts and inquiries by leaving a comment below.
➕ Follow me on Medium to receive notifications when I release new articles.
😍 Connecting with me on Twitter and LinkedIn and I would be delighted to provide assistance.
Every day is another opportunity to gain Mastery! 💙 — Isaac Adariku (Eternity)