Flutter Navigator v2 (Part 1)

Something I've felt hasn't been covered to death is how to handle the new Navigator in Flutter. The official tutorial can be found at https://flutter.dev/docs/development/ui/navigation, but that just shows it in isolation. My blog here has been focused on practical development. So we're going to make a simple shopping app with the new way of navigating.

It doesn't look pretty, but that's not the point. What you'll notice is that we have an auth screen followed by the main app. This will be a logical divide in the app. Meaning you can't navigate to the shopping without first giving your name. I'm a fan of using flutter_bloc to manage my state, and we'll use it here. The final result can be found at https://github.com/cadaniel/FlutterNavigator2Example

Authentication State and Router

Our first router is going to be deailing with authentication alone. If the user is not signed in, then we dont' want to allow them to shop. First up is our cubit and state.

part of 'auth_cubit.dart';

@freezed
abstract class AuthState with _$AuthState {
  factory AuthState.loggedOut() = _LoggedOut;
  factory AuthState.loggedIn() = _LoggedIn;
  factory AuthState.loading() = _Loading;
}
@injectable
class AuthCubit extends Cubit<AuthState> {
  AuthService authService;

  AuthCubit(this.authService) : super(AuthState.loading()) {
    if (authService.isLoggedIn()) {
      emit(AuthState.loggedIn());
    } else {
      emit(AuthState.loggedOut());
    }
  }

  void login(String name) {
    authService.login(name);
    emit(AuthState.loggedIn());
  }
}

This is fairly simple, two possible states, and that's about it. So let's talk about pages.

Pages

In the first version of Navigation in Flutter, you pushed widgets and that was about it. You could have named routes if you got fancy. With this version, you push pages in a list. The list corresponds to what you can navigate back to. So you can change the history depending on what state you start with.

When it comes to pages, you can have a material or a cupertino one. We'll make our own page class that picks material or cupertino depending on what platform we're on.

class PlatformPage extends Page {
  final Widget child;
  final String path;
  final String title;

  PlatformPage(this.child, this.path, {this.title})
      : super(key: ValueKey(path));

  @override
  Route createRoute(BuildContext context) {
    if (Platform.isIOS) {
      return CupertinoPageRoute(
        builder: (_) => child,
        settings: this,
        title: title,
      );
    } else {
      return MaterialPageRoute(
        settings: this,
        builder: (_) => child,
      );
    }
  }
}

So this is what we'll use for pages. They take in a child, path, and optional title. The create route checks what platform we're on, and returns the route to the page. While we're talking about platform specifics, I'll quickly show you PlatformScaffold as well. The logic is basically the same.

class PlatformScaffold extends StatelessWidget {
  final Widget child;
  final CupertinoNavigationBar cupertinoNavigationBar;
  final AppBar materialAppBar;

  const PlatformScaffold({
    Key key,
    this.cupertinoNavigationBar,
    this.materialAppBar,
    @required this.child,
  })  : assert(child != null),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    if (Platform.isIOS) {
      return Material(
        child: SafeArea(
          child: CupertinoPageScaffold(
            navigationBar: cupertinoNavigationBar,
            child: child,
          ),
        ),
      );
    } else {
      return SafeArea(
        child: Scaffold(
          appBar: materialAppBar,
          body: child,
        ),
      );
    }
  }
}

If we want to add more to the scaffold, it's just a matter of taking them into the widget. This way we don't need to constantly be checking what platform we're on. Always fallow the DRY (Don't Repeat Yourself) principal. I also wrap the CupertinoPageScaffold in Material, because I use material widgets in several places, and require that to be in the tree.

Now it's time for navigation

If you want to see how I do the UI, you can check out the github link posted above. I'm going to be focusing on the actual navigation here. Let's take a look at what our main app entry point looks like

class NavigatorExample extends StatefulWidget {
  @override
  _NavigatorExampleState createState() => _NavigatorExampleState();
}

