Navigation 2.0 - Routing On Flutter Web

Navigation 2.0 - Routing On Flutter Web

Learn how to sync web URLs with Flutter pages and handle navigation on web using Flutter

In this tutorial we will learn how Navigation 2.0 works with Flutter Web, how we can build webpages and sync the URL with our Flutter Web projects.

Previously, with imperative navigation techniques, we were only able to push and pop routes in the navigation stack, but that did not handle the web URL and web histories. So, the Flutter team came up with this new declarative navigation technique that handles URLs and histories as an integral part of route management.

Let's look at the different techniques that we can use to manage Routes and how Navigation 2.0 makes it better.

Routing using OnGenerateRoute

Let’s start with Routing using onGenerateRoute first. If you try navigating to different pages using simple Navigator.push() or other methods, the page will change but the URL won’t. Also, there will be no history saved for this.

We can do this using Generate Routes.

onGenerateRoutes is the route generator callback used when the app is navigated to a named route. Using this, we will generate routes, navigate to different page and sync the changes with the URL of the browser.

We need two route files (for cleaner code), one for defining the route names and other for defining the onGenerateRoute class.

The routes_name file will have the routes with corresponding route names.

class RoutesName {
  // ignore: non_constant_identifier_names
  static const String FIRST_PAGE = '/first_page';
  // ignore: non_constant_identifier_names
  static const String SECOND_PAGE = '/second_page';
}

Here, we have two pages, FIRST_PAGE (Example: localhost:3000/#/first_page) and SECOND_PAGE (Example: localhost:3000/#/second_page).

Now, we will create a reusable onGenerateRoute class in our second routes file.

class RouteGenerator {
  static Route<dynamic> generateRoute(RouteSettings settings) {
    switch (settings.name) {
      case RoutesName.FIRST_PAGE:
        return _GeneratePageRoute(
            widget: FirstPage(), routeName: settings.name);
      case RoutesName.SECOND_PAGE:
        return _GeneratePageRoute(
            widget: SecondPage(), routeName: settings.name);
      default:
        return _GeneratePageRoute(
            widget: FirstPage(), routeName: settings.name);
    }
  }

Using the route settings, we will get the Routes name and then navigate to the corresponding Page. Here, the _GeneratePageRoute is the class extending PageRouteBuilder. It is used to add navigation transition animations.

class _GeneratePageRoute extends PageRouteBuilder {
  final Widget widget;
  final String routeName;
  _GeneratePageRoute({this.widget, this.routeName})
      : super(
            settings: RouteSettings(name: routeName),
            pageBuilder: (BuildContext context, Animation<double> animation,
                Animation<double> secondaryAnimation) {
              return widget;
            },
            transitionDuration: Duration(milliseconds: 500),
            transitionsBuilder: (BuildContext context,
                Animation<double> animation,
                Animation<double> secondaryAnimation,
                Widget child) {
              return SlideTransition(
                textDirection: TextDirection.rtl,
                position: Tween<Offset>(
                  begin: Offset(1.0, 0.0),
                  end: Offset.zero,
                ).animate(animation),
                child: child,
              );
            });
}

Setting routes is almost completed. The Final step is to add the generateRoute to our main.dart file.

void main() {
  runApp(App());
}
class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      builder: (context, child) => HomePage(child: child),
      onGenerateRoute: RouteGenerator.generateRoute,
      initialRoute: RoutesName.FIRST_PAGE,
    );
  }
}

Inside the MaterialApp, we will add the routes to onGenerateRoute property and set one initial route (This page will be loaded first and the corresponding route will be the initial route that will be shown on the URL too). Now, using builder property of material app, we will insert the widgets. HomePage is a stateless widget that builds the inserted widget.

class HomePage extends StatelessWidget {
  final Widget child;

  const HomePage({Key key, this.child}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
    );
  }
}

We have two pages for our two routes ( /first-page and /second-page). To navigate from one page to another, we will use Navigator.pushNamed(context, RouteName);

First Page:

class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 400,
      child: Column(
        children: [
          Container(
            child: Center(
              child: Text("FIRST PAGE"),
            ),
          ),
          TextButton(
              onPressed: () {

               Navigator.pushNamed(context, RoutesName.SECOND_PAGE);
              },
              child: Text("NAVIGATE")),
        ],
      ),
    );
  }
}

Second Page:

class SecondPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      height: 400,
      child: Column(
        children: [
          Container(
            child: Center(
              child: Text("SECOND PAGE"),
            ),
          ),
          TextButton(
              onPressed: () {
                Navigator.pop(context);
                // Navigator.pushNamed(context, RoutesName.SECOND_PAGE);
              },
              child: Text("NAVIGATE")),
        ],
      ),
    );
  }
}

Our routing part is now completed.

Now, let's look at another method of implementing Routing in Flutter Web using Navigation 2.0.

Routing Using Navigation 2.0

Navigation 2.0 follows a declarative approach. Using this approach, we will sync a web URL with our Flutter project.

Let’s see how we can do this.

routing.png

We will use a constructor named MaterialApp.router(). It creates a material app that uses the router instead of Navigator. It requires two things, routerDelegate and routerInformationParser.

The URL typed will pass through RouterInformationParser and it will convert the URL into user defined data. Then, this will be passed to RouterDelegate, which is in charge of the states of the router app. It will render according to the consumed data.

Let’s first create our generic data type in which route will be converted through RouteInformationParser.

class HomeRoutePath {
  final String pathName; 
 final bool isUnkown;

