Integrating Bottom Navigation with Go Router in Flutter
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
- AppBar gives a constant top shell.
- BottomNavigationBar acts as the bottom shell and also as the controller for switching between different tabs.
- 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.