Integrating Bottom Navigation with Go Router in Flutter

onat çipli
Flutter Community
Published in
9 min readSep 20, 2023
Photo by Jaromír Kavan on Unsplash

TLDR: This article explores the powerful features of Go Router, a routing library for Flutter. It showcases how Go Router facilitates advanced navigation within tabs, including root navigator and preserving tab-specific state. The article emphasizes the benefits of using Go Router for seamless and efficient navigation in complex Flutter applications.

Introduction

Routing is one of the most essential parts of any Flutter application. While Flutter offers a built-in navigation system, third-party packages like Go Router provide an enhanced, more intuitive way to manage your app’s routes. In this article, we’ll explore how to integrate Go Router with BottomNavigation in Flutter using a Singleton helper class.

What is Routing?

Routing is the mechanism that handles the navigation between different screens in your application. In Flutter, this is often managed through the Navigator 2.0 API, which allows for a flexible and straightforward way to handle transitions between pages.

Why Choose Go Router?

Selecting a routing package is a critical decision in the Flutter development process, and Go Router offers a multitude of features that make it a compelling choice. Here's why you might consider using Go Router in your next Flutter project:

  • Declarative Syntax: Go Router uses a declarative syntax, making your routing logic easy to read and manage. This intuitive syntax simplifies route definition, making it easier for developers to get started. You can see all of your routes(pages) in one place.
final routes = [
GoRoute(
parentNavigatorKey: parentNavigatorKey,
path: signUpPath,
pageBuilder: (context, state) {
return getPage(
child: const SignUpPage(),
state: state,
);
},
),
GoRoute(
parentNavigatorKey: parentNavigatorKey,
path: signInPath,
pageBuilder: (context, state) {
return getPage(
child: const SignInPage(),
state: state,
);
},
),
GoRoute(
path: detailPath,
pageBuilder: (context, state) {
return getPage(
child: const DetailPage(),
state: state,
);
},
),
];
  • Named Routes: Having named routes provides an extra layer of abstraction that makes your routes easier to manage. It allows you to centralize your routing logic, making your codebase easier to maintain and refactor.
....
static const String signUpPath = '/signUp';
static const String signInPath = '/signIn';
static const String detailPath = '/detail';
....
  • Type Safety: Go Router is designed with type safety in mind. This ensures that you are less likely to run into runtime errors due to type mismatches, making your application more robust and maintainable.
  GoRoute(
path: '/details/:id', // :id is a parameter
pageBuilder: (context, GoRouterState state) {
// Access parameters and enforce type safety using type casting
final id = int.tryParse(state.params['id'] ?? '') ?? -1;

// Given that `id` is now an integer, you can proceed safely
return getPage(
child: DetailsPage(id: id),
state: state,
);
},
),
  • Deep Linking: Deep linking is a feature that allows you to navigate to a particular screen or state within your app through a URL. Go Router supports deep linking out-of-the-box, enabling more sophisticated navigation scenarios and improved user experiences.
  • Redirection: Go Router supports route redirection, a useful feature when you want to reroute users based on specific conditions like authentication status. This feature provides additional flexibility when handling complex routing scenarios.
GoRoute(
path: '/dashboard',
pageBuilder: (context, state) => MaterialPage(child: DashboardScreen()),
// Redirects to login if not authenticated
redirect: (state) {
if (!isUserAuthenticated()) {
return '/login';
}
return null;
},
),
  • Parameter Passing: Go Router simplifies passing parameters between routes, including complex data types. This is managed without the need for serialization or parsing, making the exchange of data between routes seamless.

Setting Up Go Router in Flutter

To get started with Go Router, we first need to add the package to our Flutter project. This can be done by using the following command:

flutter pub add go_router

This command adds the latest version of the Go Router package to your project. Now, let’s delve into the heart of our routing setup — the CustomNavigationHelper class.

Initialization and Singleton Design Pattern

In Flutter, it’s often beneficial to have a centralized navigation helper, especially if you’re working with multiple routes and navigators. Here, we implement the singleton pattern for our navigation helper

class CustomNavigationHelper {
static final CustomNavigationHelper _instance =
CustomNavigationHelper._internal();

static CustomNavigationHelper get instance => _instance;
factory CustomNavigationHelper() {
return _instance;
}

CustomNavigationHelper._internal() {
// Router initialization happens here.
}
}

Explanation: The above code defines a singleton instance of the CustomNavigationHelper. This ensures that only a single instance of this class is created and shared throughout the application.

Defining Navigator Keys

For advanced navigation setups, especially when dealing with multiple navigators or tabs, Flutter requires unique keys. We define these keys at the beginning of our helper. If you don’t add these keys there could be some issues in Navigation or Transition animations so don’t forget to add keys.

