Flutter for Single-Page Scrollable Websites with Navigator 2.0 — Part 6: Navigation
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.
- Part 1: Introduction
- Part 2: Scroll to Position
- Part 3: Scroll to Page
- Part 4: Ensure Visible
- Part 5: Scroll to Index
- Part 6: Navigation
- Part 7: Query Params
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:
- First visible section changes (trailing index) as the user scrolls.
- Top or side navigation menu button clicks.
- User interaction with the Web browser app such as back/forward button click, or entering URL on the address bar.
- Clicking the shaped button in the sections.
- 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:
- The
RouteInformationProvider
gets location and state information from theBrowserHistory
entry and maps it to theRouteInformation
. Then theRouteInformationParser
parses theRouteInformation
and returns an instance of the configuration. Finally, theRouterDelegate
updates the app state according to this configuration instance and builds provides aNavigator
widget to theRouter
.
Router
widget reportsRouteInformation
in certain events to the engine. For example, once the navigation stack changes upon app state update, a newRouteInformation
reporting is scheduled. When theRouteInformation
reporting task is scheduled, theRouter
widget first retrieves the current app configuration from theRouterDelegate
and then passes it to theRouteInformationParser
. Using this configuration,RouteInformationParser
restores theRouteInformation
which is then provided back to the engine through theRouteInformationProvider
.
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. 👇
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 .`👇
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:
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 returnhome
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 theUnknownScreen
. - If
unknown
is set to false, in any case, the bottom page in the stack isHomePage
. - If the selected shape border type and selected color both have values, we add the
ShapeDialog
above theHomePage
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.