Flutter for Single-Page Scrollable Websites with Navigator 2.0 — Part 6: Navigation

Cagatay Ulusoy
11 min readJul 18, 2021

In the previous articles, we focused on implementing a single-page scrollable website from the scroll logic point. In this and the next article, we will explore how to utilize the Navigator 2.0 API to manage the URLs on the browser’s address bar.

If you are not familiar with the fundamentals of declarative navigation, I would suggest taking a break here and starting with my Flutter Navigator 2.0 for Authentication and Bootstrapping series. In the Web part of that series, we covered the two-way RouteInformation flow between the Router widget and the operating system (OS). For the sake of simplicity, we didn’t dive into how the Flutter framework layer communicates with the OS.

Flutter architecture

Flutter architecture has a layered system. Each layer is optional and replaceable [1][2].

  • Flutter framework layer contains platform, layout, and foundational high-level libraries written in the Dart language. Although it is the layer that developers mostly code in, accessing the lower layers when needed is possible.
  • Flutter engine layer is a portable runtime for hosting Flutter applications. It is the lowest layer of Flutter. It provides the low-level implementation of Flutter’s core API. It is mostly written in C++ but also Java, Objective-C, and Dart to provide communication with the underlying OS.
  • The embedder layer is a native OS application. It is an entry point to the underlying OS and contains platform-specific dependencies. It hosts the entire Flutter content and also enables integrating Flutter code as a module to the existing native applications.

Flutter was first introduced as a cross-platform toolkit for mobile applications. By the time, it is expanded to incorporate Web, desktop, and embedded platforms [3]. The engine of the Flutter Web applications needed to be different due to the unique characteristics of the Web. Therefore, the Flutter Web engine is the reimplementation of the C++ Flutter engine on top of standard browser APIs. The Dart code in the engine for the Flutter Web is compiled into JavaScript instead of ARM machine code that is used for mobile applications.

For Web apps, it is actually not the OS that the Flutter app communicates with. A Web app is sandboxed in the Web browser application so it can’t directly access the file system or low-level network [2]. For example, we can’t import dart.io libraries to Flutter Web projects. However, this doesn’t make any difference for the Navigator 2.0 API, because in the end, it is implemented in the Flutter framework layer, and it doesn’t need to know the engine implementation details.

Browser History

When we are working with the Navigator 2.0 API for Web apps, we quite often hear the term browser history. The first thing we should know is that the browser history is different than the navigation history (a.k.a navigation stack) managed by the Navigator widget. While the BrowserHistory is included in the Flutter Web Engine, the Navigator widget is part of the Flutter framework layer.

The Navigator widget takes a list of Page instances to construct a navigation history. The pages are then turned into a stack ofRoute s. When the back button is dispatched in the mobile platforms, the maybePop method of the Navigator widget will be invoked to pop the current route.

The BrowserHistory is a list of history entries each contains a location (URL) and a state object. When we click the browser’s backward or the forward buttons, the browser takes the information from the entry list prior to or next to the current history entry in theBrowserHistory class. Then, the Router widget in the Flutter framework maps theBrowserHistory entry to the RouteInformation instance to build a new navigation history with the help of its delegates.

In the last four sample apps of this series, we manage the entries in the browser history and update the URL in the browser’s address bar with the following cases:

  1. First visible section changes (trailing index) as the user scrolls.
  2. Top or side navigation menu button clicks.
  3. User interaction with the Web browser app such as back/forward button click, or entering URL on the address bar.
  4. Clicking the shaped button in the sections.
  5. Clicking the barrier of the shaped border dialog.

SinglePageAppConfiguration

The SinglePageAppConfiguration is a custom data type that is used by the Router widget and its delegates in the following ways:

In my opinion, defining a proper app configuration is the key to implement a good navigation logic using the Navigator 2.0 API. In our case, the app state is defined by three properties of the SinglePageAppConfiguration class: colorCode , shapeBorderType , and unknown .

The colorCode field is set in the following ways:

  • The user clicks a button on the side or top navigation menu bar.
  • The first visible section changes.
  • The user specifies a color code by typing a URL on the address bar.
  • The user clicks a shaped border button that has an associated color.

The shapeBorderType field is set when the user clicks a shaped button in any section or the user specifies the type by typing a URL on the address bar.

The unknown field is set depending on the validity of the URL typed by the user on the address bar.

HomePage

Since this is a single-page website, the content of the HomeScreen is expected to be heavy, so we should avoid re-instantiating the entireHomeScreen each time the Navigator widget is rebuilt. Hence, we create the HomePage instance when the RouterDelegate is instantiated, and use this instance when building a Navigator widget.

Page is only an extended version of the RouteSettings . When we instantiate the HomePage , we don’t build the HomeScreen , but instead, we tell the Navigator widget what to build when needed.

In the sample apps, I could have used a customized HomePage class which extends the Page class. However, I realized that each time the Navigator widget is built, the builder method of the HomePage returned a new HomeScreen widget instance although the Page key was the same. This is probably the expected behavior but it causes a really bad user experience especially in the case that list items are not built lazily. This issue is demonstrated in the below screen recording. 👇

Using Custom page triggers HomeScreen construction

