Educational program on nested scrolling in Jetpack Compose

Most Android apps are based on lists. Over the years, many different solutions have emerged that implement how other UI components interact with lists—for example, how an app bar responds to scrolling a list, or how nested lists interact with each other. Have you ever encountered a situation where one list is inside another, and when you scroll the inner list to the end, you want the outer list to continue moving? This is a classic example of nested scrolling!

Nested scrolling is a system in which scrolling components that reside within each other can communicate their scrolling deltas to each other to work together in a consistent manner. For example, in the View system, nested scrolling is implemented based on NestedScrollingParent And NestedScrollingChild. These designs are used by components such as NestedScrollView And RecyclerView, to implement different nested scrolling options. Nested scrolling is a key feature in many UI frameworks. In this article we'll look at how Jetpack Compose handles it.

Let's look at an example where we could use a nested scrolling system. For this example, we will create a collapsing app bar effect in our application at the top of the application. The collapsible panel will interact with the list to create a collapsing effect – at any time while the panel is expanded, scrolling up the list will cause it to collapse. Likewise, if a panel is collapsed, scrolling down the list will expand it. Here's an example of what it should look like:

Let's assume that our application consists of a top bar and a list (this design is typical for many simple applications):

Note: Similar behavior can be achieved using the parameter scrollBehavior TopAppBar in Material 3, but we'll write parts of this logic ourselves to illustrate how the nested scrolling system works.

val AppBarHeight = 56.dp
val Purple40 = Color(0xFF6650a4)

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleInitialCode.kt on GitHub

This code will display the following:

By default, there is no connection between the top bar and the list. If we scroll the list, the panel remains static. One solution would be to make the panel part of the list itself, but as we'll see shortly, that doesn't work. Having scrolled down the list, we will have to scroll back to the very top to see the panel again:

When faced with this problem, we realized that we would like to maintain the hierarchy for the top bar (outside the list). However, we also want to respond to scroll changes in the list – that is, make the component respond to scrolling the list. This directly suggests that Compose's nested scrolling system may be a solution to this problem.

A nested scrolling system is a good solution if you need coordination between components when one or more of them are scrollable and they are hierarchically related (in the above case, the top bar and list share a common parent). This system links scrolling containers and gives us the ability to interact with scroll deltas that are propagated/passed between them.

Nested scroll loop

Let's go back a little and discuss what the overall design of a nested scrolling system looks like. A nested scroll loop is a stream of scroll deltas (changes) that are passed up and down a hierarchical tree through all components that may be part of a nested scroll system.

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

  • At the stage pre-scroll the component that receives the touch deltas will send those events to the highest parent in the hierarchy tree. The delta events will then cascade down, meaning deltas will propagate from the root-most parent down to the child component that initiated the nested scroll loop. This gives the parent a nested scroll all along that path (composable using the modifier nestedScroll) the ability to “do something” with the delta before the node itself can consume it.

If we return to our chart, a child element (such as a list) scrolled by 10 pixels will trigger a nested scroll process. As a child component, it will send 10 pixels up the chain to the root parent itself, where during the pre-scroll phase the parents will be given the opportunity to use those 10 pixels themselves:

Any parent on the way down to the child node that initiated the process has the opportunity to consume a portion of the 10 pixels, and the rest will be passed down the chain. When it reaches the child component, we will enter the consumption phase of the node. In this example, parent 1 has decided to consume 5 pixels, so there will be 5 pixels left for the next phase.

  • On phase node consumption already the node itself uses a delta that was not used by its parents. It is at this point, for example, that the list will really start to move.

During this phase, the child component can do all or part of the remaining scrolling. Anything left over will be sent back to the top to go through the post-roll phase. The child component in our diagram only used 2 pixels to move, leaving 3 pixels for the next phase.

  • Finally, at the stage post-scroll everything that was not consumed by the node itself will be sent back to its parents in case someone wants to use the rest of the delta.

The post-swipe phase will work the same as the pre-swipe phase, where either parent can commit consumption.

During this phase, parent 2 consumes the remaining 3 pixels and passes the remaining 0 pixels down the chain.

Similarly, upon completion of a drag gesture, the user's intent can be converted into speed, which will be used to fling the list – that is, scroll through it using a fling animation. Swiping is also part of a nested scroll loop, and the speeds generated by a drag event will go through similar phases: pre-swipe (pre-fling), node consumption and post-swipe (post-fling).

