Blog Infos
Author
Published
Topics
, , ,
Published
Introduction

Creating smooth, responsive user experiences in modern UI development often involves complex scrolling interactions. One common requirement is nested scrolling, where a scrollable component is embedded within another. Jetpack Compose, with its declarative approach, provides an elegant way to handle such interactions.

In PART 1 of this article, we covered scroll states and the basics of nested scrolling. Now, in PART 2, we will build a dynamic UI with a collapsing toolbar and learn how to handle nested scrolling effectively.

Mastering Scroll in Jetpack Compose — PART 1

Overview

We aim to create an interface with a lazy list where scrolling causes the top card to transform into a toolbar, with a smooth, curved path transition effect.

What You’ll Learn
  • Creating custom layouts
  • Resizing layouts based on states (collapsed or expanded)
  • Combining lazy list scrolling with screen content
  • Working with nested scrolling and NestedScrollConnection
Step 1: Building the Header

The header has two states: expanded and collapsed. We use dynamic elements with changing heights and widths to achieve a smooth transition between these states.

Expanded state

 

Collapsed State

 

To calculate the height for these containers we will use Custom layout in compose. If you already not know custom layout checkout this.

# Defining the Heights

private val expandedBoxHeight = 200.dp
private val collapsedBoxHeight = 96.dp
private val ExpandedLeoHeight = 80.dp
private val CollapsedLeoHeight = 32.dp
private val leoTextHeight = 16.sp
private val ButtonSize = 24.dp

 

We interpolate between these values as the header transitions between states.

To get the linear sizes ar each state we use lerp function.

val leoHeight = with(LocalDensity.current) {
    lerp(CollapsedLeoHeight.toPx(), ExpandedLeoHeight.toPx(), progress).toDp()
}

 

where progress is changing from 1f to 0f lineraly.

# Create box to set background image.

@Composable
fun CollapsingToolbar(
    @DrawableRes backgroundImageResId: Int,
    progress: Float,
    onPrivacyTipButtonClicked: () -> Unit,
    onSettingsButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    val leoHeight = with(LocalDensity.current) {
        lerp(CollapsedLeoHeight.toPx(), ExpandedLeoHeight.toPx(), progress).toDp()
    }
    val logoPadding = with(LocalDensity.current) {
        lerp(CollapsedPadding.toPx(), ExpandedPadding.toPx(), progress).toDp()
    }
Surface(
    color = MaterialTheme.colors.primary,
    elevation = Elevation,
    modifier = modifier
) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(if (progress == 1f) 200.dp else leoHeight * 3)
    ) {
    //#Background Image
            Image(
                painter = painterResource(id = backgroundImageResId),
                contentDescription = null,
                contentScale = ContentScale.FillWidth,
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer {
                        alpha = progress * Alpha
                    },
                alignment = BiasAlignment(0f, 1f - ((1f - progress) * 0.75f))
            )

      ....... // inside content }
}
}

 

Here background image alignment is changing with progress.

