Flutter Navigator 2.0 and Deep Links

With Flutter’s Navigator 2.0, learn how to handle deep links in Flutter and gain the ultimate navigation control for your app. By Kevin D Moore.

3.9 (26) · 3 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 5 of this article. Click here to view the first page.

RouterDelegate

RouterDelegate contains the core logic for Navigator 2.0. This includes controlling the navigation between pages. This class is an abstract class that requires classes that extend RouterDelegate to implement all of its unimplemented methods.

Begin by creating a new Dart file in the router directory called router_delegate.dart. You will name the RouterDelegate for this app ShoppingRouterDelegate. Add the following import statements:

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../app_state.dart';
import '../ui/details.dart';
import '../ui/cart.dart';
import '../ui/checkout.dart';
import '../ui/create_account.dart';
import '../ui/list_items.dart';
import '../ui/login.dart';
import '../ui/settings.dart';
import '../ui/splash.dart';
import 'ui_pages.dart';

This includes imports for all the UI pages. Next, add the code representing the basic structure of this app’s RouterDelegate, i.e. ShoppingRouterDelegate:

// 1
class ShoppingRouterDelegate extends RouterDelegate<PageConfiguration>
    // 2
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<PageConfiguration> {
 
  // 3
  final List<Page> _pages = [];

  // 4
  @override
  final GlobalKey<NavigatorState> navigatorKey;

  // 5
  final AppState appState;

  // 6
  ShoppingRouterDelegate(this.appState) : navigatorKey = GlobalKey() {
    appState.addListener(() {
      notifyListeners();
    });
  }

  // 7
  /// Getter for a list that cannot be changed
  List<MaterialPage> get pages => List.unmodifiable(_pages);
  /// Number of pages function
  int numPages() => _pages.length;

// 8
  @override
  PageConfiguration get currentConfiguration =>
      _pages.last.arguments as PageConfiguration;
}

Ignore the errors for now, you will resolve them soon. Here’s what’s happening in the code above:

  1. This represents the app’s RouterDelegate, ShoppingRouterDelegate. It extends the abstract RouterDelegate, which produces a configuration for each Route. This configuration is PageConfiguration.
  2. ShoppingRouterDelegate uses the ChangeNotifier mixin, which helps notify any listeners of this delegate to update themselves whenever notifyListeners() is invoked. This class also uses PopNavigatorRouterDelegateMixin, which lets you remove pages. It’ll also be useful later when you implement BackButtonDispatcher.
  3. This list of Pages is the core of the app’s navigation, and it denotes the current list of pages in the navigation stack. It’s private so that it can’t be modified directly, as that could lead to errors and unwanted states. You’ll see later how to handle modifying the navigation stack without writing to this list directly from anywhere outside ShoppingRouterDelegate.
  4. PopNavigatorRouterDelegateMixin requires a navigatorKey used for retrieving the current navigator of the Router.
  5. Declare a final AppStatevariable.
  6. Define the constructor. This constructor takes in the current app state and creates a global navigator key. It’s important that you only create this key once.
  7. Define public getter functions.
  8. currentConfiguration gets called by Router when it detects route information may have changed. “current” means the topmost page of the app i.e. _pages.last. This getter returns configuration of type PageConfiguration as defined on line 1 while creating RouterDelegate<PageConfiguration>. The currentConfiguration for this last page can be accessed as _pages.last.arguments.

Next, you’ll implement build.

One of the methods from RouterDelegate that you’ll implement is build. It gets called by RouterDelegate to obtain the widget tree that represents the current state. In this scenario, the current state is the navigation history of the app. As such, use Navigator to implement build by adding the code below:

@override
Widget build(BuildContext context) {
  return Navigator(
    key: navigatorKey,
    onPopPage: _onPopPage,
    pages: buildPages(),
  );
}

Navigator uses the previously defined navigatorKey as its key. Navigator needs to know what to do when the app requests the removal or popping of a page via a back button press and calls _onPopPage.
pages calls buildPages to return the current list of pages, which represents the app’s navigation stack.

To remove pages, define a private _onPopPage method:

bool _onPopPage(Route<dynamic> route, result) {
  // 1
  final didPop = route.didPop(result);
  if (!didPop) {
    return false;
  }
  // 2
  if (canPop()) {
    pop();
    return true;
  } else {
    return false;
  }
}