Okay, but how does this relate to our original problem? Compose provides a set of tools with which we can influence the operation of these phases and interact directly with them. In our case, if the top panel is currently displayed and we are scrolling up the list, we would like to set the priority to scrolling the panel. On the other hand, if we are scrolling down a list and the panel is not currently displayed, we also want to prioritize scrolling to the panel before scrolling the list itself. This is another hint that a nested scrolling system might be a good solution: our use case forces us to do something with the scroll deltas before the list is scrolled (just like the pre-scroll phase above).

Let's look at the tools available to us.

Nested Scroll Modifier

If we think of a nested scroll loop as a system that operates on a chain of nodes, then the nested scroll modifier is our way of wedging into that chain and influencing the data (scroll deltas) that propagate along it. This modifier can be placed anywhere in the hierarchy and will interact with instances of nested scroll modifiers higher up the tree, allowing information to be exchanged over that channel. To interact with information transmitted over this channel, you can use NestedScrollConnection, which will cause certain callbacks depending on the consumption phase. Let's take a closer look at the components of this modifier:

  • NestedScrollConnection: A join is a way to respond to the phases of a nested scroll loop. This is the main way nested scrolling can be affected. It consists of 4 callback methods, each of which represents one of the phases: pre/post-scroll and pre/post-fling. Each callback also provides information about the delta being propagated:

1. available: Delta available for this phase.

2. consumed: Delta consumed in previous phases. For example, onPostScroll has a “consumed” argument, which indicates how much was consumed during the node's consumption phase. We can use this value, for example, to find out how far the original list has been scrolled, since this function will be called after node consumption phases.

3. nested scroll source: Where did this delta come from – Drag (if it’s a gesture) or Fling (if it’s a fling animation).

Using the values ​​returned in the callback, we tell the system how to behave. We'll talk more about this a little later.

  • NestedScrollDispatcher: A dispatcher is an entity that starts a nested scroll loop, meaning using a dispatcher and calling its methods essentially starts a loop. For example, the scrolling container has a built-in dispatcher that takes care of sending deltas captured during gestures to the system. Therefore, in most cases, a connection will be used instead of a dispatcher, since we react on already existing deltas, and not send new.

Now let's think about what we know about the order of delta propagation in a nested scrolling system and try to apply that information to our case to see how we can implement the collapsing behavior we want for the top bar. We learned earlier that once the scroll event fires, before the list itself can move, we will be given the opportunity to decide on the positioning of the top bar. This suggests that we need to do something during onPreScroll. Remember onPreScroll is the phase that occurs immediately before scrolling the list (phase NodeConsumption).

Our source code is a combination of two components: one for the top bar and another for the list wrapped in Box:

val AppBarHeight = 56.dp
val Purple40 = Color(0xFF6650a4)

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleInitialCode.kt on GitHub

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