static final GlobalKey<NavigatorState> parentNavigatorKey =
GlobalKey<NavigatorState>();

static final GlobalKey<NavigatorState> homeTabNavigatorKey =
GlobalKey<NavigatorState>();
// ... other navigator keys

Explanation: Each key represents a unique navigator. For example, homeTabNavigatorKey is specifically for the home tab's navigation stack.

Routes Declaration

Routes guide our application on how to navigate between different screens. In Go Router, routes are represented by instances of GoRoute. Let's see a basic example:

static const String homePath = '/home';

// ... inside the CustomNavigationHelper._internal()
final routes = [
GoRoute(
path: homePath,
pageBuilder: (context, GoRouterState state) {
return getPage(
child: const HomePage(),
state: state,
);
},
),
// ... other routes
];

Explanation: The homePath is a string that represents the route for the HomePage. In the routes list, we associate this string with a page builder, which returns the HomePage widget when this route is navigated to.

Initializing the Go Router

After defining our routes, we initialize the GoRouter:

// ... at the end of the CustomNavigationHelper._internal()
// ... after we declared our routes

router = GoRouter(
navigatorKey: parentNavigatorKey,
initialLocation: signUpPath,
routes: routes,
);

Explanation: The GoRouter takes in a list of routes we've defined, an initial location (the page the app opens on first launch), and a navigator key.

Helper Function: getPage

The getPage function is a utility function to wrap our routes in a MaterialPage:

static Page getPage({
required Widget child,
required GoRouterState state,
}) {
return MaterialPage(
key: state.pageKey,
child: child,
);
}

Explanation: This function takes a widget (the page we want to navigate to) and a state, returning a MaterialPage that wraps our widget. This makes our route definitions cleaner and more readable. In this part its important to pass state.pageKey to MaterialPage for go router. It is preventing the weird animations or any confusion in the routing

Using the CustomNavigationHelper

Finally, in our main app widget, we can utilize the CustomNavigationHelper to set up our application with the Go Router, It is important the first initialize the CustomNavigationHelper because we need to set the our routes before our app starts and you can do that by simply accessing the CustomNavigationHelper.instance or CustomNavigationHelper() both will be same since its a Singleton class.

main() {
CustomNavigationHelper.instance; // Initializing our navigation helper
runApp(const App());
}
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
routerConfig: CustomNavigationHelper.router,
);
}
}

Explanation: In our main function, we first initialize the CustomNavigationHelper, and then run our main app widget. Inside the App widget, we use the MaterialApp.router constructor and provide it with our GoRouter configuration.

Advanced Routing with StatefulShellRoute, StatefulShellBranch, and PageBuilder

StatefulShellRoute.indexedStack with PageBuilder

In the StatefulShellRoute.indexedStack named constructor, one of the important properties is pageBuilder. The pageBuilder is responsible for rendering the UI for the Shell part and its child.

StatefulShellRoute.indexedStack(
parentNavigatorKey: parentNavigatorKey,
branches: [
// Branches defined here...
],
pageBuilder: (
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return getPage(
child: BottomNavigationPage(
child: navigationShell,
),
state: state,
);
},
)

Explanation: Here, the pageBuilder function takes three arguments:

  • BuildContext context: The build context.
  • GoRouterState state: The current router state.
  • StatefulNavigationShell navigationShell: A custom object that holds the navigation state for the child branches.

The function returns a page that contains our BottomNavigationPage, which in turn holds the navigationShell. This way, the BottomNavigationPage becomes a shell for the child branches, effectively becoming the bottom navigation interface for the entire app. Also we have AppBar in BottomNavigation page so the child will be in the body only.

The Role of PageBuilder in StatefulShellBranch’s routes

In each StatefulShellBranch, and for its routes each GoRoute class have the pageBuilder property is also used but for a different purpose.

StatefulShellBranch(
navigatorKey: homeTabNavigatorKey,
routes: [
GoRoute(
path: homePath,
pageBuilder: (context, GoRouterState state) {
return getPage(
child: const HomePage(),
state: state,
);
},
),
],
)

Explanation: Within the StatefulShellBranch, the pageBuilder is responsible for defining what UI is displayed for each individual route within the branch. This is how each tab can have its own navigation stack, separate from the others.

By using the pageBuilder property wisely in both StatefulShellRoute.indexedStack and StatefulShellBranch routes GoRoute we ensure a robust, modular, and efficient routing mechanism that works perfectly with bottom navigation.

