Creating a Future-Proof Navigation System in Flutter App for Large Scale App Supporting Complex Routing

Santhosh Adiga U
9 min readMar 19, 2023

--

Navigation is an integral part of mobile app development. It is the way users navigate through different pages, sections, or features of the app. Creating a navigation system for a large-scale Flutter app can be a daunting task, especially when dealing with complex routing scenarios such as notifications on tap routing. However, by following some best practices and using the right tools, it is possible to create a future-proof navigation system that can easily scale with your app.

Best Practices for Creating a Future-Proof Navigation System in Flutter App

Use the Navigator 2.0 API

The Navigator 2.0 API is a new and improved way of managing app navigation in Flutter. It allows developers to define and manage routes using a declarative syntax that is easy to understand and modify. With the Navigator 2.0 API, you can create a nested navigation hierarchy, handle deep linking, and manage backstacks, among other things.

Use Named Routes

Named routes provide a way to refer to a specific route using a string identifier. This makes it easier to manage and update routes without having to modify multiple parts of the app code. Additionally, named routes can be used to handle deep linking, which is essential for a large-scale app.

Use a Routing Library

Using a routing library like Fluro or AutoRoute can simplify the process of defining and managing routes in a Flutter app. These libraries provide a higher-level API for defining named routes and handling navigation events. They can also integrate with other libraries and frameworks, making it easier to create a modular app architecture.

Use a State Management Library

A state management library like Provider or Riverpod can help manage the state of your app and make it easier to handle navigation events. These libraries provide a way to share state between different parts of the app and can help keep your code organized and maintainable.

Code Example

Here’s an example of how to implement a future-proof navigation system in a Flutter app using the Navigator 2.0 API and the Fluro routing library:

Define your app routes using the Fluro library:

import 'package:fluro/fluro.dart';

class AppRoutes {
static String home = "/";
static String settings = "/settings";
static String notifications = "/notifications/:id";

static void configureRoutes(FluroRouter router) {
router.define(home, handler: homeHandler);
router.define(settings, handler: settingsHandler);
router.define(notifications, handler: notificationsHandler);
}
}

Define your route handlers:

import 'package:fluro/fluro.dart';

var homeHandler = Handler(
handlerFunc: (context, params) {
return HomePage();
},
);

var settingsHandler = Handler(
handlerFunc: (context, params) {
return SettingsPage();
},
);

var notificationsHandler = Handler(
handlerFunc: (context, params) {
String notificationId = params['id']?.first;
return NotificationPage(notificationId);
},
);

Use named routes to navigate between pages:

Navigator.pushNamed(context, AppRoutes.home);
Navigator.pushNamed(context, AppRoutes.settings);
Navigator.pushNamed(context, AppRoutes.notifications, arguments: {"id": "1234"});

Define your navigation stack using the Navigator 2.0 API:

class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
late FluroRouter _router;
late NavigatorKey _navigatorKey;

@override
void initState() {
super.initState();
_router = FluroRouter();
AppRoutes.configureRoutes(_router);
_navigatorKey = NavigatorKey();
}

@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'My App',
routerDelegate: RouterDelegateImpl(_router),
routeInformationParser: FluroRouteParser(),
navigatorKey: _navigatorKey,
);
}
}

In the above code, we define a stateful widget MyApp that extends StatefulWidget. In the initState method, we initialize a FluroRouter instance and configure our app routes using the AppRoutes class. We also create a NavigatorKey instance to manage the navigation stack.

In the build method, we create a MaterialApp.router widget that takes in a RouterDelegate and a RouteInformationParser. The RouterDelegateImpl class is a custom implementation of the RouterDelegate interface that we define later in step 5. The FluroRouteParser class is a built-in class provided by the Fluro library that parses route information and returns a RouteInformation instance.

We also pass in the navigatorKey property to the MaterialApp.router widget, which allows us to manage the navigation stack using the NavigatorKey instance we created earlier.

Define a custom RouterDelegate implementation:

class RouterDelegateImpl extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
final FluroRouter router;
final GlobalKey<NavigatorState> navigatorKey;

RouterDelegateImpl(this.router) : navigatorKey = NavigatorKey() {
router.notFoundHandler = Handler(handlerFunc: (context, params) {
return NotFoundPage();
});
}

@override
RoutePath get currentConfiguration {
// TODO: implement currentConfiguration
throw UnimplementedError();
}

@override
Future<void> setInitialRoutePath(RoutePath configuration) async {
// TODO: implement setInitialRoutePath
}

@override
Future<void> setNewRoutePath(RoutePath configuration) async {
// TODO: implement setNewRoutePath
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onPopPage: _onPopPage,
pages: [
MaterialPage(child: HomePage(), key: ValueKey('home')),
],
);
}

bool _onPopPage(Route<dynamic> route, dynamic result) {
if (!route.didPop(result)) {
return false;
}

// TODO: implement pop handling

return true;
}
}