class _NavigatorExampleState extends State<NavigatorExample> {
  final authBloc = GetIt.instance.get<AuthCubit>();
  final NavigatorExampleRouteDelegate routeDelegate =
      NavigatorExampleRouteDelegate();
  final NavigatorExampleInformationParser informationParser =
      NavigatorExampleInformationParser();

  @override
  Widget build(BuildContext context) {
    return BlocProvider.value(
      value: authBloc,
      child: BlocBuilder(
        cubit: authBloc,
        builder: (BuildContext context, _) {
        if (Platform.isIOS) {
            return CupertinoApp.router(
              routeInformationParser: informationParser,
              routerDelegate: routeDelegate,
              debugShowCheckedModeBanner: false,
            );
          }
          return MaterialApp.router(
            routeInformationParser: informationParser,
            routerDelegate: routeDelegate,
            debugShowCheckedModeBanner: false,
          );
        },
      ),
    );
  }
}

I'm sure no one is surprised that we start with a stateful widget and a bloc. Then we have our route delegate and information parser declared. We'll get to those in a moment. I want to point out we're using MaterialApp.router constructor for the app. (Or the CupertinoApp.router if we're on iOS)

Information Parser

The information parser is meant to parse out a state of some kind (like a url path) and output the state. In part two we're going to go over how to do that. So for now our parsers are simple.

class NavigatorExampleInformationParser
    extends RouteInformationParser<AuthState> {
  @override
  Future<AuthState> parseRouteInformation(
      RouteInformation routeInformation) async {
    return AuthState.loggedOut();
  }

  @override
  RouteInformation restoreRouteInformation(AuthState path) {
    return RouteInformation(location: "/");
  }
}

If you are saving state in some fashion, like saving navigation if the app closes, then this is where you would parse it back and output state. For right now though, we'll just return a base route, and logged out state.

Route Delegate

So the route delegate is where the pages are actually configured. Here's the entire class

class NavigatorExampleRouteDelegate extends RouterDelegate<AuthState>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<AuthState> {
  final GlobalKey<NavigatorState> navigatorKey;

  AuthState _currentState = AuthState.loading();

  NavigatorExampleRouteDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  AuthState get currentConfiguration => _currentState;

  List<Page<dynamic>> _getPages( {
    List<Page<dynamic>> pages = [LoadingPage()]; //default

    _currentState.when(
      loggedOut: () {
        pages = [LoginPage()];
      },
      loggedIn: () {
        pages = [HomePage()];
      },
      loading: () {
        pages = [LoadingPage()];
      },
    );

    return pages;
  }

  @override
  Widget build(BuildContext context) {
    return BlocListener(
      cubit: BlocProvider.of<AuthCubit>(context),
      listener: (BuildContext context, AuthState state) {
        _currentState = state;
        notifyListeners();
      },
      child: Navigator(
        pages: _getPages(context),
        onPopPage: (route, result) {
          //we dont' want to pop any further than this navigator
          return false;
        },
      ),
    );
  }

  @override
  Future<void> setNewRoutePath(AuthState configuration) async {
    _currentState = configuration;
    notifyListeners();
  }
}

This is a lot to take in, so let's take it bit by bit. Starting at the top we have our initialization

AuthState _currentState = AuthState.loading();

  NavigatorExampleRouteDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  AuthState get currentConfiguration => _currentState;

Simple enough, hold the current state, when the class is created, it creates a navigation key, and we can get the state using a getter. Let's skip to the bottom and look at setNewRoutePath

@override
  Future<void> setNewRoutePath(AuthState configuration) async {
    _currentState = configuration;
    notifyListeners();
  }

Here we take in a new state (like from the information parser) and set it to the current state, and tell everything to reconfigure its self.

Next up is the Build method

@override
  Widget build(BuildContext context) {
    return BlocListener(
      cubit: BlocProvider.of<AuthCubit>(context),
      listener: (BuildContext context, AuthState state) {
        _currentState = state;
        notifyListeners();
      },
      child: Navigator(
        pages: _getPages(),
        onPopPage: (route, result) {
          //we dont' want to pop any further than this navigator
          return false;
        },
      ),
    );
  }

Alright, here is where we hook up our bloc. We insert a bloc listener, that gets the bloc from the widget tree. If it changes, it sets it and tells everyone to change places. From there we insert a simple Navigator.

One thing to note, we always return false onPopPage. This method is called when the back button is pressed, and is flutter's way of asking did we pop a page, and can we go any further. Since we want no back navigation from this, we return false. This leaves us with _getPages

List<Page<dynamic>> _getPages( {
    List<Page<dynamic>> pages = [LoadingPage()]; //default

    _currentState.when(
      loggedOut: () {
        pages = [LoginPage()];
      },
      loggedIn: () {
        pages = [HomePage()];
      },
      loading: () {
        pages = [LoadingPage()];
      },
    );

    return pages;
  }

Here is where we actually set the navigation. To start I simply load the loading page. Given what's below it's redundant, but left encase things change in the future. From there we switch on the state, and set the page stack. Here we only ever have one. What about the navigation I saw earlier? That's all handled by a nested navigator in HomePage.

Now you're asking what those pages are? I'll show the LoadingPage for a better sense, but all pages can be viewed on github.

class LoadingPage extends PlatformPage {
  LoadingPage() : super(LoadingWidget(), "/loading");
}

class LoadingWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        child: Center(
          child: Text("Loading"),
        ),
      ),
    );
  }
}

Home Page Navigation

The home page navigation is handled on it's own.

class HomePage extends PlatformPage {
  HomePage() : super(HomeWidget(ValueKey("/home")), "/home");
}

class HomeWidget extends StatelessWidget {
  HomeWidget(Key key) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider.value(value: GetIt.instance.get<HomeCubit>()),
        BlocProvider.value(value: GetIt.instance.get<CartCubit>()),
      ],
      child: Router(
        routeInformationProvider: HomeRouteInformationProvider(),
        routeInformationParser: HomeInformationParser(),
        routerDelegate: HomeRouterDelegate(),
      ),
    );
  }
}

Here we inject two blocs, the HomeCubit for our home state. This is what we'll actually navigate on. We also have the CarCubit, which is just here to be able to add/remove items from your cart.

We also have a router as the child widget. Like before, HomeInformationParser isn't really filled in because it's not needed for what we're doing, for now....

class HomeInformationParser extends RouteInformationParser<HomeState> {
  @override
  Future<HomeState> parseRouteInformation(RouteInformation routeInformation) {
    return Future.value(HomeState.products());
  }

  @override
  RouteInformation restoreRouteInformation(HomeState path) {
    return RouteInformation(location: "/");
  }
}

The new item here is HomeRouteInformaitonProvider. Again, this isn't really needed at the moment, so it's pretty simple. Just tracks listeners, and notifies them on change

class HomeRouteInformationProvider extends RouteInformationProvider {
  List<Function> _listeners = [];
  @override
  void addListener(void Function() listener) {
    _listeners.add(listener);
  }

  @override
  void removeListener(void Function() listener) {
    _listeners.remove(listener);
  }

  @override
  RouteInformation get value => RouteInformation(location: "/");

  void notifyListeners() {
    for (Function function in _listeners) {
      function.call();
    }
  }
}

I'm not 100% sure on why this is needed, but we'll be exploring route syncing when we get to the web.

Once again the meat and potatoes is in the delegate.

class HomeRouterDelegate extends RouterDelegate<HomeState>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<HomeState> {
  final GlobalKey<NavigatorState> navigatorKey;

  HomeState _currentState = HomeState.products();
  HomeState _previousState;

  HomeRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  HomeState get currentConfiguration => _currentState;

