Slivers or how scrolling works

Mikhail <mbixjkee> Zotyev
14 min readJul 5, 2023

Hi everyone! My name is Mikhail Zotyev, I’m a software engineer, passionate about Dart and Flutter.

In this article, I want to talk about Slivers. If you feel this picture with the word Sliver, you’ve definitely already worked with them.

Slivers are usually pretty much demonized, especially by beginners in the topic, because of the underhood complexity. When we start to figure out how Slivers work and see all kinds of responsibilities and entities, we understand that the TV series Dark is pretty straightforward and not at all twisted compared to them. But in order to make good use of the Slivers we need to understand their inner workings. That is a bit unusual requirement for a Flutter person because we used to have everything easy and high-level when working with Flutter. Therefore, quite often people decide that it is not worth spending time learning Slivers and prefer to do hacks instead of them.

Today we gonna fix it, explore all these internal secrets of Slivers, and will become able to implement any magic scrolling behavior we ever can imagine. Let’s go on this journey.

What is a Sliver and why do we need them?

This question will be our entry point. To answer let’s recall how Flutter builds layout. I’m sure you are familiar with this, but let’s recall this information anyway.

Render Objects are responsible for this. Three rules make it possible:

  • Constraints go down by the tree from the parents to the children.
  • Sizes go up by the tree from the children to the parents.
  • Parents set the position of the children.

In general, when we say “constraints” we mean BoxConstraints. BoxConstraint has only 4 properties — minimal and maximum width, minimal and maximum height.

We give these constraints to the layout method and want to get back a size. But it works for general cases, so let’s look closer at scrolling.

For example, let’s take a rather common Android AppBar behavior when it changes its state based on scroll progress.

When we scroll AppBar becomes less in size before it disappears completely. We continue to scroll further for a long time and when we change the scrolling direction, AppBar appears anyway irrespectively of how much content we’ve scrolled. And it is one of the common UX for scrolling. But this behavior is hard or even impossible to implement with BoxConstraints, because it has too few properties for defining the object’s size and position, so we need a more flexible approach, let’s call it a Sliver Protocol.

But you shouldn’t think that Sliver Protocol is something fundamentally different. The rules are the same, but we get flexibility by using more detailed objects for constraints and sizes, that is SliverConstraints And SliverGeometry.

So, the first simple conclusion:

Slivers are widgets with a more flexible approach to layout.

How to implement scrolling?

Before we start to figure out the internal implementation of SliverConstraints and SliverGeometry, I would like to offer you a small game. Let’s imagine that Flutter doesn’t exist and we are working on the creation of our own Flutter, where we need to implement a scrolling system. What should we do? Think to yourselves, and then check on my version below.

From my point of view, we need 3 responsibilities here.

  • The interaction layer detects user gestures.
  • The container stores all objects which we want to scroll.
  • The content that should be scrolled.

Let’s return to Flutter and check how scrolling works there. Not surprisingly, our views on this question coincide. Let’s check the concrete entities responsible for each part one by one.

Scrollable

The first responsibility, as we mentioned, is working with user gestures. For that, scrollable is responsible.

What is scrollable? The documentation tells us:

Scrollable is a widget that scrolls.

But without jokes, it is the only thing that we need to know about Scrollable. It is the interaction layer, that includes gesture recognition but doesn’t have a clue about the content, what is shown, etc.

Viewport

The next responsibility is a container for scrolled content. In Flutter it is a Viewport class, the subclass of MultiChildRenderObjectWidget.

Viewport is the visual workhorse of the scrolling machinery. Its inner size is more than its out size, like a black hole in the space of Flutter. Viewport is used in all scrollable widgets, whether it’s a ListView, PageView, or CustomScrollView, you’ll find a Viewport combined with a Scrollable in all of them.

Since Viewport is a container for scrolling, it should have two directions:

axisDirection — the direction in which offset is increased. Based on this we decide which edge is leading and trailing for the viewport.

crossAxisDirection — The direction in which a child should be laid out perpendicular to the axisDirection.

If we have two axes we have a point of their crossing. This point is called an anchor.

Where is it? Regarding the left or right side — it depends on crossAxisDirection. And the position by the main axis can be set. The whole viewport size by the main axis is a segment from 0 to 1 increasing by the axisDirection.

The anchor value is zero by default. And it means that in the very common case when the axisDirection is down and the crossAxisDirection is right, the anchor is the top left corner.

But we can set anchor 0.5 for example and we move the anchor to the middle of the main axis.

But why do we need it? Because the anchor is much more than just a crossing point. This is a point of alignment of the center sliver, which is a rather important one.

