Simple Flutter navigation with Riverpod

Pavel Zika
7 min readApr 3, 2022

--

I’m 61, so don’t take me too seriously…

Introduction

Google’s “Flutter Navigator 2.0” search returns many results in which developers complain about its complexity. But fortunately, it doesn’t have to be that complicated.

Remi Rousselet Riverpod, the production version of Flutter on the Web and Windows is what interests me now. How do these tools affect the use of Flutter navigation?

Riverpod …

… is the best and most intuitive state management library I’ve ever used.

Navigator 2.0 …

… is a hit. The fact that the navigation stack can be updated all at once greatly simplifies abstraction and development.

Flutter on the Web …

… brings new challenges to navigation:

  • The current web browser URL, which reflects the Flutter navigation stack, can be easily saved and then reused. Regardless of the current state of the application (e.g. whether the user is logged in or not).
    Therefore, the application must handle many more navigation states correctly (for example handle the state when the URL contains a screen that requires a login but the application is not logged-in).
  • Switching between individual navigation stacks with a quick click on the Back x Forward web browser buttons. For example, if the screens of your application load and store data in external storage asynchronously, then this can cause problems without proper synchronization (when the screen in the new navigation stack uses the data stored by the screen from the old stack).

Prerequisites

The article presumes general knowledge of the riverpod package and Flutter Navigator 2.0.

I am using strictly typed navigation, where you can use an object instead of a string. E.g. use navigate([HomeSegment(),BookSegment(id: 2)]) instead of navigate(‘home/book;id:2’) in your code.

Terminology used

