The Basics of Scrolling in Flutter
The Basics of Scrolling in Flutter
The Basics of Scrolling in Flutter

The Basics of Scrolling in Flutter

Flutter

Flutter

Flutter

Slivers: it-which-must-not-be-named or an unnecessarily feared topic?

11. 1. 2023

Scrolling — almost every app needs it, and almost nobody wants to learn it. Think of every time you read about issues related to scrolling not working as intended and how they should be fixed. And don't even get me started on slivers 🪄, the it-which-must-not-be-named in the magical world of Flutter. But in case you ever get in trouble, you can use this write-up as an entrance point to the wonderful and sometimes strenuous process of designing scrolling in Flutter.

Everything is a widget

Let’s start with a recap.

As you may have heard, in Flutter, “everything is a widget.” One can use widgets to do the primitive stuff, like display, offset, and style something on the screen. You might want to use widgets like Container or Text. To lay out widgets horizontally or vertically, or in whatever fashion you like, you also use widgets that help with that. Widgets like ColumnRow or Stack focus on layouting multiple-child widgets on the screen, while others, such as Align or Padding, deal with layouting single-child widgets. Sometimes, you have to use more advanced widgets to handle overflowing, mainly to efficiently display large horizontal or vertical content. Such widgets handle scrolling for you, and many of them use remarkably efficient algorithms, so you don’t end up rendering all children but only those you actually need.

Let’s talk about constraints

All widgets have something in common, but let’s focus on box constraints. As you probably know, constraints in Flutter work differently from the web, to give an example. Flutter constraints provide an efficient way to lay out and display widgets on the screen. As the Flutter docs say, “Constraints go down. Sizes go up. Parent sets position.” That is the bread and butter of constraints in Flutter.

In practice, each widget asks its parent for constraints, and it provides them with minimal and maximal height and width. The process repeats for all childen of this widget. Then, the widget positions its child widgets and tells a parent about its size. And so on. This describes the quote from the Flutter docs. As a result, the widget only calls for the desired size, but the actual sizing and aligning are done by its parent.

Due to properties of Flutter constraints, when you lay out a Row stretched to the whole screen with two Containerchildren, where the former sets its height to 100 and the latter to 200, children are rendered with max height, not considering the manually set heights. That’s exactly how it should work based on the quote and previous explanation. But, sometimes, you want to enforce that the size is truly what you set up. You have two basic options to consider here. You either set ConstrainedBox or [IntrinsicHeight] above the Row. Both will limit the height for your needs, but with the former, you have to specify the min-height. While using the latter, it magically works without any further input, and the height is set as the max height of the children. While the latter approach sounds easier because it works by itself, be aware that it does some additional computation that makes the algorithm O(N2), so use it with caution.

Layout widgets

As has already been mentioned, to lay out widgets, we use widgets that can lay out single-child or multi-child widgets. While Column works amazingly well for layout widgets in a column, when you reach the limit of its parent constraints, an error like A RenderFlex overflowed by 154 pixels on the bottom. will appear. That tells you the widget cannot lay out its children according to its constraints and children’s sizes.

If you’re coming straight from web development, this might seem very weird to you. There, scrolling is automatically available on the whole document, or you can set up heights and overscroll to enable it on any element. You don’t distinguish if the element is scrollable or not; everything can be set up. This may be a bit easier for beginners, but in the end, the whole layout and rendering process of the website performs worse than Flutter.

======== Exception caught by rendering library =====================================================
The following assertion was thrown during layout:
A RenderFlex overflowed by 154 pixels on the bottom.

The relevant error-causing widget was: 
  Column Column:file:///Users/tenhobi/sandbox/lib/test_app.dart:8:17
The overflowing RenderFlex has an orientation of Axis.vertical.
The edge of the RenderFlex that is overflowing has been marked in the rendering with a yellow and black striped pattern. This is usually caused by the contents being too big for the RenderFlex.

Consider applying a flex factor (e.g. using an Expanded widget) to force the children of the RenderFlex to fit within the available space instead of being sized to their natural size.
This is considered an error condition because it indicates that there is content that cannot be seen. If the content is legitimately bigger than the available space, consider clipping it with a ClipRect widget before putting it in the flex, or using a scrollable container rather than a Flex, like a ListView.

The specific RenderFlex in question is: RenderFlex#67075 relayoutBoundary=up1 OVERFLOWING
...  parentData: offset=Offset(0.0, 0.0); id=_ScaffoldSlot.body (can use size)
...  constraints: BoxConstraints(0.0<=w<=1800.0, 0.0<=h<=1046.0)
...  size: Size(1800.0, 1046.0)
...  direction: vertical
...  mainAxisAlignment: start
...  mainAxisSize: max
...  crossAxisAlignment: center
...  verticalDirection: down
◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤

Here, I just want to pay respect to Flutter for providing wonderful error messages, coupled with several possible reasons and suggestions on how to solve the problem.

Scroll widgets