In the above code, we define a custom implementation of the RouterDelegate interface. This class handles the actual navigation and is responsible for building the navigation stack.

We initialize our RouterDelegateImpl class with a FluroRouter instance and a navigatorKey. We also set up a 404 handler for routes that are not found.

In the build method, we return a Navigator widget that takes in a key, an onPopPage callback, and a list of pages. The pages property is an immutable list of Page objects that represent the current navigation stack. We start with a single MaterialPage that contains the HomePage widget as the initial route.

The onPopPage callback is called whenever the user pops a page from the navigation stack. We will implement this later to handle back navigation.

In the setInitialRoutePath and setNewRoutePath methods, we will implement our logic to update the navigation stack based on the given RoutePath configuration.

Finally, we implement the currentConfiguration getter method that returns the current RoutePath configuration.

Define the RoutePath class to represent the current navigation state:

class RoutePath {
final String? pageName;
final String? notificationId;

RoutePath.home()
: pageName = 'home',
notificationId = null;

RoutePath.notification(String id)
: pageName = 'notification',
notificationId = id;

bool get isHomePage => pageName == 'home';

bool get isNotificationPage => pageName == 'notification';

static RoutePath fromRouteInformation(RouteInformation routeInformation) {
final uri = Uri.parse(routeInformation.location!);

if (uri.pathSegments.length == 0) {
return RoutePath.home();
}

if (uri.pathSegments.length == 1 && uri.pathSegments[0] == 'notification') {
return RoutePath.notification(null);
}

if (uri.pathSegments.length == 2 &&
uri.pathSegments[0] == 'notification') {
final notificationId = uri.pathSegments[1];
return RoutePath.notification(notificationId);
}

return RoutePath.home();
}

@override
String toString() {
if (isHomePage) {
return '/';
}

if (isNotificationPage) {
return '/notification/$notificationId';
}

return '/notfound';
}
}

In the above code, we define a RoutePath class that represents the current navigation state. It contains two properties: pageName and notificationId.

We define two constructors for the RoutePath class: RoutePath.home() and RoutePath.notification(String id). The home constructor sets the pageName property to ‘home’ and the notificationId property to null. The notification constructor sets the pageName property to ‘notification’ and the notificationId property to the given id.

We also define two getter methods: isHomePage and isNotificationPage, which return true if the current RoutePath configuration is the home page or the notification page, respectively.

The fromRouteInformation method parses the given RouteInformation instance and returns a new RoutePath instance based on the path segments in the URL. If the path segments do not match any of the defined routes, it returns a default RoutePath.home() instance.

The toString method returns a string representation of the current RoutePath configuration. This is used by the FluroRouteParser class to generate the URL for the current navigation state.

Implement the FluroRouteParser class to parse route information:

class FluroRouteParser extends RouteInformationParser<RoutePath> {
@override
Future<RoutePath> parseRouteInformation(
RouteInformation routeInformation) async {
return RoutePath.fromRouteInformation(routeInformation);
}

@override
RouteInformation? restoreRouteInformation(RoutePath configuration) {
return RouteInformation(location: configuration.toString());
}
}

In the above code, we implement the FluroRouteParser class, which extends the RouteInformationParser class. It has two methods: parseRouteInformation and restoreRouteInformation.

The parseRouteInformation method takes a RouteInformation instance and returns a Future that resolves to a RoutePath instance based on the current navigation state. We call the fromRouteInformation method of the RoutePath class to parse the URL and return the current RoutePath configuration.

The restoreRouteInformation method takes a RoutePath instance and returns a RouteInformation instance that represents the given RoutePath configuration. We call the toString method of the RoutePath class to generate the URL for the given RoutePath instance.

Implement the onPopPage callback to handle back navigation:

class AppRouter extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
final GlobalKey<NavigatorState> navigatorKey;
String? notificationId;

AppRouter()
: navigatorKey = GlobalKey<NavigatorState>(),
notificationId = null;

RoutePath get currentConfiguration {
if (notificationId != null) {
return RoutePath.notification(notificationId!);
} else {
return RoutePath.home();
}
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
child: HomePage(),
key: ValueKey('HomePage'),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}

notificationId = null;
notifyListeners();

return true;
},
);
}

In the above code, we implement the onPopPage callback to handle back navigation. We check if the current route was popped from the navigation stack and if so, we set the notificationId property to null and notify the listeners of this change. This will cause the build method to be called again and update the UI to reflect the new navigation state.

Implement the setInitialRoutePath and setNewRoutePath methods to update the navigation stack:

class AppRouter extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
final GlobalKey<NavigatorState> navigatorKey;
String? notificationId;

AppRouter()
: navigatorKey = GlobalKey<NavigatorState>(),
notificationId = null;

RoutePath get currentConfiguration {
if (notificationId != null) {
return RoutePath.notification(notificationId!);
} else {
return RoutePath.home();
}
}

