Flutter for Single-Page Scrollable Websites with Navigator 2.0 — Part 2: Scroll To Position

Cagatay Ulusoy
Level Up Coding
Published in
9 min readJul 18, 2021

--

In the first part of this series, we discussed the options when building a scrollable widget in a single-page scrollable website (SPSW) and explained the sample apps that we will be building. In this article, we will explore building an SPSW using a ListView whose sections are built lazily (on-demand) and have equal height.

HomeScreen

HomeScreen has a Column with two main components: a sticky top navigation menu and an expanded scrollable list. We provide colors, colorCodeNotifier, and shapeBorderTypeNotifier to HomeScreen as constructor parameters.

Note that although shapeBorderTypeNotifier and colorCodeNotifier are known to be instances of ValueNotifier , we pass them as ValueListenable which is the parent class of ValueNotifier. It is a good practice because while the ValueNotifier instances can notify their listeners, ValueListenable instances cannot. Inside HomeScreen widget, we won’t notify the listeners, but its children widgets will do. Hence, it is safer to include their type as ValueListenable in HomeScreen.

TopNavigationMenu

TopNavigationMenu widget wraps all the NavigationMenuButton widgets in a horizontal run. The number of runs varies depending on the screen width.

This widget both listens and notifies the colorCodeNotifier:

  • It listens to the color code values coming from different sources and fills the background of the button if the current color code is the same as the button text.
  • It notifies the color code listeners on the user button press. When the user clicks a color button, the button’s Color is converted to a String hex color code and the listeners of colorCodeNotifier are notified.

Let’s first analyze the NavigationMenuButton . It is a StatelessWidget that includes either a TextButton or an ElevatedButton depending on the selected parameter.

  • If the selected is false , then it is a TextButton whose text color is the same as the color parameter.
  • If the selected is true , then it is an ElevatedButton whose background color is the same as the color parameter. We set the text color black or white according to the estimated brightness of the color.

TopNavigationMenu is a StatefulWidget . It listens colorCodeNotifier and whenever its value is updated, setState method is called so that NavigationMenuButton widgets are rebuilt according to new selected state. The button whose text value equals to the hex color code value in the colorCodeNotifier will be the selected one.

On second thought, the TopNavigationMenu widget can be a stateless widget thanks to Flutter's ValueListenableBuilder widget which helps the content to stay synced with a ValueListenable . In our case, we will wrap the content with a ValueListenableBuilder for colorCodeNotifier . Whenever the value that the colorCodeNotifier holds is updated, the widget provided to the builder field is rebuilt.

ColorSections

ColorSections is a scrollable widget that lists all the color sections. This widget both listens for the color code changes and notifies the color code listeners:

  • It listens to the color code values coming from different sources and scrolls to the corresponding color section if the source of the value update is not scrolling.
  • It notifies the color code listeners when the first visible section (trailingIndex ) changes and also when a shaped button is pressed.

In the recording below, when a shaped button is clicked, all the ColorCode listeners will be notified. The listener in the ColorSections widget will trigger a scroll to the section that has the shaped button.

We build the ListView lazily using ListView.builder constructor. Instead of building all the list items (color sections) at once, the items will be built when they are visible on the screen. Obviously, this helps from the performance point but comes with a cost as mentioned in the introduction part of this series.

Since all the items are not laid off at once (built lazily), we cannot know the offset of all items unless we have their size information before they are built. As a result, when a color code is selected from the TopNavigationMenu we cannot jump to the corresponding color section if the sections have unpredictable heights.

In this sample, we assume that all the list items have the same height according to this formula: If the available height (visible listview area, viewport height) is smaller than _minItemHeight (700 px), it is equal to _minItemHeight , otherwise the available height.

Listen to Color Code Update

In ColorSections , we listen to colorCodeNotifier to scroll to a color section. Note that the colorCodeNotier value can be set from different sources. We will not programmatically scroll to a section if the color is updated due to scroll event.

When the value that colorCodeNotifier holds changes and the source of the change is not scrolling, we do the following:

  1. Find the index of the color code from the colors list
  2. Calculate the scroll offset by multiplying the index with the item height. We can get the available height information from the size property of the ColorSections ' context on a button click because the ColorSections widget is already laid out. Hence, the framework can determine its size.
  3. Call ScrollController.animateTo method to programmatically scroll to the section.

Notify a Color Code Update

While the user is scrolling, we want to update the colorCodeNotifier listeners as the first visible color code section (trailingIndex) changes. Note that when we are setting the colorCodeNotifier notifier value, we set its source field as fromScroll .