In Flutter, if you want to enable scrolling, you must wrap the desired widget in a [Scrollable]. That widget implements all interactions and gesture recognition, among other things. Most of the time, you will use a descendant of that widget, like [ListView]. To keep it simple, you can wrap the desired widget in [SingleChildScrollView], which enables scrolling by wrapping it as its child.

Although technically feasible, don’t just wrap a Column in SingleChildScrollView. This scrollable widget has no optimization, and you will render everything every time. Imagine that you have a list of hundreds or thousands of widgets. Instead, use another scrollable widget descendant, like ListView. By using virtualization, this widget substantially improves performance, but it still has to compute for the visibility of every child. And so, to reach peak performance, it’s preferred to use ListView.builder, where children are built on demand by specifying an IndexedWidgetBuilder, which returns a widget for concrete index. With this constructor, you can have a virtually infinite number of children in the list, and all the computing only happens for the visible ones, possibly for a couple of before and after that. Hence, scrolling is faster using precomputed data.

Principle of virtual scrolling — only visible items are rendered. (Virtual scrolling | LogRocket)

Don’t forget to check the scrolling widgets catalog. There, you can find additional scroll widgets, configurations, notifications, and many other useful tools.

Slivers

Now, let’s dig further down our rabbit hole and explore one particular word that instills fear in many coders: slivers.

Slivers are also widgets, because everything is a widget.

Under the hood, they’re used for every scrollable, such as ListView. While slivers are a low-level interface, they are essential for creating custom animations, scrolling control or unifying scroll across multiple scrollable areas. Here’s a neat definition:

A sliver is a portion of a scrollable area that you can define to behave in a special way. You can use slivers to achieve custom scrolling effects, such as elastic scrolling.

A sliver is a portion of a scrollable area that you can define to behave in a special way. You can use slivers to achieve custom scrolling effects, such as elastic scrolling.

A sliver is a portion of a scrollable area that you can define to behave in a special way. You can use slivers to achieve custom scrolling effects, such as elastic scrolling.

Sliver widgets differ from others by not being represented as boxes and not having box constraints. Standard widgets are based on [RenderBox], and to lay them out, BoxConstraints are passed to it; the final size is calculated from that. Sliver widgets are based on [RenderSliver], and during layout, each sliver receives a SliverConstraints, and based on that, SliverGeometry is computed. Slivers also need to know where they are on the scrollable. As explained when discussing constraints, regular box widgets don’t care about their position.

Both RenderBox and RenderSlivers are protocols for rendering based on RenderObject. Since this article only covers the essentials of scrolling, we might take a more careful look into it next time.

To render slivers, use CustomScrollView and pass your slivers inside the slivers property. Slivers are built lazily when they appear on the screen in the viewport. That is beneficial performance-wise, even with all the effects based on scrolling you can do with them. While slivers implement Widget, you cannot pass a non-sliver widget into the slivers property. The analysis won’t tell you that, so just keep that in mind. Instead, put there widgets that usually start with a Sliver*prefix, such as SliverAppBar. Beware that this only applies to widgets producing RenderSliver. Similarly, you cannot use slivers in place of a regular widget because they do not produce RenderBox.

It’s worth mentioning that if you want to use slivers and CustomScrollView while relying on Talkback/VoiceOver to support some accessibility features in Flutter’s scroll areas, you definitely can!

From the Flutter API, this widget can send notifications to the platform API, so it might surprise you with an announcement of "showing items 1 to 10 of 23". You must provide the first visible child index and the total number of children. Some implementations of slivers like ListView can do it automatically. If you pay special attention to accessibility, take a look at the docs.

SliverToBoxAdapter

The simplest sliver to describe is [SliverToBoxAdapter]. As the name suggests, it converts a box widget to a sliver, so you can easily display Container or Text in CustomScrollView. But be aware of overusing it for multiple widgets that would otherwise be used inside SliverList. The reasoning for this is very similar to the aforementioned SingleChildScrollView with Column. Simply put – it works, but especially if you have dynamic number of widgets that better suit a list or grid, use slivers with built-in support. SliverToBoxAdapter should be used as a last resort.

SliverToBoxAdapter(
  child: Container(
    height: 200,
    color: Colors.red,
  ),
)

SliverAppBar

This sliver implements the Material Design app bar. [SliverAppBar] is used as the first sliver in CustomScrollView with various parameters for modification. You can set up a leading widget, actions widgets, and flexibleSpace that expands for you and shrinks on scroll.

SliverAppBar(
  title: const Text('My title'),
  leading: const Icon(Icons.arrow_back),
  pinned: true,
  actions: const [
    Padding(
      padding: EdgeInsets.only(right: 10),
      child: Icon(Icons.info_outline),
    ),
    Padding(
      padding: EdgeInsets.only(right: 10),
      child: Icon(Icons.settings),
    ),
  ],
  expandedHeight: 150,
  flexibleSpace: Container(
    alignment: Alignment.bottomCenter,
    child: const Text('expanded'),
  ),
),