when progress = 1f
(0f , 1f — ((1f — progress) * 0.75f) = (0f , 1f — ((1f — 1f) * 0.75f)

(0f, 1f — 0f * 0.75f) = (0f, 1f) which mean it will start from horizontal 0 to vertical 1.

when progress = 0f

(0f , 1f — ((1f — progress) * 0.75f) = (0f , 1f — ((1f — 0f) * 0.75f)

(0f, 1f — 1f * 0.75f) = (0f, 1f-0.75f) = (0f, 0.25f)

which mean it will start from horizontal 0 to vertical 0.25f alignment of whole box size .

and alpga is changing with progress which means on 1f it will be completely visible to get invisible on 0f progress value.

# Now let’s add all these element without any sense of direction for now.

//Inside card elements
Image(
    painter = painterResource(id = R.drawable.ic_leo),
    contentDescription = null,
    modifier = Modifier
        .padding(logoPadding)
        .height(leoHeight)
        .width(leoHeight)
)
Text(
    text = "LEO",
    color = Color.White,
    fontSize = 16.sp,
    modifier = Modifier
        .padding(logoPadding)
        .wrapContentWidth(),
)
Row(
    modifier = Modifier.wrapContentSize(),
    horizontalArrangement = Arrangement.spacedBy(ContentPadding)
) {
    IconButton(
        onClick = onPrivacyTipButtonClicked,
        modifier = Modifier
            .size(ButtonSize)
            .background(
                color = LocalContentColor.current.copy(alpha = 0.0f),
                shape = CircleShape
            )
    ) {
        Icon(
            modifier = Modifier.fillMaxSize(),
            imageVector = Icons.Rounded.Edit,
            contentDescription = null,
        )
    }
    IconButton(
        onClick = onSettingsButtonClicked,
        modifier = Modifier
            .size(ButtonSize)
            .background(
                color = LocalContentColor.current.copy(alpha = 0.0f),
                shape = CircleShape
            )
    ) {
        Icon(
            modifier = Modifier.fillMaxSize(),
            imageVector = Icons.Rounded.Share,
            contentDescription = null,
        )
    }
}

 

# Arrange them dynamically using custom layout

@Composable
fun CollapsingToolbar(
    @DrawableRes backgroundImageResId: Int,
    progress: Float,
    onPrivacyTipButtonClicked: () -> Unit,
    onSettingsButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    val leoHeight = with(LocalDensity.current) {
        lerp(CollapsedLeoHeight.toPx(), ExpandedLeoHeight.toPx(), progress).toDp()
    }
    val logoPadding = with(LocalDensity.current) {
        lerp(CollapsedPadding.toPx(), ExpandedPadding.toPx(), progress).toDp()
    }
Surface(
    color = MaterialTheme.colors.primary,
    elevation = Elevation,
    modifier = modifier
) {
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .height(if (progress == 1f) 200.dp else leoHeight * 3)
    ) {
    //#Background Image
            Image(
                painter = painterResource(id = backgroundImageResId),
                contentDescription = null,
                contentScale = ContentScale.FillWidth,
                modifier = Modifier
                    .fillMaxSize()
                    .graphicsLayer {
                        alpha = progress * Alpha
                    },
                alignment = BiasAlignment(0f, 1f - ((1f - progress) * 0.75f))
            )
            CollapsingToolbarLayout(progress, Modifier) {
                    //Inside card elements 
                      ..............................
                }
            }
        }
}
}

 

@Composable
private fun CollapsingToolbarLayout(
    progress: Float,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
 Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

// Repositioning of the elements
... 
}
}

 

now check element count is 3(1. cat image, 2. Text, 3. row of buttons) and get placables from that.

check(measurables.size == 3)
val placeables = measurables.map {
    it.measure(constraints)
}
layout(
    width = constraints.maxWidth,
    height = constraints.maxHeight
) {
    val expandedHorizontalGuideline = (constraints.maxHeight * 0.4f).roundToInt()
    val collapsedHorizontalGuideline = (constraints.maxHeight * 0.5f).roundToInt()

    val leoImage = placeables[0]
    val petName = placeables[1]
    val buttons = placeables[2]

 

We will check the positioning of each item in

#Collapsed state

Cat Image : because content padding was already added. x cooridnate can start from 0 in this case. and y can be middle of collapsed card.

x = 0
y = collapsedHorizontalGuideline/2

Text : x coordinate will start after cat image. and padding was already added.
x = leoImage.width
y = (collapsedHorizontalGuideline — petName.height/2)

Buttons :

x = constraints.maxWidth - buttons.width
y = (constraints.maxHeight - buttons.height) / 2

 

Same way place expanded content. whole code will look something like this.

@Composable
private fun CollapsingToolbarLayout(
    progress: Float,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        check(measurables.size == 3)
        val placeables = measurables.map {
            it.measure(constraints)
        }
        layout(
            width = constraints.maxWidth,
            height = constraints.maxHeight
        ) {
            val expandedHorizontalGuideline = (constraints.maxHeight * 0.4f).roundToInt()
            val collapsedHorizontalGuideline = (constraints.maxHeight * 0.5f).roundToInt()

            val leoImage = placeables[0]
            val petName = placeables[1]
            val buttons = placeables[2]

            leoImage.placeRelative(
                x = lerp(
                    start = 0,
                    stop = constraints.maxWidth / 2 - leoImage.width / 2,
                    fraction = progress
                ),
                y = lerp(
                    start = collapsedHorizontalGuideline / 2,
                    stop = expandedHorizontalGuideline / 2,
                    fraction = progress
                )
            )
            petName.placeRelative(
                x = lerp(
                    start = leoImage.width ,
                    stop = constraints.maxWidth / 2 - petName.width / 2,
                    fraction = progress
                ),
                y = lerp(
                    start = (collapsedHorizontalGuideline - petName.height/2),
                    stop = constraints.maxHeight / 2 + leoImage.width / 3,
                    fraction = progress
                )
            )
            buttons.placeRelative(
                x = constraints.maxWidth - buttons.width,
                y = lerp(
                    start = (constraints.maxHeight - buttons.height) / 2,
                    stop = 0,
                    fraction = progress
                )
            )
        }
    }
}

 

Here placeRelative function takes x, y, z coordinates. and to set x, y we will again use lerp function we defines linear path for these cooridinates, where start being collapsed state and stop being expanded state.

I hope it was pretty clean till now.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Step 2: In this step we will create a list below header and add scroll state to create collapsing view
@Composable
fun LazyColumnExample(progress: Float, scrollState: ScrollableState) {
    val items = listOf(
        "Item 1",
        "Item 2",
        "Item 3",
        "Item 4",
        "Item 5",
        "Item 6",
        "Item 7",
        "Item 8",
        "Item 9",
        "Item 10",
        "Item 11",
        "Item 12",
        "Item 13",
        "Item 14",
        "Item 15",
        "Item 16",
        "Item 17",
        "Item 18",
        "Item 19",
        "Item 20"
    )

    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.LightGray)
            .scrollable(scrollState, Orientation.Vertical)
    ) {
        item {
            CollapsingToolbar(R.drawable.ic_unsplash_background, progress, {}, {}, Modifier)
        }
        items(items.size) { item ->
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = items[item],
                    color = Color.Black
                )
            }
        }
    }
}

 

