Understanding Nested Scrolling in Jetpack Compose | by Levi Albuquerque | Android Developers | Feb, 2024

Levi Albuquerque

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.

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.

  • In the pre-scroll phase, the component that received the touch deltas will dispatch those events through the hierarchy tree to the topmost parent. Then delta events will bubble down, meaning that deltas will be propagated from the root-most parent down towards the child that started the nested scroll cycle. This gives the nested scroll parents along this path (composables that use the nestedScroll modifier) the opportunity to “do something” with the delta before the node itself can consume it.

If we go back to our diagram, a child (a list, for instance) scrolling 10 pixels will kickstart the nested scrolling process. The child will dispatch the 10 pixels up the chain to the root-most parent where, during the pre-scroll phase, the parents will be given the chance to consume the 10 pixels:

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.

  • In the node consumption phase, the node itself will use whatever delta was not used by its parents. This is the moment that, for instance, a list will actually move.

During this phase, the child may choose to consume all or part of the remaining scroll. Anything left will be sent back up to go through the post-scroll phase. The child in our diagram used only 2 pixels to move, leaving 3 pixels to the next phase.

  • Finally, in the post-scroll phase, anything that the node itself didn’t consume will be sent up again to its ancestors in case anyone would like to consume it.

The post-scroll phase will work in a similar fashion as the pre-scroll phase, where any of the parents may choose to consume.

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.

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:

  • NestedScrollConnection: A connection is a way to respond to the phases of the nested scroll cycle. This is the main way you can influence the nested scroll system. It’s composed of 4 callback methods, each representing one of the phases: pre/post-scroll and pre/post-fling. Each callback also gives information about the delta being propagated:

1. available: The available delta for that particular phase.

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.

  • NestedScrollDispatcher: A dispatcher is the entity that originates the nested scroll cycle — that is, using a dispatcher and calling its methods will essentially trigger the cycle. For instance, a scrollable container has a built-in dispatcher that takes care of sending deltas captured during gestures into the system. For this reason, most of the use cases will involve using a connection instead of a dispatcher since we’re reacting to already existing deltas rather than sending new ones.

Now, let’s think about what we know regarding the propagation order of deltas in the nested scroll system and try to apply that information to our use case to see how we could implement the correct collapsing behavior for the app bar. Previously we learned that, after a scroll event is triggered, even before the list itself can move, we will be given the chance to make a decision about the app bar positioning. This hints that we need to do something during onPreScroll. Remember, onPreScroll is the phase that happens right before the list scrolls (NodeConsumption phase).

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:

  • We can use the nested scrolling system as a way of allowing components in different places in the Compose hierarchy to interact with scrolling components.
  • We can use a NestedScrollConnection to allow changes to the propagated deltas within the nested scrolling cycle.
  • We should override onPreScroll/onPostScroll methods to change scrolling deltas and onPreFling/onPostFling to change fling velocities.
  • Always remember to return whatever was consumed in each of the overridden methods so the nested scrolling cycle can continue the propagation.

If you’d like to learn more about the scrolling system, check out the official documentation where there’s a more technical discussion about the APIs used here and how you can interop with View’s nested scrolling system.

Code snippets license: Copyright 2024 Google LLC.

SPDX-License-Identifier: Apache-2.0

Next Post

AI-powered martech releases: Feb 22

One of my problems with AI isn’t the hallucinations, but how boring those hallucinations are. I want Hunter-Thompson-on-illicit-substances visions and what I get are mumbles from an incompetent, prejudiced CPA with too little sleep. I am happy to announce that ChatGPT and Google took big steps towards meeting my needs […]
AI-powered martech releases: Feb 22

You May Like