Understanding Nested Scrolling in Jetpack Compose

Levi Albuquerque on 2024-02-07

Understanding Nested Scrolling in Jetpack Compose

Lists are at the core of most Android apps. Over the years, different solutions were introduced to ensure other UI components could interact with such lists — for instance, how an app bar reacts to list scrolls or how nested lists interact with one another. Have you ever encountered a situation where you have one list inside another and, by scrolling the inner list to the end, you’d like the outer list to continue the movement? That’s a classic nested scrolling example!

Nested scrolling is a system where scrolling components contained within each other can communicate their scrolling deltas to make them work together. For instance, in the View system, NestedScrollingParent and NestedScrollingChild are the building blocks for nested scrolling. These constructs are used by components such as NestedScrollView and RecyclerView to enable many of the nested scrolling use cases. Nested scrolling is a key feature in many UI frameworks, and in this blog post we’ll take a look at how Jetpack Compose handles it.

Let’s have a look at a use case where the nested scroll system can be helpful. In this example, we’ll create a custom collapsing app bar effect in our app. The collapsing app bar will interact with the list to create the collapsing effect — at any point, if the app bar is expanded, scrolling the list up will cause it to collapse. Similarly, if the app bar is collapsed, scrolling the list down will make it expand. Here’s an example of what it should look like:

Let’s assume our app is made up of an app bar and a list, which is applicable to a lot of apps.

Note: You can achieve similar behavior by using Material 3’s TopAppBar scrollBehavior parameter, but we’re rewriting some of that logic to illustrate how the nested scrolling system works.

This code renders the following:

By default, there is no communication between our app bar and the list. If we scroll the list, the app bar is static. One alternative would be to make the app bar be part of the list itself, but we soon see that wouldn’t work. Once we scrolled the list down, we would need to scroll all the way up again to see the app bar:

By looking into this issue, we see that we’d like to keep the hierarchical place of the app bar (outside of the list). However, we also want to react to changes in scroll in the list — that is, to make a component react to list scrolls. This is a hint that the nested scrolling system in Compose may be a good solution for this problem.

The nested scrolling system is a good solution if you want coordination between components when one or more of them is scrollable and they’re hierarchically linked (in the case above, the app bar and the list share the same parent). This system links scrolling containers and gives an opportunity for us to interact with the scrolling deltas that are being propagated/shared amongst them.

Presenting: The nested scroll cycle

Let’s go back a bit and discuss how nested scrolling works in general. The nested scroll cycle is the flow of scroll deltas (changes) that are dispatched up and down the hierarchy tree through all components that can be part of the nested scrolling system.

Let’s take a list as an example. When a gesture event is detected, even before the list itself can scroll, the deltas will be sent to the nested scroll system. The deltas generated by the event will go through 3 phases: pre-scroll, node consumption, and post-scroll.

On the way down towards the child that started the process, any parent may choose to consume part of the 10 pixels and the rest will be propagated down the chain. When it reaches the child, we will go to the node consumption phase. In this example, parent 1 chose to consume 5 pixels, so there will be 5 pixels left for the next phase.

During this phase, parent 2 consumed the remaining 3 pixels and reported the remaining 0 pixels down the chain.

Similarly, when a drag gesture finishes, the user’s intention may be translated into a velocity that will be used to “fling” the list — that is, make it scroll using an animation. The fling is also part of the nested scroll cycle, and the velocities generated by the drag event will go through similar phases: pre-fling, node consumption, and post-fling.

Okay, but how is this relevant to our initial problem? Well, Compose provides a set of tools that we can use to influence how these phases work and to interact directly with them. In our case, if the app bar is currently showing and we scroll the list up, we’d like to prioritize scrolling the app bar. On the other hand, if we scroll down and the app bar is not showing, we’d like to also prioritize scrolling the app bar before scrolling the list itself. This is another hint that the nested scrolling system may be a good solution: our use case makes us want to do something with the scroll deltas even before a list scrolls (see the link with the pre-scroll phase above).

Let’s have a look at these tools next.

The nested scroll modifier

If we think of the nested scroll cycle as a system acting on a chain of nodes, the nested scroll modifier is our way of inserting ourselves in changes and influencing the data (scroll deltas) that are propagated in this chain. This modifier can be placed anywhere in the hierarchy, and it communicates with nested scroll modifier instances up the tree so it can share information through this channel. To interact with the information that is passed through this channel, you can use a NestedScrollConnection that will invoke certain callbacks depending on the phase of consumption. Let’s take a deeper look at the building blocks of this modifier:

2. consumed: The delta consumed in the previous phases. For instance, onPostScroll has a “consumed” argument, which refers to how much was consumed during the node consumption phase. We may use this value to learn, for instance, how much the originating list has scrolled, since this will be invoked after the node consumption phase.

3. nested scroll source: Where that delta originated — Drag (if it is from a gesture), or Fling (if it is from a fling animation).

The values returned in the callback are the way we’ll tell the system how to behave. We’ll look more into this in a bit.

Our initial code is a combination of two composables, one for the app bar and another for the list wrapped around with a Box:

The height of our app bar is fixed and we can simply offset its position to show/hide it. Let’s create a state variable to hold the value of such offset:

Now, we need to update the offset based on the scrolling of the list. We’ll install a nested scroll connection in a position in the hierarchy where it will be able to capture deltas coming from the list; at the same time, it should be able to change the app bar offset. A good place is the common parent of the two — the parent is well positioned hierarchically to 1) receive deltas from one component and 2) influence the position of the other component. We’ll use the connection to influence the onPreScroll phase:

In the onPreScroll callback we’ll receive the delta from the list in the available parameter. The return of this callback should be whatever we used from available. This means that if we return Offset.Zero, we didn’t consume anything and the list will be able to use it all for scrolling. If we return available, the list won’t have anything left, so it won’t scroll.

For our use case, if our appBarOffset is anything between 0 and the max height of the app bar, we’ll need to give the delta to the app bar (add it to the offset). We can achieve that with a calculation using coerceIn (this limits the values between a minimum and a maximum). After that, we’ll need to report back to the system what was consumed by the app bar offsetting. In the end, our onPreScroll implementation looks like this:

Let’s re-organize our code a little bit and abstract the state offset and the connection into a single class:

And now, we can use that class to offset our appBar:

Now, the list will remain static until the app bar is completely collapsed since the app bar offset is consuming the whole delta and there’s nothing left for the list to use.

This is not exactly what we want. To fix this, we’ll need to use the appBarOffset to also update the space area before our list so when the app bar is fully collapsed, the item height will be reset. After that, the app bar won’t consume anything else, so the list will be able to scroll freely.

This logic also applies to expanding the app bar. While the app bar is expanding, the list is static, but the invisible item is growing so this gives the illusion that the list is moving. Once the app bar is fully expanded, it won’t use any more deltas, and the list will be able to continue scrolling.

In the final result, the app bar will collapse/expand before the list scrolls as expected.

To sum up:

Code snippets license: Copyright 2024 Google LLC.

SPDX-License-Identifier: Apache-2.0