It also utilizes the [SliverPersistentHeader] sliver, which provides a simple API for building content that can shrink on scroll. Keep in mind that its delegate has to have minExtent defined and maxExtent properties.

SliverList, SliverGrid

Next, let’s take a look at [SliverList]and [SliverGrid], the two equivalents of ListView and GridView widgets. Both slivers accept the delegate parameter that accepts a SliverChildListDelegateor a SliverChildBuilderDelegate. The former delegate accepts explicit List<Widget> list of box widgets. The latter accepts a builder that builds box widgets lazily based on BuildContext and index. To SliverChildBuilderDelegateyou can also pass the total number of widgets using a childCount property, or if null, the number is determined by index of the last builder call that returns null.

SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) {
      return Container(
        height: 100,
        alignment: Alignment.center,
        color: index.isOdd ? Colors.green : Colors.yellow,
        child: Text('SliverList - item $index'),
      );
    },
    childCount: 5,
  ),
)

SliverGrid also requires the gridDelegate property, which can be SliverGridDelegateWithFixedCrossAxisCount or SliverGridDelegateWithMaxCrossAxisExtent. As the name implies, the first explicitly specifies the number of items in cross axis with the crossAxisCount property. Further, the second specifies the maxCrossAxisExtent property that sets the widget’s max extent in cross axis.

SliverGrid(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 4,
    crossAxisSpacing: 20,
    mainAxisSpacing: 20,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) {
      return Container(
        height: 100,
        alignment: Alignment.center,
        color: index.isOdd ? Colors.orange : Colors.deepPurpleAccent,
        child: Text('SliverGrid - item $index'),
      );
    },
    childCount: 10,
  ),
)

Slivers for list and grid come in many different variations. For example, [SliverFixedExtentList], where each widget has the same extent, [SliverPrototypeExtentList], where each widget has the same constraints as the prototype, or [SliverReorderableList] that allows you to reorder the list items and provides callback for reordering. Both also have animated versions, which support animations when inserting or removing items; you’ll find them under [SliverAnimatedList] and [SliverAnimatedGrid].

SliverFillRemaining, SliverFillViewport

Another sliver you shouldn’t miss is [SliverFillRemaining]. Based on the value of its property hasScrollBody, the sliver will fill the maximum available extent when true, meaning the whole viewport. When false, it will only fill the space that remains to be filled. This will help you position a widget to the bottom of the viewport, and when another sliver before that extends the viewport, it will be the last thing you can scroll to. And when true, you can create custom slides-like scrolling area. It can also alter its behavior to fill up extra space if used around another sliver that alters the layout, like SliverFillViewport.

In the process of writing this article and testing the parameters, I came to notice this sliver’s intriguing behavior when it’s not the last item in the scrollable and hasScrollBody is set to true. Try it yourself and set the alignment to Alignment.bottomCenter. The resulting effect is very interesting because the content is visible and on the bottom of viewport every time you see this viewport-extent widget on your screen.

SliverFillRemaining(
  hasScrollBody: false,
  child: Container(
    alignment: Alignment.center,
    padding: const EdgeInsets.all(30),
    child: const Text('SliverFillRemaining'),
  ),
)

Another cool sliver is [SliverFillViewport] that accepts multiple widgets in a children argument and sizes each of them to fill the whole viewport. It can be used with additional arguments like viewportFraction that scales axis extent based on this fraction. If you set up panEnds to true, which is a default value, the last widget will be offset so the widget is centered in the viewport. I can imagine using this sliver for a slides-like application, especially when combining it with no scroll behavior and a handful of icons for moving on to the next item.

SliverFillViewport(
  delegate: SliverChildListDelegate(
    [
      Container(
        color: Colors.red,
        alignment: Alignment.center,
        child: const Text('SliverFillViewport 1'),
      ),
      Container(
        alignment: Alignment.center,
        color: Colors.green,
        child: const Text('SliverFillViewport 2'),
      ),
    ],
  ),
)

Other slivers

There are hundreds of slivers you can implement while also extending your own. But make no mistake — equivalents for many regular widgets like [SliverVisibility][SliverSafeArea][SliverOpacity][SliverPadding][SliverOffstage][SliverIgnorePointer] or [SliverLayoutBuilder] already exist, so it’s always best to check beforehand.

Closing thoughts

Applying slivers doesn’t have to be a daunting task since their basic use can be very easy to pick up. Therefore, don’t be scared to try them out — you can achieve very cool effects with them even if you’re just starting out your journey with Flutter.

And if you still need some convincing, check out this small demo, showing off all slivers we have been through in action.


An example of slivers discussed in this article. (NetGlade)

An example of slivers discussed in this article. (NetGlade)

Further readings

Layouts in Flutter | docs.flutter.dev

Understanding constraints | docs.flutter.dev

Using slivers to achieve fancy scrolling | docs.flutter.dev

Sources

Virtual scrolling | LogRocket

Podobné články

Podobné články

Podobné články