  HomeRoutePath.home()
      : pathName = null,
        isUnkown = false;

  HomeRoutePath.otherPage(this.pathName) : isUnkown = false;

  HomeRoutePath.unKown()
      : pathName = null,
        isUnkown = true;

  bool get isHomePage => pathName == null;
  bool get isOtherPage => pathName != null;
}

In the above class, we have two variables, pathName, which will be the URL param (after “/“) and boolean, that will show the 'unknown page' in case of an invalid URL.

Also, we have created some named constructors.

home() shows the initial screen when route is “/“. In this case, the unknown boolean will be false and the path will be empty. otherPage() is for the pages with pathName. Eg. /xyz. unknown() is for unknown paths.

After this, we will create the RouteInformationParser which will convert URL into our HomeRoutePath.

class HomeRouteInformationParser extends RouteInformationParser<HomeRoutePath> {
  @override
  Future<HomeRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
       final uri = Uri.parse(routeInformation.location);

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

    if (uri.pathSegments.length == 1) {
      final pathName = uri.pathSegments.elementAt(0).toString();
      if (pathName == null) return HomeRoutePath.unKown();
      return HomeRoutePath.otherPage(pathName);
    }

    return HomeRoutePath.unKown();
  }

  @override
  RouteInformation restoreRouteInformation(HomeRoutePath homeRoutePath) {
    if (homeRoutePath.isUnkown) return RouteInformation(location: '/error');
    if (homeRoutePath.isHomePage) return RouteInformation(location: '/');
    if (homeRoutePath.isOtherPage)
      return RouteInformation(location: '/${homeRoutePath.pathName}');

    return null;
  }
}

Our class will extend RouterInformationParser with type HomeRoutePath and then we will override the parseRouteInformation method that is responsible for parsing URL path into HomeRoutePath.

When we will have zero path segments (pathSegements after “/“ eg /abc/xyz has 2 path segments), we will convert to home() type. Similarly, we will convert to other types as per the conditions.

We have another method to override, called the restoreRouteInformation. This method will store the browsing history in the browser. Here, we have passed the path to the RouteInformation as per the HomeRoutePath state.

We have now completed informationParser.

Let’s move to RouterDelegate.

This class need to extend RouterDelegate with type HomeRoutePath. It has 5 override methods and we need not to handle all of them. For the addListener and removeListener method, we can use the ChangeNotifier and add it to the function.

Also for popRoute, we can use the mixin PopNavigatorRouterDelegateMixin.

class HomeRouterDelegate extends RouterDelegate<HomeRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<HomeRoutePath> {
  String pathName;
  bool isError = false;

  @override
  GlobalKey<NavigatorState> get navigatorKey => GlobalKey<NavigatorState>();

  @override
  HomeRoutePath get currentConfiguration {
    if (isError) return HomeRoutePath.unKown();

    if (pathName == null) return HomeRoutePath.home();

    return HomeRoutePath.otherPage(pathName);
  }

  void onTapped(String path) {
    pathName = path;
    print(pathName);
    notifyListeners();
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
        key: navigatorKey,
        pages: [
          MaterialPage(
            key: ValueKey('HomePage'),
            child: HomePage(
              onTapped: (String path) {
                pathName = path;
                notifyListeners();
              },
            ),
          ),
          if (isError)
            MaterialPage(key: ValueKey('UnknownPage'), child: UnkownPage())
          else if (pathName != null)
            MaterialPage(
                key: ValueKey(pathName),
                child: PageHandle(
                  pathName: pathName,
                ))
        ],
        onPopPage: (route, result) {
          if (!route.didPop(result)) return false;

          pathName = null;
          isError = false;
          notifyListeners();

          return true;
        });
  }

  @override
  Future<void> setNewRoutePath(HomeRoutePath homeRoutePath) async {
    if (homeRoutePath.isUnkown) {
      pathName = null;
      isError = true;
      return;
    }

    if (homeRoutePath.isOtherPage) {
      if (homeRoutePath.pathName != null) {
        pathName = homeRoutePath.pathName;
        isError = false;
        return;
      } else {
        isError = true;
        return;
      }
    } else {
      pathName = null;
    }
  }
}

Here, we have defined two states that we will use for navigating to pages. One is pathName and other is for error pages isError.

Let’s start with override methods one by one:

currentConfiguration: is called by the [Router] when it detects a route information may have changed as a result of a rebuild. So, according to the conditions, we are calling the HomePageRoute constructors. setNewRoutePath: This method handles the routing when user enters URL in the browser and presses enter. build: Returns the Navigator with our HomePage by default and changing the state through HomePage and if pathName is not null, we can map the name with pages and show different pages as per different routes (here I’m just showing one page with route names).

The final Step is to add our routerDelegate and routerInformationParser to MaterialApp.router() inside main.dart.

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

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: "Flutter Navigaton 2.0",
      routerDelegate: HomeRouterDelegate(),
      routeInformationParser: HomeRouteInformationParser(),
    );
  }
}

Conclusion

The approach involving Navigation 2.0 has too much boilerplate code to add but it handles the cases that are missed in the onGenerateRoute approach. You may notice that the forward arrow in the browser might not work properly with first approach. Still, the first approach is somewhat easier to implement and manage compared to the second.

Resources

flutter.dev/docs/cookbook/animation/page-ro..

api.flutter.dev/flutter/widgets/WidgetsApp/..

api.flutter.dev/flutter/widgets/LayoutBuild..

medium.com/flutter/learning-flutters-new-na..