DEV Community

RECHIDI AHMED ABDELAAZIZ
RECHIDI AHMED ABDELAAZIZ

Posted on

Flutter Custom Navigation Approach: Utilizing “RouteBase” and “AppNavigator”

Introduction :

Navigation in Flutter is crucial for ensuring smooth transitions between screens. While the standard onGenerateRoute approach works well in many cases, it can become unstructured and difficult to maintain in complex applications. To address these issues, I created a custom navigation approach centered around RouteBase and AppNavigator classes. This method provides greater flexibility, structure, and type safety while simplifying route management.


AppRouter Class

The AppRouter class acts as the central route generator for your application. Instead of using the default onGenerateRoute method provided by Flutter, this custom AppRouter allows you to tie your routes to the RouteBase structure, which gives you more control and flexibility over the navigation logic.

Key Purpose

The AppRouter is responsible for taking a RouteSettings object and generating a corresponding route (using the RouteBase) to display the associated screen. It ensures that each route in the application is consistent, type-safe, and customizable.

Code Breakdown

class AppRouter {
  const AppRouter();

  Route<T> generateRoute<T>(RouteSettings settings) {
    final route = settings.arguments as RouteBase<T>;
    return route.buildRoute();
  }
}
Enter fullscreen mode Exit fullscreen mode

Components:

  1. generateRoute<T> Method

The main functionality of the AppRouter class resides in the generateRoute method. Let's break it down step by step:

Route<T> generateRoute<T>(RouteSettings settings) {
  final route = settings.arguments as RouteBase<T>;
  return route.buildRoute();
}
Enter fullscreen mode Exit fullscreen mode
  • Input: The method takes a RouteSettings object, which is a Flutter object that holds information about the requested route (such as its name and any passed arguments).

  • Route Casting: The method expects the arguments of the RouteSettings to be a RouteBase<T> object. This is the core concept because it leverages your custom RouteBase class (which encapsulates a path, a widget, and a navigator).

  • Route Creation: Once the route is cast to RouteBase<T>, the method calls buildRoute() on the RouteBase instance. This method (as explained in the RouteBase class breakdown) constructs a MaterialPageRoute using the custom navigator (e.g., NormalNavigator or NoPopNavigator) and the associated child widget.

By using AppRouter, you can fully control how routes are built and allow for flexible customization, such as handling different navigators or dynamically adjusting the routes based on arguments.

Example Usage in a Flutter App

When you use AppRouter in the Flutter app's MaterialApp, it would look like this:

MaterialApp(
  onGenerateRoute: (RouteSettings settings) {
    return AppRouter().generateRoute(settings);
  },
);
Enter fullscreen mode Exit fullscreen mode

Here, every time the app navigates to a new route using the Navigator, it calls the AppRouter.generateRoute method, ensuring that your custom navigation logic is applied consistently across all routes.

Benefits of AppRouter:

  1. Customizable Navigation: The main advantage is the ability to integrate RouteBase with custom navigators (such as NoPopNavigator), allowing you to control how routes behave.
  2. Type-Safe: The generics (<T>) make sure the return type of the route matches the expected type.
  3. Decoupled Route Logic: You separate the route creation logic from the UI components, following clean architecture principles.

Navigator Extension (context.to())

To streamline and simplify navigation throughout your app, you've introduced an extension on the BuildContext that adds a to() method. This provides a cleaner and more readable way to navigate between screens without needing to directly access Navigator.of(context).

Key Purpose

The to() method abstracts away the lower-level Navigator API and lets you pass a RouteBase object to handle navigation. It also includes logic for preventing back navigation if needed, adding extra control over how navigation is handled.

Code Breakdown

extension NavigatorExtension on BuildContext {
  Future<T?> to<T>(RouteBase<T> route, {bool canPop = true}) async {
    if (!canPop) route.navigator = NoPopNavigator<T>();

    return await _tryNavigate<T>(() => Navigator.of(this)
        .pushNamed<T>(route.path, arguments: route));
  }