val appBarOffset by remember { mutableIntStateOf(0) }

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   Box {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           // Для фактического перемещения верхней панели приложения используйте appBarOffset.
           modifier = Modifier.offset { IntOffset(0, appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleWithAppBarOffset.kt on GitHub

Now we need to update the offset based on the scrolling of the list. We'll set up the nested scroll connection at a point in the hierarchy where it can intercept deltas coming from the list; at the same time it must be able to change the offset of the top panel. The appropriate place is their common parent – the parent is well positioned hierarchically to 1) receive deltas from one component and 2) influence the position of the other component. We will use the connection to influence the phase onPreScroll:

val appBarOffset by remember { mutableIntStateOf(0) }

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   val connection = remember {
       object : NestedScrollConnection {
           override fun onPreScroll(
               available: Offset,
               source: NestedScrollSource
           ): Offset {
               return super.onPreScroll(available, source)
           }
       }
   }

   Box(Modifier.nestedScroll(connection)) {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           modifier = Modifier.offset { IntOffset(0, appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleWithConnection.kt on GitHub

We will get the delta from the list in the parameter available callback onPreScroll. The return of this callback should be what we used from available. This means that if we return Offset.Zero, then we didn't use anything, and the list will be able to use all of this for its scrolling. If we return availablethere will be nothing left in the list and it will not scroll.

In our case, if the value appBarOffset is in the range from 0 to the maximum panel height, we will need to give the panel delta (add it to the offset). We can do this with a calculation using coerceIn (it limits the values ​​between the minimum and maximum). After this we will need to tell the system what was consumed when the top bar was moved. As a result, our implementation onPreScroll will look like this:

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
       val delta = available.y.toInt()
       val newOffset = appBarOffset + delta
       val previousOffset = appBarOffset
       appBarOffset = newOffset.coerceIn(-appBarMaxHeight, 0)
       val consumed = appBarOffset - previousOffset
       return Offset(0f, consumed.toFloat())
   }

ComposeNestedScrollSampleOnPreScrollHighlight.kt on GitHub

Let's refactor our code a bit and abstract state offset and join into a single class:

private class CollapsingAppBarNestedScrollConnection(
    val appBarMaxHeight: Int
) : NestedScrollConnection {

   var appBarOffset: Int by mutableIntStateOf(0)
       private set

   override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
       val delta = available.y.toInt()
       val newOffset = appBarOffset + delta
       val previousOffset = appBarOffset
       appBarOffset = newOffset.coerceIn(-appBarMaxHeight, 0)
       val consumed = appBarOffset - previousOffset
       return Offset(0f, consumed.toFloat())
   }
}

ComposeNestedScrollSampleEncapsulatedConnection.kt on GitHub

And now we can use this class to offset our appBar:

Surface(
   modifier = Modifier.fillMaxSize(),
   color = MaterialTheme.colorScheme.background
) {
   val appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
   val connection = remember(appBarMaxHeightPx) {
       CollapsingAppBarNestedScrollConnection(appBarMaxHeightPx)
   }

   Box(Modifier.nestedScroll(connection)) {
       LazyColumn(contentPadding = PaddingValues(top = AppBarHeight)) {
           items(Contents) {
               ListItem(item = it)
           }
       }
       
       TopAppBar(
           modifier = Modifier.offset { IntOffset(0, connection.appBarOffset) },
           title = { Text(text = "Jetpack Compose") },
           colors = TopAppBarDefaults.topAppBarColors(
               containerColor = Purple40,
               titleContentColor = Color.White
           )
       )
   }
}

ComposeNestedScrollSampleUsingConnectionClass.kt on GitHub

Now the list will remain static until the top panel is completely collapsed because the panel offset consumes all the delta and the list has nothing left to use.

But this is not exactly what we need. To fix this we need to use appBarOffset to update the space in front of the list so that when the top panel is completely collapsed, the height of the elements is reset to zero. After this, the top bar will no longer consume anything and the list will be able to scroll freely.

This logic also applies to sliding out the top panel. While the panel slides out, the list remains static, but our invisible element grows, so it creates the illusion that the list is moving. When the top bar is fully expanded, it will no longer consume deltas and the list will be able to continue scrolling.

Surface(
    modifier = Modifier.fillMaxSize(),
    color = MaterialTheme.colorScheme.background
) {
    val appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
    val connection = remember(appBarMaxHeightPx) {
        CollapsingAppBarNestedScrollConnection(appBarMaxHeightPx)
    }
    val density = LocalDensity.current
    val spaceHeight by remember(density) {
        derivedStateOf {
            with(density) {
                (appBarMaxHeightPx + connection.appBarOffset).toDp()
            }
        }
    }

    Box(Modifier.nestedScroll(connection)) {
        Column {
            Spacer(
                Modifier
                    .padding(4.dp)
                    .height(spaceHeight)
            )
            LazyColumn {
                items(Contents) {
                    ListItem(item = it)
                }
            }
        }

        TopAppBar(
            modifier = Modifier.offset { IntOffset(0, connection.appBarOffset) },
            title = { Text(text = "Jetpack Compose") },
            colors = TopAppBarDefaults.topAppBarColors(
                containerColor = Purple40,
                titleContentColor = Color.White
            )
        )
    }
}

ComposeNestedScrollSampleCompleteCode.kt on GitHub

As a result, the top panel will collapse/expand before scrolling the list, as we wanted.

Let's summarize:

  • We can use the nested scrolling system as a way to allow components in different places in the Compose hierarchy to interact with scrollable components.

  • We can use NestedScrollConnectionto allow changes to propagated deltas within a nested scroll loop.

  • We need to override methods onPreScroll/onPostScroll to change the scroll delta and onPreFling/onPostFling to change the swipe speed.

  • Always remember to return whatever was consumed in each of the overridden methods so that the nested scroll loop can continue propagating.

If you want to know more about the scrolling system, take a look at official documentationwhich goes into more detail about the APIs used here and how you can interact with the View's nested scrolling system.

Code snippets license: Copyright 2024 Google LLC.
SPDX-License-Identifier: Apache-2.0

The article was prepared in anticipation of the start of the online course “Android Developer. Basic”.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *