The Full Nested Scrolling Guide for Jetpack Compose

Exploring Google AI for Samsung Galaxy and the Jetpack Compose Update

Most Android apps are primarily composed of lists. Different approaches have been taken over time to guarantee that other UI elements can communicate with these lists, such as the way an app bar responds to list scrolls or the way nested lists communicate with one another. Have you ever found yourself with one list inside another and wanting the outer list to keep moving while you scrolled through the inner list to the end? That demonstrates nested scrolling in a classic way!

 

A system known as nested scrolling allows scrolling components that are inside of one another to communicate their scrolling deltas in order to function as a unit. NestedScrollingParent and NestedScrollingChild, for example, are the building elements for nested scrolling in the View system. Many of the nested scrolling use cases are made possible by components like NestedScrollView and RecyclerView, which rely on these features. Many UI frameworks have nested scrolling as a fundamental feature; in this blog post, we’ll examine how Jetpack Compose handles it.

 

Let’s examine a scenario in which the stacked scroll mechanism may be useful. We’ll design a unique collapsing app bar effect for our app in this example. The list and the collapsing app bar work together to provide the collapsing effect; if the app bar is expanded at any time, scrolling up will cause the list to collapse. Similarly, scrolling down will cause the list to increase if the app bar is collapsed. Here’s an illustration of how it ought to appear:

Since this may be used in many apps, let’s imagine that our app consists of a list and an app bar.

 

Note: To demonstrate how the stacked scrolling system functions, we’re rewriting some of the logic that can be achieved by utilizing Material 3’s TopAppBar scrollBehavior option.

 

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
          )
      )
  }
}

This code renders the following:

Our app bar and the list don’t communicate with each other by default. The app bar remains static while we go through the list. Making the app bar a part of the list itself is one option, however it quickly becomes apparent that this wouldn’t be feasible. To view the app bar, we would have to scroll all the way up after scrolling down the list:

 

After investigating this matter, we have concluded that we would want to maintain the app bar’s hierarchical position (outside of the list). On the other hand, we also want a component to respond when the list scrolls, or to changes in the list’s scroll. This suggests that Compose’s nested scrolling approach would be a suitable fix for this issue.

 

When one or more components are scrollable and connected hierarchically (like in the example above, where the app bar and the list have the same parent), the nested scrolling system is a useful way to achieve coordination between the components. We may interact with the scrolling deltas that are being exchanged and propagated among scrolling containers thanks to this mechanism that connects them.

 

Presenting: The nested scroll cycle

Let’s go back a little and talk about the overall operation of nested scrolling. The flow of scroll deltas, or modifications, that are sent up and down the hierarchy tree through each component that can be a part of the nested scrolling system is known as the nested scroll cycle.

Consider a list as an illustration. The deltas will be communicated to the nested scroll system as soon as a gesture event is recognized, even before the list can scroll. Three stages will be experienced by the deltas produced by the event: pre-scroll, node consumption, and post-scroll.

 

  • The component that got the touch deltas will send those events to the top parent in the hierarchy tree during the pre-scroll phase. After then, delta events will “bubble down,” which means that deltas will move from the child who initiated the nested scroll cycle to the root-most parent. As a result, before the node itself can use the delta, the nested scroll parents along this path—composables that employ the nestedScroll modifier—have the chance to “do something” with it.|

Referring back to our diagram, the nested scrolling will begin when a child (a list, for example) scrolls 10 pixels. During the pre-scroll phase, the parents will have the opportunity to consume the 10 pixels once the child sends them up the chain to the root-most parent:

Any parent can opt to use some of the 10 pixels as they descend toward the kid who initiated the process; the remaining pixels will continue to propagate down the chain. We will enter the node consumption phase once it reaches the child. Since parent 1 in this instance decided to use 5 pixels, there will be 5 pixels available for use in the following stage.

  • The node itself will use any delta that its parents did not use during the node consumption phase. At this point, for example, a list will start to move.

 

The child has the option to eat all or some of the leftover scroll during this stage. Anything that remains will be sent back up for the post-scroll stage. In our diagram, the child only utilized two pixels for movement, saving three pixels for the following stage.

  • Anything that the node itself did not consume will finally be passed up to its ancestors in the post-scroll phase in case anyone would choose to consume it.

Like the pre-scroll phase, where any parent may choose to consume, the post-scroll phase will operate similarly.

Parent 2 used the last 3 pixels during this phase and reported the final 0 pixels to the chain.

Likewise, upon completion of a drag motion, the user’s intent can be converted into a velocity that will be employed to “fling” the list – an animation will cause it to scroll. The fling is a component of the nested scroll cycle, and the pre-fling, node consumption, and post-fling phases of the velocities produced by the drag event are comparable.

All right, but what connection does this have to our original issue? Well, Compose gives us a set of tools to work with these stages directly and to modify their behavior. In our situation, we would prefer to scroll the app bar first if it is currently visible and we scroll the list upward. However, we would also like to favor scrolling the app bar over scrolling the list itself if we scroll down and the app bar is not visible. Here’s another indication that the stacked scrolling method might work well: Our use case motivates us to take action with the scroll deltas prior to a list scrolling (refer to the link pertaining to the pre-scroll phase).

 

Let’s have a look at these tools next.

 

The nested scroll modifier

The nested scroll modifier is our means of interjecting ourselves into modifications and affecting the data (scroll deltas) that are propagated in this chain, if we consider the nested scroll cycle to be a system operating on a chain of nodes. This modifier can be positioned anywhere in the hierarchy and uses this channel to exchange data with instances of nested scroll modifiers higher up the tree. Depending on the phase of consumption, a NestedScrollConnection can be used to interact with the data sent through this channel by invoking different callbacks. Let’s examine this modifier’s fundamental components in more detail:

 

  • NestedScrollConnection: A connection allows you to react to the different stages of the cycle of layered scrolling. The nested scroll system can be influenced primarily in this way. It consists of four callback methods, pre/post-fling and pre/post-scroll, each of which stands for a different phase. Additionally, each callback provides details about the delta that is being spread:
  1. available: The phase-specific available delta.
  2. consumed: The amount of delta used up in earlier stages. For example, the “consumed” input in onPostScroll specifies the amount consumed during the node consumption phase. Since this will be called after the node consumption phase, we might use it to find out, for example, how far the original list has scrolled.
  3. nested scroll source: If the delta came from a fling animation, it came from Fling; otherwise, it came from a gesture.

 

We’ll instruct the system on how to behave based on the values that are returned in the callback. We’ll investigate this further later.

  • NestedScrollDispatcher: The object that starts the nested scroll cycle is a dispatcher; in other words, using a dispatcher and invoking its methods will basically start the cycle. A scrollable container, for example, comes with an integrated dispatcher that handles transferring gesture-captured deltas into the system. Since we’re responding to pre-existing deltas rather than generating new ones, the majority of use cases will require utilizing a connection rather than a dispatcher.

 

In order to determine how to implement the proper collapsing behavior for the app bar, let’s now consider what we know about the propagation order of deltas in the nested scroll system and attempt to apply that information to our use case. As we previously taught, we will be able to choose where the app bar should be positioned even before the list may move itself when a scroll event is triggered. This suggests that there is something we should do when onPreScroll. Recall that the onPreScroll phase (also known as the NodeConsumption phase) occurs just prior to the list scrolling.

 

Two composables—one for the app bar and another for the list encircled by a Box—combine to form our original code:

 

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
          )
      )
  }
}

 

Our app bar’s height is fixed, and we can show or conceal it by just adjusting its position. Let’s construct a state variable to store this offset’s value:

 

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(
          // use the appBarOffset to actually move the top app bar.
          modifier = Modifier.offset { IntOffset(0, appBarOffset) },
          title = { Text(text = “Jetpack Compose”) },
          colors = TopAppBarDefaults.topAppBarColors(
              containerColor = Purple40,
              titleContentColor = Color.White
          )
      )
  }
}

We must now adjust the offset in accordance with the list’s scrolling. A nested scroll connection will be installed at a hierarchy point that will allow it to record deltas from the list and adjust the app bar offset simultaneously. The common parent of the two is a good place to be because it is positioned hierarchically to 1) receive deltas from one component and 2) affect the other component’s position. The connection will be used to affect the onPreScroll stage:

 

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
          )
      )
  }
}

We will obtain the delta from the list in the available argument of the onPreScroll callback. Whatever we utilize from the available should be the callback’s return value. This indicates that we didn’t utilize any resources if we return Offset.Zero, and the list can use all of it for scrolling. The list won’t scroll if we return available because there won’t be anything on it.

 

We must provide the app bar the delta (add it to the offset) in our use case if our appBarOffset is anywhere between 0 and the app bar’s maximum height. With a computation employing coerceIn, we may accomplish that (this confines the values between a minimum and a maximum). Following that, we must inform the system about the amount of data used by the app bar offsetting. Ultimately, this is how our onPreScroll implementation appears:

 

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())
  }

Let’s simplify our code a little by combining the connection and the state offset 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())
  }
}

At this point, we can offset our appBar using that class:

 

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
          )
      )
  }
}

Since the app bar offset is using up the entire delta and leaving nothing for the list to use, the list will now stay static until the app bar is fully collapsed.

 

We don’t precisely want this. The item height will be reset when the app bar is fully compressed, therefore we’ll need to utilize the appBarOffset to update the space area before our list as well. After that, nothing more will fit in the app bar, allowing the list to scroll without restriction.

 

We don’t precisely want this. The item height will be reset when the app bar is fully compressed, therefore we’ll need to utilize the appBarOffset to update the space area before our list as well. After that, nothing more will fit in the app bar, allowing the list to scroll without restriction.

 

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
          )
      )
  }
}

The app bar will eventually shrink or enlarge before the list scrolls as intended.

 

Table of Contents

Recent Comments
    May 2025
    M T W T F S S
     1234
    567891011
    12131415161718
    19202122232425
    262728293031