This will create a list below header. now add scrolling.

@Composable
fun MyScreen() {
    val scrollState = rememberScrollState()
    val maxScrollOffset = 500 // Adjust this to your desired maximum scroll offset

    val progress = remember {
        derivedStateOf {
            val currentScrollOffset = scrollState.value
            val progressValue = currentScrollOffset.toFloat() / maxScrollOffset.toFloat()
            progressValue.coerceIn(0f, 1f)
        }
    }

    // Use the 'progress' state to control the toolbar's appearance
    Box(
        modifier = Modifier
            .fillMaxSize()
    ) {
        LazyColumnExample(progress.value, scrollState)
    }
}

 

In this code, scrollOffset is an Animatable object used to keep track of the toolbar’s current offset (or position) on the Y-axis as it transitions between expanded and collapsed states. It’s animated to provide smooth, gradual transitions between these states based on the scroll direction (up or down).

Progress value will change on bases of scroll offset. Let’s run this code and see.

Oops. Both the scrolling containers are not working together. something it collapses the header, but other time it is just scrolling the list.

Step 3: Add Nested scrolling

This is where nested scrolling comes into picture.

Nested scrolling is a system where multiple scrolling components contained within each other work together by reacting to a single scroll gesture and communicating their scrolling deltas (changes). This is essential when there are nested scrollable elements, like a scrollable toolbar (collapsing or expanding) at the top of a list, where both components need to respond to the user’s scroll input in a synchronized manner.

How Nested Scrolling Works in This Example

NestedScrollConnection: This interface lets you intercept, control, and respond to scroll events for coordinated scrolling. We use NestedScrollConnection here to control the toolbar’s expand and collapse behavior based on the user’s scroll actions.

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
}

  override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
}
}

 

The Nested Scroll Chain: When nested scrolling is active, events are passed through a chain of scrollable components, which communicate with each other:

onPreScroll: This method lets a parent component intercept a scroll event before the child handles it. In this example, onPreScroll checks the scroll delta (how much the user has scrolled) to determine if the toolbar should expand or collapse based on the scroll direction.

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
              val delta = available.y
              val newOffset = (scrollOffset.value + delta).coerceIn(0f, maxScrollOffsetPx)
              val consumed = newOffset - scrollOffset.value
              scrollOffset.value = newOffset
              return Offset(x = 0f, y = consumed)
          }

 