Instead of using a custom Page class for the HomePage , I instantiated a MaterialPageRoute when the RouterDelegate is instantiated, and used that instance when building the Navigator widget. As can be noticed in the below recording, unlike the previous case, the HomeScreen is not constructed each time the Navigator widget is built. Hence, the scrolling when the first visible section changes is smoother .`👇

We use Material Page Route in sample apps for HomePage

ShapePage

The ShapePage is a very simple Cupertino dialog. It is shown when any of the shaped color buttons is clicked. One way of showing this dialog is by calling the showCupertioneDialog() method of the Flutter API on button click.

Let’s see the implementation of showCupertioneDialog() in Flutter:

Click the image to visit the source code

As seen in the source code, when showCupertioneDialog() is called, it first finds the closest Navigator widget in the widget tree, and pushes a CupertinoDialogRoute.

The modal barrier is a barricade between the current route and the route below it in the navigation stack. It is usually semi-transparent and you almost feel like you can interact with the widget in the below route. The barrier’s task is preventing this interaction. When barrierDismissable is set true, the clicks on the barrier invokes Navigator.pop method, meaning that the CupertinoDialogRoute that has the ShapeDialog widget will be popped.

Calling the showCupertioneDialog method which pushes a new route to the Navigator widget is so imperative but we want the navigation to be declarative and separate the concerns. The ShapedButton should not know anything about the navigation logic on click events. It is the job of the RouterDelegate to build a Navigator widget on app state changes caused by user interactions.

Let’s do this in a declarative way. We will first define a custom Page class. When the Navigator widget is built and if this page is included in the pages list depending on the app state, a CupertinoDialogRoute will be created.

RouteInformationParser

RouteInformationParser is responsible for mapping the RouteInformation to the configuration, and the configuration to RouteInformation .

Parsing RouteInformation

When the Router widget receives a newRouteInformation from the RouteInformationProvider , it calls the parseRouteInformation method of RouteInformationParser . Here we will map the RouteInformation to the SinglePageAppConfiguration according to the following logic:

  • If the URL doesn’t include a path, then we return a configuration that is constructed with home method.
  • If the URL includes 2 path segments, the first path segment should be colors , and the second path segment should be a valid hex color code. In this case, we return home configuration. For example: `https://mysinglepageapp.com/colors/ff5722`.
  • If the URL includes 3 path segments, the rules for the first two segments apply the same. The last path segment should be a valid shape border type. In this case, we return shapeBorder configuration. For example: `https://mysinglepageapp.com/colors/ff5722/rounded`.
  • In any other cases, we construct and return the unknown configuration.

Restoring RouteInformation

When the Router widget schedules route reporting, it calls the restoreRouteInformation method of the RouteInformationParser to forward a RouteInformation to the engine according to the current app configuration.

RouterDelegate

The main responsibility of the RouterDelegate is building a Navigator widget when the Router widget asks for it as a result of an app state change or system event. It also manages the configuration so that Router widget uses it whenever RouteInformation reporting task is scheduled.

The Navigator widget is usually located near the top of the widget tree. The widget that causes app state change on user interaction may be deep down in the widget tree. We need to find a good mechanism to propagate the state change from the deep down of the tree to the top of the widget.

In the Flutter Navigator 2.0 for Authentication and Bootstrapping series, we passed callback functions starting from the RouterDelegate to the children widgets through the Page classes. This is not recommended because as the app gets more complex, we will need to pass the callback methods on many widgets from the top of the tree until the destination widget.

There are different ways of improving the app state management using the state management libraries. In this article, we will not focus on state management topic but we will use ValueNotifier instances instead of callback methods. Using ValueNotifier will not solve the problem of passing the ValueNotifier instances down to the children in the widget tree but by using ValueNotifier we make sure that its listeners will be invoked only when the single value that it holds is updated.

ValueNotifier A ChangeNotifier that holds a single value. When value is replaced with something that is not equal to the old value as evaluated by the equality operator ==, this class notifies its listeners [4].

Reacting to App State changes

When the Router widget is notified as a result of app state change, it expects a Navigator widget from the RouterDelegate . In the build method of RouterDelegate we will return the Navigator widget with a list of pages .

The logic is very simple:

  • If unknown is set to true, the stack shows only the UnknownScreen .
  • If unknown is set to false, in any case, the bottom page in the stack is HomePage .
  • If the selected shape border type and selected color both have values, we add the ShapeDialog above the HomePage in the stack.

After receiving the Navigator widget, the Router widget may schedule route information reporting and ask the RouteInformationParser to restore the RouteInformation which helps to update the URL in the address bar of the Web browser.

When we click the barrier field of the dialog route, onPopPage method of the Navigator widget is triggered. In this case, we first make sure that route is actually popped. Then, we check the route’s name. If the name equals to the ShapePage`s name, we set the shapeBorderTypeNotifier value to null .

Here, I handled setting the shapeBorderTypeNotifier value to null in onPopPage method of the Navigator widget. I am not happy with doing things in onPopPage method because it still sounds legacy. It could have been better to update the app state on user interaction events such as onDialogDismissed . However, as of today, I am not aware of a callback that is fired when the dialog is dismissed due to a barrier. Hence, I had to handle the app state change in this way.

Reacting to URL updates

After receiving the configuration from the RouteInformationParser , the Router widget passes it to the RouterDelegate by calling its setNewRoutePath method. In this method, we have a chance to update the values that are held in the ValueNotifier fields. Then, the Router calls the build method of the RouterDelegate and receives a freshly constructed Navigator widget according to the new state.

Reporting the Configuration

When the Router schedules reporting the RouteInformation to the engine, it retrieves the current app configuration from the RouterDelegate . Therefore, in the getter method of the currentConfiguration we need to construct a SinglePageAppConfiguration according to the values that are held in the ValueNotifier fields.

Conclusion

In this article, we studied the integration of the Navigator 2.0 API into our sample apps. From the first sample to the fourth sample app, we use selected color and shape border types as variables in path segments for URLs. To see the implementation in the demo you can run any of these samples on Github. In the next article, we will explore using these variables as query segments in one path segment

If you liked this article, please press the clap button, and star the Github repository of the sample apps.

--

--