This method will be called when pop is invoked, but the current Route corresponds to a Page found in the pages list.

The result argument is the value with which the route completed. An example of this is the value returned from a dialog when it’s popped.

In the code above:

  1. There’s a request to pop the route. If the route can’t handle it internally, it returns false.
  2. Otherwise, check to see if we can remove the top page and remove the page from the list of pages.

Note that route.settings extends RouteSettings.
It’s possible you’ll want to remove a page from the navigation stack. To do this, create a private method _removePage. This modifies the internal _pages field:

void _removePage(MaterialPage page) {
  if (page != null) {
    _pages.remove(page);
  }
}

_removePage is a private method, so to access it from anywhere in the app, use RouterDelegate‘s popRoute method. Now add pop methods:

void pop() {
  if (canPop()) {
    _removePage(_pages.last);
  }
}

bool canPop() {
  return _pages.length > 1;
}

@override
Future<bool> popRoute() {
  if (canPop()) {
    _removePage(_pages.last);
    return Future.value(true);
  }
  return Future.value(false);
}

These methods ensure there are at least two pages in the list. Both pop and popRoute will call _removePage to remove a page and return true if it can pop, ottherwise, return false to close the app. If you didn’t add the check here and called _removePage on the last page of the app, you would see a blank screen.

Now that you know how to remove a page, you’ll write code to create and add a page. You’ll use MaterialPage, which is a Page subclass provided by the Flutter SDK:

MaterialPage _createPage(Widget child, PageConfiguration pageConfig) {
  return MaterialPage(
      child: child,
      key: Key(pageConfig.key),
      name: pageConfig.path,
      arguments: pageConfig
  );
}

The first argument for this method is a Widget. This widget will be the UI displayed to the user when they’re on this page. The second argument is an object of type PageConfiguration, which holds the configuration of the page this method creates.

The first three parameters of MaterialPage are straightforward. The fourth parameter is arguments, and the pageConfig is passed to it. This lets you easily access the configuration of the page if needed.

Now that there’s a method to create a page, create another method to add this page to the navigation stack, i.e. to the _pages list:

void _addPageData(Widget child, PageConfiguration pageConfig) {
  _pages.add(
    _createPage(child, pageConfig),
  );
}

The public method for adding a page is addPage. You’ll implement it using the Pages enum:

void addPage(PageConfiguration pageConfig) {
  // 1
  final shouldAddPage = _pages.isEmpty ||
        (_pages.last.arguments as PageConfiguration).uiPage !=
            pageConfig.uiPage;

  if (shouldAddPage) {
    // 2
    switch (pageConfig.uiPage) {
      case Pages.Splash:
        // 3
        _addPageData(Splash(), SplashPageConfig);
        break;
      case Pages.Login:
        _addPageData(Login(), LoginPageConfig);
        break;
      case Pages.CreateAccount:
        _addPageData(CreateAccount(), CreateAccountPageConfig);
        break;
      case Pages.List:
        _addPageData(ListItems(), ListItemsPageConfig);
        break;
      case Pages.Cart:
        _addPageData(Cart(), CartPageConfig);
        break;
      case Pages.Checkout:
        _addPageData(Checkout(), CheckoutPageConfig);
        break;
      case Pages.Settings:
        _addPageData(Settings(), SettingsPageConfig);
        break;
      case Pages.Details:
        if (pageConfig.currentPageAction != null) {
          _addPageData(pageConfig.currentPageAction.widget, pageConfig);
        }
        break;
      default:
        break;
    }
  }
}

The code above does the following:

  1. Decides whether to add a new page. The second condition ensures the same page isn’t added twice by mistake. Example: You wouldn’t want to add a Login page immediately on top of another Login page.
  2. Uses a switch case on the pageConfig‘s UI_PAGE so you know which page to add.
  3. Uses the recently created private addPageData to add the widget and PageConfiguration associated with the corresponding UI_PAGE from the switch case.

You’ll notice switch doesn’t handle the Details page case. That’s because adding that page requires another argument, which you’ll read about later.

Now comes the fun part. Create some methods that allow you to modify the contents of the _pages list. To cover all use cases of the app, you’ll need methods to add, delete and replace the _pages list:

// 1
void replace(PageConfiguration newRoute) {
  if (_pages.isNotEmpty) {
    _pages.removeLast();
  }
  addPage(newRoute);
}

// 2
void setPath(List<MaterialPage> path) {
  _pages.clear();
  _pages.addAll(path);
}

// 3
void replaceAll(PageConfiguration newRoute) {
  setNewRoutePath(newRoute);
}

// 4
void push(PageConfiguration newRoute) {
  addPage(newRoute);
}

// 5
void pushWidget(Widget child, PageConfiguration newRoute) {
  _addPageData(child, newRoute);
}

// 6
void addAll(List<PageConfiguration> routes) {
  _pages.clear();
  routes.forEach((route) {
    addPage(route);
  });
}

Here’s a breakdown of the code above:

  1. replace method: Removes the last page, i.e the top-most page of the app, and replaces it with the new page using the add method
  2. setPath method: Clears the entire navigation stack, i.e. the _pages list, and adds all the new pages provided as the argument
  3. replaceAll method: Calls setNewRoutePath. You’ll see what this method does in a moment.
  4. push method: This is like the addPage method, but with a different name to be in sync with Flutter’s push and pop naming.
  5. pushWidget method: Allows adding a new widget using the argument of type Widget. This is what you’ll use for navigating to the Details page.
  6. addAll method: Adds a list of pages.

The last overridden method of the RouterDelegate is setNewRoutePath, which is also the method called by replaceAll above. This method clears the list and adds a new page, thereby replacing all the pages that were there before:

@override
Future<void> setNewRoutePath(PageConfiguration configuration) {
  final shouldAddPage = _pages.isEmpty ||
      (_pages.last.arguments as PageConfiguration).uiPage !=
          configuration.uiPage;
  if (shouldAddPage) {
    _pages.clear();
    addPage(configuration);
  }
  return SynchronousFuture(null);
}

When an page action is requested, you want to record the action associated with the page. The _setPageAction method will do that. Add:

void _setPageAction(PageAction action) {
  switch (action.page.uiPage) {
    case Pages.Splash:
      SplashPageConfig.currentPageAction = action;
      break;
    case Pages.Login:
      LoginPageConfig.currentPageAction = action;
      break;
    case Pages.CreateAccount:
      CreateAccountPageConfig.currentPageAction = action;
      break;
    case Pages.List:
      ListItemsPageConfig.currentPageAction = action;
      break;
    case Pages.Cart:
      CartPageConfig.currentPageAction = action;
      break;
    case Pages.Checkout:
      CheckoutPageConfig.currentPageAction = action;
      break;
    case Pages.Settings:
      SettingsPageConfig.currentPageAction = action;
      break;
    case Pages.Details:
      DetailsPageConfig.currentPageAction = action;
      break;
    default:
      break;
  }
}

Now comes the most important method, buildPages. This method will return a list of pages based on the current app state:

List<Page> buildPages() {
  // 1
  if (!appState.splashFinished) {
    replaceAll(SplashPageConfig);
  } else {
    // 2
    switch (appState.currentAction.state) {
      // 3
      case PageState.none:
        break;
      case PageState.addPage:
        // 4
        _setPageAction(appState.currentAction);
        addPage(appState.currentAction.page);
        break;
      case PageState.pop:
        // 5
        pop();
        break;
      case PageState.replace:
        // 6
        _setPageAction(appState.currentAction);
        replace(appState.currentAction.page);
        break;
      case PageState.replaceAll:
        // 7
        _setPageAction(appState.currentAction);
        replaceAll(appState.currentAction.page);
        break;
      case PageState.addWidget:
        // 8
        _setPageAction(appState.currentAction);
        pushWidget(appState.currentAction.widget, appState.currentAction.page);
        break;
      case PageState.addAll:
        // 9
        addAll(appState.currentAction.pages);
        break;
    }
  }
  // 10
  appState.resetCurrentAction();
  return List.of(_pages);
}
  1. If the splash screen hasn’t finished, just show the splash screen.
  2. Switch on the current action state.
  3. If there is no action, do nothing.
  4. Add a new page, given by the action’s page variable.
  5. Pop the top-most page.
  6. Replace the current page.
  7. Replace all of the pages with this page.
  8. Push a widget onto the stack (Details page)
  9. Add a list of pages.
  10. Reset the page state to none.

RouterDelegate is a lot to take in. Take a break to digest what you just learned. :]