  List<Page<dynamic>> _getPages() {
    List<Page<dynamic>> pages = [ProductsPage()];
    _previousState?.maybeWhen(
      orElse: () {},
      product: (product) => pages.add(ProductPage(product)),
    );

    _currentState.when(
      products: () {},
      product: (product) => pages.add(ProductPage(product)),
      cart: () => pages.add(CartPage()),
    );

    return pages;
  }

  @override
  Widget build(BuildContext context) {
    return BlocListener(
      cubit: BlocProvider.of<HomeCubit>(context),
      listener: (_, HomeState state) {
		_previousState = _currentState;
        _currentState = state;
        notifyListeners();
      },
      child: Navigator(
        pages: _getPages(),
        onPopPage: (route, result) {
          if (!route.didPop(result)) {
            return false;
          }
          return _currentState.when(
            products: () {
              return false;
            },
            product: (product) {
              _currentState = _previousState ?? HomeState.products();
              return true;
            },
            cart: () {
              _currentState = _previousState ?? HomeState.products();
              return true;
            },
          );
        },
      ),
    );
  }

  @override
  Future<void> setNewRoutePath(HomeState configuration) async {
    _currentState = configuration;
    notifyListeners();
  }
}

What are those pages? Well we have 3. The first one is the list of products screen. From there you can navigate to the cart, or to a specific product. Next up we have a product page, which simply displays the product and adds the possibility to add to the cart. We can either go back, or go to the cart as well. Then we have the cart. Which displays items. The only navigation here is back. Maybe I'll do a part 3 where I expand on the navigation possibilities. For now KISS (Keep It Simple Stupid).

I won't go over the constructor or setNewRoutePath since they're the same as before. The only real thing of note, is we're storing the previous state.

Let's look at our getPages;

List<Page<dynamic>> _getPages() {
    List<Page<dynamic>> pages = [ProductsPage()];
    _previousState?.maybeWhen(
      orElse: () {},
      product: (product) => pages.add(ProductPage(product)),
    );

    _currentState.when(
      products: () {},
      product: (product) => pages.add(ProductPage(product)),
      cart: () => pages.add(CartPage()),
    );

    return pages;
  }

So at the far back of our stack, we always want the products page. It's our new home sweet home. We first take a look at if the previous state is a product page, and if it is, we insert it into the back stack. What this means, if we click the cart page from the product page, then we want to go back to the product page, not the list of products page.

Now we look at our current state. Do we go to the Products screen? We'll we already have that one in the list. Do we go to the product screen? We'll let's add it to the list. Similarly with the cart. If that's where we're going we need to go there.

Let's take a look at our build method now.

  @override
  Widget build(BuildContext context) {
    return BlocListener(
      cubit: BlocProvider.of<HomeCubit>(context),
      listener: (_, HomeState state) {
        _previousState = _currentState;
        _currentState = state;
        notifyListeners();
      },
      child: Navigator(
        pages: _getPages(),
        onPopPage: (route, result) {
          if (!route.didPop(result)) {
            return false;
          }
          return _currentState.when(
            products: () {
              return false;
            },
            product: (product) {
              _currentState = _previousState ?? HomeState.products();
              return true;
            },
            cart: () {
              _currentState = _previousState ?? HomeState.products();
              return true;
            },
          );
        },
      ),
    );
  }

When our BlocListener changes, it will set the previous state as the current state, then set the current state to the new state. The real fun is in the Navigator. Remember that onPopPage allowing back navigation? Well let's look at it.

First we want to check the result of the pop. If it failed, we can't go back so we return false. Now we want to conditionally configure if we can go back. If we're on our base of products, then no, we don't want to go back. If we're on the product screen, we want to set our current state to the previous one and go back. Same for the cart screen. If previous state is null (since it is nullable) we'll use the default of products.

What do we get?

We get the app we promised at the start! That's all! That's it!

What's next

So part 2 will be covering how to sync a web app to the url path. But that's a problem for another day.

Casey Daniel

Casey Daniel

Canada
Tracy Pyett

Tracy Pyett