In summary, the pageBuilder in StatefulShellRoute.indexedStack serves as a shell to host the bottom navigation interface, while the pageBuilder in each StatefulShellBranch takes care of the individual pages that are part of each tab's navigation stack. This layered approach allows for a seamless user experience

How BottomNavigationPage and Go Router Work Together

Now let’s look at the BottomNavigationPage class and how it works in tandem with the CustomNavigationHelper.

The BottomNavigationPage Class

This widget is essentially a stateful wrapper for your bottom navigation bar. It takes a StatefulNavigationShell as its child.

class BottomNavigationPage extends StatefulWidget {
const BottomNavigationPage({
super.key,
required this.child,
});

final StatefulNavigationShell child;
// ...
}

Handling Navigation Changes

Inside the BottomNavigationPage, we manage the state of the bottom navigation by using widget.child.goBranch method.

onTap: (index) {
widget.child.goBranch(
index,
initialLocation: index == widget.child.currentIndex,
);
setState(() {});
},

Explanation: When a tab is tapped, the goBranch method of the StatefulNavigationShell child is called. This method changes the current branch, hence switching the navigation stack. After that, setState is called to rebuild the UI with the new branch.

Combining Both Worlds

When setting up the StatefulShellRoute.indexedStack, you pass it as the child to BottomNavigationPage.

pageBuilder: (
BuildContext context,
GoRouterState state,
StatefulNavigationShell navigationShell,
) {
return getPage(
child: BottomNavigationPage(
child: navigationShell,
),
state: state,
);
},

Explanation: The pageBuilder in StatefulShellRoute.indexedStack returns a MaterialPage that contains the BottomNavigationPage. This allows Go Router to seamlessly manage the routes while still providing a custom bottom navigation experience.

By combining CustomNavigationHelper with BottomNavigationPage, you not only manage your routes dynamically but also maintain individual navigation stacks for each tab in your bottom navigation. This results in a clean, modular, and easy-to-manage navigation setup in your Flutter application.

The BottomNavigationPage as a UI Shell

What is a Shell?

In application development, a “shell” is often a component that hosts other parts of the application’s UI. It provides a consistent layout or set of interactions. In the context of our Flutter app, both the BottomNavigationPage and the AppBar act as shells.

BottomNavigationPage Explained

The BottomNavigationPage class is a StatefulWidget that takes a StatefulNavigationShell as a required parameter. This shell contains the navigation state for the individual branches (tabs) that are managed by the StatefulShellRoute.indexedStack.

....
class _BottomNavigationPageState extends State<BottomNavigationPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bottom Navigator Shell'),
),
body: SafeArea(
child: widget.child,
),
bottomNavigationBar: BottomNavigationBar(
....
),
);
}
}

Here’s a breakdown of its main components:

AppBar

The AppBar in the Scaffold provides a consistent top section across different pages. It hosts the title, which in our example is set to 'Bottom Navigator Shell'.

Body

The body of the Scaffold is set to SafeArea(child: widget.child). Here, widget.child is the StatefulNavigationShell passed into this widget, which in turn contains the current page based on the active tab. The SafeArea ensures that the UI doesn't overlap any system interfaces.

BottomNavigationBar

The BottomNavigationBar is where things get more interactive. It not only displays the available tabs but also handles tab switching.

BottomNavigationBar(
type: BottomNavigationBarType.fixed,
currentIndex: widget.child.currentIndex,
onTap: (index) {
widget.child.goBranch(
index,
initialLocation: index == widget.child.currentIndex,
);
setState(() {});
},
...
)

Here, currentIndex is fetched from StatefulNavigationShell to indicate which tab is currently active. When a tab is tapped, the onTap method uses goBranch from StatefulNavigationShell to switch to the appropriate branch. The setState(() {}) ensures that the UI is rebuilt to reflect the changes.

How it All Ties Together

  1. AppBar gives a constant top shell.
  2. BottomNavigationBar acts as the bottom shell and also as the controller for switching between different tabs.
  3. Body displays the current page that corresponds to the active tab, thus ensuring a seamless user experience.

In essence, the BottomNavigationPage serves as a cohesive shell that wraps around the navigational aspects and the individual pages of the application. It leverages the StatefulNavigationShell to maintain and manage the navigation state, thus facilitating a sophisticated yet straightforward user interface.

Complete Example

The following code snippet provides a comprehensive example of how to use Go Router in a Flutter app, showcasing features like route definition and StatefulShellRoute and other things like how to navigate from root navigator or from the shell etc.

You can find the complete source code for this example on GitHub Gist.

Additionally, if you’d like to try running this app, you can do so on DartPad via this live test link.

--

--

onat çipli
Flutter Community

Software Engineer who enjoys creating mobile applications with Flutter and Dart