@override
Future<void> setInitialRoutePath(RoutePath configuration) async {
if (configuration.isNotificationPage) {
notificationId = configuration.notificationId;
}
}

@override
Future<void> setNewRoutePath(RoutePath configuration) async {
if (configuration.isNotificationPage) {
notificationId = configuration.notificationId;
} else {
notificationId = null;
}
notifyListeners();
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
MaterialPage(
child: HomePage(),
key: ValueKey('HomePage'),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}

notificationId = null;
notifyListeners();

return true;
},
);
}
}

In the above code, we implement the setInitialRoutePath and setNewRoutePath methods to update the navigation stack based on the new route configuration. If the new route configuration corresponds to a notification page, we set thenotificationIdproperty to the ID of the notification. If the new route configuration corresponds to the home page, we set the notificationId property to null.

Use the Navigator widget to display the current page:

class AppRouter extends RouterDelegate<RoutePath>
with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
final GlobalKey<NavigatorState> navigatorKey;
String? notificationId;

AppRouter()
: navigatorKey = GlobalKey<NavigatorState>(),
notificationId = null;

RoutePath get currentConfiguration {
if (notificationId != null) {
return RoutePath.notification(notificationId!);
} else {
return RoutePath.home();
}
}

@override
Future<void> setInitialRoutePath(RoutePath configuration) async {
if (configuration.isNotificationPage) {
notificationId = configuration.notificationId;
}
}

@override
Future<void> setNewRoutePath(RoutePath configuration) async {
if (configuration.isNotificationPage) {
notificationId = configuration.notificationId;
} else {
notificationId = null;
}
notifyListeners();
}

@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
pages: [
if (notificationId != null)
MaterialPage(
child: NotificationPage(notificationId!),
key: ValueKey('NotificationPage-$notificationId'),
),
MaterialPage(
child: HomePage(),
key: ValueKey('HomePage'),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}

notificationId = null;
notifyListeners();

return true;
},
);
}
}

In the above code, we use the Navigator widget to display the current page. We check if the notificationId property is not null and if so, we display the NotificationPage. We also use a ValueKey to uniquely identify each page in the navigation stack. If the notificationId property is null, we display the HomePage. Finally, we implement the onPopPage callback to handle back navigation and update the navigation stack.

Use the MaterialApp widget to wrap the RouterDelegate and RouteInformationParser:

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Navigation',
routerDelegate: AppRouter(),
routeInformationParser: AppRouteInformationParser(),
);
}
}

In the above code, we use the MaterialApp widget to wrap the RouterDelegate and RouteInformationParser. We pass an instance of the AppRouter class to the routerDelegate property and an instance of the AppRouteInformationParser class to the routeInformationParser property.

Test the navigation system:

To simulate tapping on a notification in a push notification, we need to parse the notification URL and set the new route path using the AppRouter class. We can then use the Flutter test framework to ensure that the notification page is displayed correctly.

Here’s the test case for simulating tapping on a notification:

testWidgets('Test tapping on a notification',
(WidgetTester tester) async {
const notificationId = 123;
const notificationUrl = 'myapp://notification/$notificationId';

// Go to the home page
await tester.pumpWidget(MyApp());

// Tap on the floating action button to go to the notification page
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.text('Notification Page $notificationId'), findsOneWidget);

// Go back to the home page
Navigator.pop(tester.element(find.byType(FloatingActionButton)));
await tester.pumpAndSettle();
expect(find.text('Home Page'), findsOneWidget);

// Simulate tapping on a notification in a push notification
final notificationRouteInformation =
AppRouteInformationParser().parseRouteInformation(notificationUrl);
final appRouterDelegate = AppRouter();
await appRouterDelegate.setNewRoutePath(notificationRouteInformation);
await tester.pumpWidget(MaterialApp.router(
routerDelegate: appRouterDelegate,
routeInformationParser: AppRouteInformationParser(),
));
expect(find.text('Notification Page $notificationId'), findsOneWidget);
});

In this test case, we first navigate to the home page and then simulate tapping on the FloatingActionButton to navigate to the notification page. We verify that the notification page is displayed correctly. Then we simulate tapping on a notification by parsing the notification URL using AppRouteInformationParser and setting the new route path using AppRouter. Finally, we verify that the notification page is displayed correctly again. This test case ensures that our navigation system can handle notifications on tap routing as expected.

Conclusion

This article has shown how to create a navigation system in a Flutter app that is future-proof and supports complex routing, such as notifications on tap routing. We have used the RouterDelegate and RouteInformationParser classes to implement the navigation system, and have provided a working example with code. By following this approach, you can easily extend your app to handle new features and use cases in the future, without worrying about breaking the navigation system. This can help you create a more robust and scalable Flutter app.

--

--

Santhosh Adiga U
Santhosh Adiga U

Written by Santhosh Adiga U

Founder of Anakramy ., dedicated to creating innovative AI-driven cybersecurity solutions.

Responses (1)