This is the sliver, relative to which other slivers grow. All slivers after the center one, grow in the forward direction, and those before the center one grow in the reverse direction. The default center sliver is the very first.

But we can change it by setting up a key for the center sliver.

How can we use it? When the offset is 0, the center sliver is positioned right at the anchor. And all the following calculations will be performed in relation to this sliver.

One more crucial thing which we must know about viewport is the cache. It is necessary to make the scrolling smooth. Thanks to the cache area the content can be prepared before it should appear. But it is not enough to only care about one direction because the content can appear from the opposite as well. So we must not destroy the content as soon as it leaves the visible part. This means we need two cache areas — one before the trailing edge and one after the leading edge.

The default size of them is 250. So the full size handled by the viewport is the cache size before the trailing edge + the main axis viewport size + the cache size after the leading edge.

Let’s put it together

We have looked at Viewport and Scrollable separately from each other, now let’s put it together.

We’ll describe a common situation when the main axis is vertical and axisDirection is down. In the same manner, you can scale this to all other variants easily. For clarity, all elements have a number from 0 to 7.

There are two different variants on the left and right as you can see. And their difference is in the growthDirection property. If every next element (with the bigger number) appears in the same direction as axisDirection, the growthDirection is forward, otherwise reverse.

We remember that Scrollable works with user interaction. This interaction is described by the userScrollDirection property. It can have a few values. When the user doesn’t touch a screen it is ScrollDirection.idle. When the user touches the screen but doesn’t move a finger it is also is ScrollDirection.idle. When a user starts to make a scroll the userScrollDirection can be ScrollDirection.forward or ScrollDirection.reverse. It depends on the direction of the movement the user makes. If the movement makes the next element appear, this is ScrollDirection.forward. If it’s the previous one, this is ScrollDirection.reverse.

Content

And we have come to the last topic — the content which we scroll that is slivers. As we already know, they are common widgets but with a little difference — more detailed layout building and rendering.

To make following the topic easier I have prepared a little application for you. It will help you to comfortably discover this part with me and maybe have experiments in the future.

SliverConstraints

We will start with the SliverConstraints. It has a lot of properties that we have already become familiar with while figuring out the viewport.

axis

The axis along which the scrollOffset and remainingPaintExtent are measured.

axisDirection

The direction in which the scrollOffset and remainingPaintExtent increase.

growthDirection

The direction in which the contents of slivers are ordered, relative to the axisDirection.

crossAxisDirection

The direction in which children should be placed on the cross-axis.

isNormalized

Whether the constraint is expressed in a consistent manner. The consistent means the axisDirection is an AxisDirection.down or AxisDirection.right.

normalizedGrowthDirection

Returns consistent constraints.

isTight

Whether constraints are tight. The tight constraints are the constraints when exactly one size is possible given these constraints.

viewportMainAxisExtent

The number of pixels the viewport can display on the main axis.

crossAxisExtent

The number of pixels in the cross-axis.

userScrollDirection

The direction in which the user is attempting to scroll.

scrollOffset

This is an important property of sliver protocol. It describes the offset of the earliest visible part of the sliver relative visible part of the viewport.

Before crossing the edge of the viewport the value is 0.

The default example can be a little confusing because the viewport edge is in the same place as the anchor. It is important to note that point of calculation is the edge of the viewport, not the anchor. If we move the anchor to the middle, crossing it will not make the value different from 0.

As soon the sliver crosses the edge the scrollOffset starts growing.

And of course, if the axis direction changes, the leading edge also changes, so the scrollOffset calculates relative to another edge.

precedingScrollExtent

The scroll distance that has been consumed by all RenderSlivers that came before this RenderSliver.

It looks pretty simple but has a tricky edge case.

Often slivers create their content lazily (for example SliverList). In this case, when the whole internal content of this sliver is much more than the viewport size, additional content will be created only if needed, when layout occurs. This means that this Sliver doesn’t have enough information to estimate its total extent. So precedingScrollExtent will be infinite for all slivers after this sliver. But it will be so before all content of this lazy sliver will be created. Then precedingScrollExtent will stop being infinite.

remainingPaintExtent

The number of pixels of content that the sliver should consider providing. Providing more pixels than this is inefficient.

But it is just considering, the actual number of pixels is specified in the SliverGeometry.

While the sliver is in the visible part of the viewport, this param is a distance from the leading edge of the sliver to the trailing edge of the viewport.

And when the leading edge of the sliver will leave the visible part of the viewport, this property will be equal to viewportMainAxisExtent.

When a sliver is before the visible part this value is 0 also.