The Scrollable widgets notify their ancestors about the scroll events with ScrollNotificaton. A ScrollNotificaton will be traversed up in the tree until a NotificationListener handles it. TheNotificationListener widget doesn’t have to be the parent of the source Scrollable widget, but it has to be an ancestor widget that specifies the Notification type as ScrollNotificaton.

In our case, we wrap the ListView widget with a NotificationListener widget whose onNotification callback property will be invoked by two scroll event sources:

  1. Programmatic scroll caused by a color button press in the TopNavigationMenu
  2. User-generated scroll with a touch, trackpad, or mouse event.

We need to distinguish the scroll event source and notify the colorCodeNotier listeners only for the user-generated ones. Hence, we check the runtimeType using the is operator and handle the notification if only its Type is UserScrollNotification .

When we receive the UserScrollNotification , we get the offset (pixels ) from the notification and calculate the first visible index (trailing index) by dividing the offset by the list item height. We can get the available height information from the context because the ColorSections widget is already laid out during user scroll.

pixels is the number of pixels to offset the children in the opposite of the axis direction. For example, if the axis direction is down, then the pixel value represents the number of logical pixels to move the children up the screen …

By returning true in the onNotification method, we stop the bubbling of the UserScrollNotification in the widget tree.

As mentioned before, we can’t get the item height from the BuildContext during a build. If we do so, we will get the following error:

The size of this render object has not yet been determined because the framework is still in the process of building widgets, which means the render tree for this frame has not yet been determined. The size getter should only be called from paint callbacks or interaction event handlers (e.g. gesture callbacks).

If you need some sizing information during build to decide which widgets to build, consider using a LayoutBuilder widget, which can tell you the layout constraints at a given location in the tree.

Hence, we determine the section height and calculate the initial offset for the ScrollController with the help of LayoutBuilder widget.

Sections

In the ShapedBorderListView we list 5 buttons with the following ShapeBorder classes: BeveledRectangleBorder, RoundedReactangleBorder , ContinousRectangleBorder, StadiumBorder, and the CircleBorder.

We set the physics property to NeverScrollableScrollPhyscis so that the ShapedBorderListView is not allowed to be scrolled. Instead, the user scroll events will be intercepted by the parent ColorSections .

When the user clicks a button in the list, we set the values in colorCodeNotifier and shapeBorderTypeNotifier accordingly and inform their listeners.

You may wonder why there is no code related to showing the dialog on click gesture. It is because showing a dialog page is handled by the Router widget declaratively. We will discuss it more in the navigation part of this series.

Resizing the Browser Window

When I was working on this series, one of the most challenging problems I faced was the inconsistent behavior of the ScrollController on browser window resize. This is the reason why this series has waited for a long time in draft mode.

For the sake of performance, I avoided rebuilding and recreating the ColorSections widget and instantiating the ScrollController in the build method. However, when the browser window size changed, the scroll controller couldn’t jump to the correct offset unless the current offset is set to 0 again.

I realized that there are two open PRs in Flutter repository (#85221 and #82687) to address this issue. ScrollMetricsNotification is introduced and integrated in Scrollbar . Hopefully, this is the right direction that also addresses this problem.

[ScrollMetricsNotification] A notification that a scrollable widget's [ScrollMetrics] has changed.

For example, when the content of a scrollable is altered, making it larger or smaller, this notification will be dispatched. Similarly, if the size of the window or parent changes, the scrollable can notify of these changes in dimensions.

The above behaviors usually do not trigger [ScrollNotification] events, so this is useful for listening to [ScrollMetrics] changes that are not caused by the user scrolling.

I found out two ways of solving this problem before this feature is landed:

  1. The first one comes with a performance cost. Each time the window size is changed, I re-created the _ColorSectionsState state and the ScrollController . Then there was another problem: When the browser window height changes, the widget doesn’t call the build method of the ColorSections because Flutter does this intentionally for performance gain. In order to force the state re-creation, I added a ValueKey to the _ColorSections widget so that each time the window height changes, the framework creates a new _ColorSectionsState .
  2. The second solution is using a PageView . This widget has a PageController which extends the ScrollController . Apparently, the PageController adapts the viewport size changes better and doesn’t have any problem with resizing the browser window.

Conclusion

In this article, we explored lazily building the sections of a single page scrollable website using a ListView widget. In the third part of this series, we will build a similar UI using a PageView widget.

You can run the sample app by right clicking on the main_003.01.dart file in the project source code.

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

--

--