  Future<T?> _tryNavigate<T>(Future<T?> Function() navigate) {
    try {
      return navigate();
    } catch (e) {
      debugPrint('Failed to navigate: $e');
      return Future.value(null);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Components:

  1. to<T>(RouteBase<T> route, {bool canPop = true}) Method

This method is the core of the navigation extension. Here's how it works:

  • Input Parameters:

    • RouteBase<T> route: The route is an instance of the RouteBase class, which encapsulates all the information about the target route (path, widget, and navigator).
    • canPop: A boolean flag indicating whether the route should allow back navigation (i.e., whether the user can pop the route). If set to false, the route uses the NoPopNavigator.
  • Changing the Navigator:

  if (!canPop) route.navigator = NoPopNavigator<T>();
Enter fullscreen mode Exit fullscreen mode

If canPop is set to false, the method assigns a NoPopNavigator to the route, ensuring that the route cannot be popped.

  • Calling the Navigator:
  return await _tryNavigate<T>(() => Navigator.of(this)
      .pushNamed<T>(route.path, arguments: route));
Enter fullscreen mode Exit fullscreen mode

This part pushes the route onto the navigation stack using the pushNamed method of the Navigator. The route's path is used as the identifier, and the RouteBase itself is passed as an argument.

By passing the RouteBase as an argument, it allows the AppRouter to retrieve the route and build it when generateRoute is called.

  1. _tryNavigate<T>(Future<T?> Function() navigate) Method

This helper method wraps the navigation logic in a try-catch block to gracefully handle navigation errors.

  • Error Handling: If the navigation attempt fails (due to an invalid route or other issues), it logs the error and returns null to avoid crashing the app.

Example Usage of context.to()

Now, instead of using the standard Flutter Navigator API, you can simply navigate like this:

final homeRoute = HomeRoute();
context.to(homeRoute);  // Navigates to the home route

final settingsRoute = SettingsRoute();
context.to(settingsRoute, canPop: false);  // Navigates to settings route with no back navigation
Enter fullscreen mode Exit fullscreen mode

In this example:

  • context.to(homeRoute) navigates to the HomeRoute, and the back button can be used to return to the previous screen.
  • context.to(settingsRoute, canPop: false) navigates to the SettingsRoute, but prevents the user from popping back to the previous screen (because NoPopNavigator is used).

Benefits of the Navigator Extension:

  1. Cleaner Syntax: This extension provides a much cleaner and more readable way to navigate within the app. Rather than dealing with Navigator.of(context) calls directly, you simply call context.to() and pass the desired route.

  2. Customizable Behavior: With the canPop flag, you can easily control whether the new route should allow back navigation, which is useful for scenarios where you want to force the user to complete a task before going back.

  3. Error Handling: The _tryNavigate method ensures that any navigation errors are caught and handled gracefully, improving the robustness of your app.

The RouteBase class is the foundational building block of the custom navigation approach you've developed. It plays a key role in representing and managing the behavior of a route in a Flutter application, offering more structure and flexibility than the standard onGenerateRoute mechanism. Let's break it down step by step:

Purpose of RouteBase

The main goal of the RouteBase class is to abstract the details of a route, including:

  • The path of the route (i.e., its URL or identifier).
  • The widget (or screen) to be displayed when the route is triggered.
  • The behavior of the route via a navigator that defines how the route should be constructed (e.g., standard navigation or preventing the user from popping back).

The design of RouteBase makes it a reusable template that can be extended or used across different types of routes. It decouples route generation from the rest of the navigation logic, allowing for easier customization.


Components of RouteBase

The class consists of several key elements:

1. Constructor

RouteBase(this._path, {required Widget child}) : _child = child;
Enter fullscreen mode Exit fullscreen mode
  • _path: A private variable that stores the route's path (or URL). This path serves as an identifier for the route, allowing the app to differentiate between various navigation targets.
  • _child: A private variable that holds the widget associated with this route. This widget is displayed when the route is triggered.

The constructor requires two main inputs:

  1. The path of the route.
  2. The child widget, which is the screen or UI that will be displayed.

For example:

class HomeRoute extends RouteBase<HomePage> {
  HomeRoute() : super('/home', child: HomePage());
}
Enter fullscreen mode Exit fullscreen mode

This defines a HomeRoute where the path is '/home' and the screen to display is the HomePage widget.

2. Navigator Property

AppNavigator<T> _navigator = NormalNavigator<T>();
Enter fullscreen mode Exit fullscreen mode

The _navigator variable is of type AppNavigator, an abstract class that controls how the route is generated and navigated to. The default value for _navigator is set to NormalNavigator, which provides standard navigation behavior.

By using an abstract class (AppNavigator), you can customize how routes behave. For instance, you can create a NoPopNavigator to prevent popping back to this route.

3. Path Getter

String get path => _path;
Enter fullscreen mode Exit fullscreen mode

This getter simply exposes the route's path. It's necessary for the Navigator system in Flutter to identify which route to push or pop.

4. Navigator Setter

set navigator(AppNavigator<T> navigator) {
  _navigator = navigator;
}
Enter fullscreen mode Exit fullscreen mode

This setter allows external code to change the AppNavigator for a particular route, offering flexibility in how the route behaves. For instance, you could modify a route to use a NoPopNavigator instead of the default NormalNavigator when navigating, giving you more control over the behavior at runtime.

For example:

route.navigator = NoPopNavigator<HomePage>();
Enter fullscreen mode Exit fullscreen mode

This line changes the navigator for HomeRoute to a NoPopNavigator, preventing back navigation.

5. Route Generation Method

MaterialPageRoute<T> buildRoute() => _navigator.buildRoute(_path, _child);
Enter fullscreen mode Exit fullscreen mode

This method is responsible for generating the actual MaterialPageRoute used by Flutter to display the screen. The method delegates the work of building the route to the _navigator object, which encapsulates the logic for creating the route.

  • The buildRoute method of the AppNavigator receives the path and the child widget and returns a MaterialPageRoute.
  • Since different navigators (like NormalNavigator or NoPopNavigator) can be used, this method provides flexibility. Each AppNavigator can generate the route differently, such as preventing back navigation or applying custom transitions.

Example of RouteBase Usage

Let's consider an example of how RouteBase might be used to define routes for a simple app with a Home page and a Settings page.

class HomeRoute extends RouteBase<HomePage> {
  HomeRoute() : super('/home', child: HomePage());
}

class SettingsRoute extends RouteBase<SettingsPage> {
  SettingsRoute() : super('/settings', child: SettingsPage());
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • HomeRoute is defined with a path of /home and points to the HomePage widget.
  • SettingsRoute is defined with a path of /settings and points to the SettingsPage widget.

To navigate between these routes, you might use the following in your app:

final homeRoute = HomeRoute();
context.to(homeRoute);

final settingsRoute = SettingsRoute();
context.to(settingsRoute, canPop: false);
Enter fullscreen mode Exit fullscreen mode

In the second navigation to the SettingsRoute, we use canPop: false to prevent the user from going back, by internally setting the NoPopNavigator as the navigator for that route.


Advantages of RouteBase

  1. Modularity:
    RouteBase decouples the route logic from the widget itself. This separation of concerns makes the code more modular, reusable, and easier to maintain.

  2. Type Safety:
    The use of generics (<T>) ensures that each route knows the type of data it handles, reducing runtime errors caused by mismatches in expected types during navigation.

  3. Navigator Flexibility:
    By abstracting the navigation behavior into a separate AppNavigator class, you gain flexibility in defining how routes behave. You can easily switch between normal navigation, no-pop navigation, or even implement other custom navigational behaviors, such as adding animations or transition effects.

  4. Consistency:
    Since each route follows the same pattern, this approach promotes consistency in how routes are defined and navigated, reducing the chance of errors or inconsistencies in large applications.

  5. Reusability:
    You can create and reuse different navigators (e.g., NormalNavigator, NoPopNavigator) across multiple routes, making it easier to apply the same behavior without duplicating code.

Top comments (0)