This value can be infinite when the viewport is unconstrained e.g. RenderShrinkWrappingViewport.

remainingCacheExtent

This one is identical to the previous one, except the borders for the measuring are not visible part edges but the cache area.

cacheOrigin

Where the cache area starts relative to the scrollOffset.

The cacheOrigin is always negative or zero and will never exceed the cacheExtent absolute value.

overlap

Size between the first painted pixels of the sliver and the first not covered by previous sliver pixels. It uses for pinned or floating effects, e. g. SliverAppBar.

Let’s explore how the overlap changes in various positions on a simple case with a floating app bar (blue one in the picture).

This value can be negative, for example with the BouncingScrollPhysics.

SliverGeometry

We have talked about constraints, but using them we should decide which is the size, and the size is the SliverGeometry.

SliverGeometry describes the amount of space occupied by a RenderSliver as the documentation says to us. And it has a lot of properties.

Let’s talk through them.

visible

Whether this sliver should be painted.

It’s pretty simple — if we set false, Flutter will not paint this sliver whenever it is.

scrollExtent

The estimated total scrollable extent that this sliver has content for. A simple way to say it: this is the amount of scrolling the user needs to do to get from the beginning of this sliver to the end of this sliver.

This property is used to calculate an offset for all other slivers, for this reason, we should set this property every time even this sliver is out of the vision.

In common cases, scrollExtent is permanent for the sliver all the time, but paintExtent or layoutExtent will change from 0 to scrollExtent while the sliver changes the position.

paintExtent

The amount of currently visible visual space that was taken by the sliver to render. It can take all SliverConstraints.remainingPaintExtent or its part. It means that the value of paintExtent must be between 0 and remainingPaintExtent.

This value does not affect how the next sliver is positioned. For example, if the paintExtent is 100 and layoutExtent is 0, for common slivers after this sliver it means they will be painted over in these 100 pixels. Based on this fact, it is easy to get that paintExtent affects the calculation of the SliverConstraints.overlap for the next sliver.

layoutExtent

The distance from the first visible part of this sliver to the first visible part of the next sliver, assuming the next sliver’s SliverConstraints.scrollOffset is zero.

This must be between zero and paintExtent and usually by default it’s paintExtent.

cacheExtent

How many pixels the sliver has consumed in the SliverConstraints.remainingCacheExtent.

This value should be equal to or larger than the layoutExtent because the sliver always consumes at least the layoutExtent from the SliverConstraints.remainingCacheExtent and possibly more if it falls into the cache area of the viewport.

maxPaintExtent

The estimated total paint extent that this sliver would be able to provide if the SliverConstraints.remainingPaintExtent was infinite.

This is used by viewports that implement shrink-wrapping.

By definition, this cannot be less than paintExtent.

paintOrigin

The visual location of the first visible part of this sliver relative to its layout position.

The default value is 0, which means slivers start painting at their layout position by default.

If we set this negative, the sliver starts to paint earlier and be painted under the previous one. If we set this positive, the sliver starts to paint later and be painted over the next one.

It does not affect the position of other slivers, but affects the SliverConstraints.overlap for the next.

hitTestExtent

The distance from where this sliver started painting to the bottom of where it should accept the user’s interaction.

This must be valued between zero and paintExtent. And by default, it is paintExtent because we expect that all visible parts are usually interactive

maxScrollObstructionExtent

The maximum extent by which this sliver can reduce the area in which content can scroll if the sliver were pinned at the edge, for example, SliverAppBar with pinned = true.

Slivers that never get pinned at the edge, should return zero.

scrollOffsetCorrection

The value for correction of the scroll by a parent. If this is non-zero after RenderSliver.performLayout returns, the scroll offset will be adjusted by the parent and then the entire layout of the parent will be rerun.

If the parent is also a RenderSliver, it must propagate this value in its own RenderSliver.geometry property until the viewport, which will adjust offset based on this value.

For example, it can be used for Cupertino refresh, which takes some size, but while it’s disappearing the size is changing. And this change of course affects the following slivers. ScrollOffsetCorrection is used to fix it.

hasVisualOverflow

Whether this sliver has a visual overflow.

By default, this is false, which means the viewport does not need to clip its children. The viewport will apply a clip to its children if any slivers have a visual overflow.

Conclusion

We’ve explored all that we need to know about Slivers. Hope this knowledge will help you to do the most exciting implementations of scroll behavior. If you like this topic, I advise you also check my next article, where I’ll put all this knowledge into practice to get custom behavior for leaving the screen.

Stay on the 🎯 Dart side of the Force ✌️

--

--