Take a look at the following terms related to URL path home/book;id=2.

  • string-path: e.g. home/book;id=2
  • string-segment: the string-path consists of two slash-delimited string-segments (home and book;id=2)
  • typed-segment: typed variant of string-segment’s (HomeSegment() for 'home’ and BookSegment(id:2) for 'book;id=2')
    typed-segment is inherited from class TypedSegment {}.
  • typed-path: typed variant of string-path ([HomeSegment(), BookSegment(id:2)] for home/book;id=2)
    typed-path is typedef TypedPath = List<TypedSegment>
  • Flutter Navigator 2.0 navigation-stack is uniquely determined by the TypedPath (where each TypedPath’s TypedSegment instance corresponds to a screen and page instance): e.g.[MaterialPage (child: HomeScreen(HomeSegment())), MaterialPage (child: BookScreen(BookSegment(id:2)))] for [HomeSegment(), BookSegment(id:2)]

A simple example

I have prepared a simple DartPad Riverpod + Navigator 2.0 example.

Data Flow Diagram

The following Data Flow Diagram is implemented in the example. Its core is riverpod navigationStackProvider:

Each time the navigationStackProvider changes, the RouterDelegate.notifyListeners method is called. RouterDelegate then rebuilds the navigation stack (in RouterDelegate.build).

Guards, redirects and more providers

In more complex cases, the dual role of navigationStackProvider from the previous example causes the problem:

  1. navigationStackProvider on the one hand represents the current Flutter navigation stack.
  2. navigationStackProvider on the other hand defines the intention where to navigate.
    At this moment, the application navigation logic has a chance to change this intention, for example, to redirect to the login screen.

It is much more convenient to divide these two tasks between two providers:

  1. The navigationStackProvider represents the current Flutter navigation stack.
  2. The newly-introduced intendedPathProvider defines the intention where to navigate.

See the changed Data Flow Diagram. In addition, this new approach allows the use of other providers on which the navigation stack depends. It looks like this:

The applicationLogic method responds to the change of all input providers. It has the option to return a value different from that held by intendedPathProvider (e.g. if a redirection is required). This new value is then assigned to both the navigationStackProvider and the intendedPathProvider.

Login flow example

I have prepared a DartPad login flow example in which this Data Flow Diagram is implemented. In the example, it is not allowed to display the BookScreen with the odd BookSegment.id without logging on.

The Input state includes an isLoggedProvider (StateProvider<bool>), which contains logging information.

Also, the AppNavigator.appNavigationLogic method implementing login-flow is added.

A bit tricky

The only tricky part of the example is coordination when changing multiple providers at once. LoginScreen contains a Login button with this code:

ElevatedButton(
onPressed: () {
ref.read(intendedPathProvider.notifier).state = [HomeSegment()];
ref.read(isLoggedProvider.notifier).state = true;
},
child: const Text('Login'),
),

It is desirable to rebuild the navigation stack only once after all providers have changed. This is accomplished by changing the navigationStackProvider in the next dart event loop. See using scheduleMicrotask in Defer2NextTick.providerChanged.

Asynchronous Navigation challenge

I use the term Asynchronous Navigation when a transition from the old navigation stack to the new one requires an asynchronous operation.

For example, when the screen of the old stack saves data asynchronously to external storage when it is deactivated (when Closed). And the new stack screen uses this data when creating (when Opened). Then some synchronization is needed so that the current data is always used.

Editing and using data shared by multiple screens will be called side effects, see the modified Data Flow Diagram:

Compared to the previous diagram, there are two changes:

  • applicationLogic is asynchronous
  • the term side effect is introduced

Asynchronous transition to a new navigation stack

First, let’s look at how to correctly synchronize the transition from the navigation stack A to B. You must first close the old stack A (where some stack screens store external data, for example), and then open the new stack B (where some stack screens use this data).

It can be graphically represented as follows:

Fast transitions between two navigation stacks

It is not uncommon for the transition to stack C to take place during the asynchronous transition from stack A to stack B:

Only the last of the following solutions is correct:

Correct solution:

  • A is closed => B is opened => B is closed => C is opened => stack is changed
  • the transitions do not overlap
  • the change of the stack (orange colouring) occurs only once

Fast transitions between multiple navigation stacks

This situation can also occur when you quickly click on the back browser button in the Flutter on the Web application:

Then the correct solution is to omit the transitions B-> C and C-> D completely:

A is closed => B is opened => B is closed => E is opened => stack changes:

Side effects during screen interaction

Another situation leads to a dangerous and incorrect transition to a new stack. During the interaction with the screen the user performs an asynchronous side effect interaction (e.g. click the button that async saves the currently edited data). And before this save is finished, he presses the Back button of the web browser.

The solution is to wait for the user interaction to complete before starting the stack change:

If the screen allows more quick save’s (when the next save is done before the previous one is completed), it is a screen problem and it is not within the navigation competence.

A simple solution to prevent more uncoordinated clicks is to overlay the entire screen with a transparent widget during the save. The widget will not allow other clicks (capture them via AbsorbPointer). And as a bonus, it can display a wait animation (ProgressIndicator) after 250ms of waiting.

Example

Take a look at an example of a correct solution to all the mentioned async situations (source code see here), where the following problems are solved:

  • the synchronized transition between navigation stacks
  • fast transitions between stacks
  • side effect during screen interaction
  • AbsorbPointer widget with ProgressIndicator

In the example, try quickly pressing on the back x forward browser buttons while the 5000msec delay is running (Side effect (5000 msec) button).

Async navigation is a little more complicated. Therefore, I created the riverpod_navigator package (used in the example).

Motivation

Why are so many pictures and texts around the issue that I have called Asynchronous Navigation?

One eLearning ReactJS project I used to work on bothered us a lot. Everything worked well except for a customer in South Africa who complained of an occasional fatal error.

It cost a lot of time and money to find the cause: the backend server was in Frankfurt, Germany. BrowserStack showed latency of 100ms for Prague, 300ms for US West but 1200ms for South Africa. The cause was the case “Side effect during screen interaction” mentioned above. Students occasionally clicked on the Back browser button while data was being saved.

Conclusion

In the article, I tried to present my view on Flutter navigation using the Riverpod package. If you did not take this feeling from the article, it is written incorrectly:

  • Flutter Navigator 2.0 and Riverpod are great tools that make development a lot easier. They make it possible to convert the whole navigation problem into a simple manipulation of the TypedSegment list
  • The coordination during the (fast) transition between multiple async navigation stacks is a little more complicated.
  • In this case, an appropriate package like riverpod_navigator can help.

--

--