Creating a Robust Flutter Persistent Navigation Bar with Nested Navigation using Stacked Router Service

Eternity (Isaac Adariku)
11 min readOct 13, 2023

--

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.

Example of a persistent navigation bar with nested navigation in Gmail Android App Version 2023.09.17.

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-built TransitionsBuilders 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. The TransitionsBuilders API provides various transition options, such as fade, 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 the MailNavigation and ChatNavigation routes. We use the children property to establish MailNavigation and ChatNavigation as children of the MainView 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.

Preview of the persistent navigation bar with nested navigation. Notice how the state of the text field and the counter is maintained when we navigate between the Mail and Chat screens.
Preview of the persistent navigation bar with nested navigation. Notice how the state of the text field and the counter is maintained when we navigate between the Mail and Chat screens.

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.

Example of a responsive UI that works well on mobile and desktop devices.

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.

Preview of Using the Back Button Behavior on Android Devices — Notice how it doesn’t close the app but jump to the first index.

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.”

Preview of “Pop to Root” Behavior on Mobile — Notice when you click on the navigation bar icon for the current screen, it returns to the root of the navigation stack.
Preview of “Pop to Root” Behavior on Mobile — Notice when you click on the navigation bar icon for the current screen, it returns to the root of the navigation stack.

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 @pathParamto 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;

....
....
}
Preview of Nested Navigation on the Web — notice the URL changes and the back and forward button works just fine.

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)

--

--

Eternity (Isaac Adariku)

GDE Flutter & Dart | Software Craftsman | Organizer FlutterKaduna