onPostScroll : This pass occurs when the dispatching (scrolling) descendant made their consumption and notifies ancestors with what’s left for them to consume.

 

override fun onPostScroll(
    consumed: Offset,
    available: Offset,
    source: NestedScrollSource
): Offset {
    val delta = available.y
    val newOffset = (scrollOffset.value + delta).coerceIn(0f, maxScrollOffsetPx)
    scrollOffset.value = newOffset
    return Offset.Zero
}

 

  • Accumulating Scroll Delta: By accumulating the scroll delta, the code ensures that the toolbar doesn’t collapse or expand for small scrolls, only for significant gestures in the up or down direction.
  • Animating scrollOffset Based on Scroll Direction: If the accumulated scroll delta crosses a threshold (e.g., 500f), the scrollOffset is animated to collapse (scroll up) or expand (scroll down) the toolbar.
val maxScrollOffsetPx = 500f

// Remember the scroll offset state
val scrollOffset = remember { androidx.compose.runtime.mutableStateOf(0f) }
val scrollProgress = (scrollOffset.value / maxScrollOffsetPx).coerceIn(0f, 1f)

 

  • Nested Scrolling with LazyColumn: The LazyColumn is the scrollable list of items within the screen. When the user scrolls, the LazyColumn emits scroll events. These events are intercepted by the NestedScrollConnection to adjust the toolbar’s position before they are passed on to the list for normal scrolling.
  • Modifier SetupModifier.nestedScroll(nestedScrollConnection) is applied to the main Box container. This connects the main scrollable content (LazyColumn) with the toolbar’s NestedScrollConnection.
Box(
    modifier = Modifier
        .fillMaxSize()
        .nestedScroll(nestedScrollConnection)
) {
    LazyColumnExample(scrollProgress)
}

 

As a result:

  • When the user scrolls up, the toolbar collapses first until it’s fully hidden, then the list scrolls.
  • When the user scrolls down, the toolbar expands first, then the list scrolls after the toolbar reaches its fully expanded state.

Here is the full code

@Composable
fun MyScreen() {
    // Total scroll distance for toolbar collapse
    val maxScrollOffsetPx = 500f

    // Remember the scroll offset state
    val scrollOffset = remember { androidx.compose.runtime.mutableStateOf(0f) }
    val scrollProgress = (scrollOffset.value / maxScrollOffsetPx).coerceIn(0f, 1f)

    // Set up a nested scroll connection for nested scroll handling
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val delta = available.y
                val newOffset = (scrollOffset.value + delta).coerceIn(0f, maxScrollOffsetPx)
                val consumed = newOffset - scrollOffset.value
                scrollOffset.value = newOffset
                return Offset(x = 0f, y = consumed)
            }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                val delta = available.y
                val newOffset = (scrollOffset.value + delta).coerceIn(0f, maxScrollOffsetPx)
                scrollOffset.value = newOffset
                return Offset.Zero
            }
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
        LazyColumnExample(scrollProgress)
    }
}

 

 

. . .

 

Conclusion

In this article, we delved into the realm of creating a dynamic, collapsing header in Jetpack Compose. We explored the intricacies of custom layouts, dynamic sizing, and the powerful concept of nested scrolling.

By leveraging the NestedScrollConnection, we were able to seamlessly integrate the header’s behavior with the underlying scrollable content. This connection allows for a smooth and intuitive user experience, as the header gracefully expands and collapses in response to user gestures.

By mastering these techniques, you can elevate your Jetpack Compose applications to new heights, providing users with engaging and delightful experiences.

References

I hope this article was helpful to you. You can write me back at karishma.agr1996@gmail.com if you want me to improve something in upcoming articles. Your feedback is valuable.

Also, follow me on Medium and Linkedin

Your claps are appreciated to help others find this article 😃 .

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Using annotations in Kotlin has some nuances that are useful to know
READ MORE
blog
One of the latest trends in UI design is blurring the background content behind the foreground elements. This creates a sense of depth, transparency, and focus,…
READ MORE
blog
Now that Android Studio Iguana is out and stable, I wanted to write about…
READ MORE
blog
The suspension capability is the most essential feature upon which all other Kotlin Coroutines…
READ MORE
Menu