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.
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.
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
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
), thescrollOffset
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, theLazyColumn
emits scroll events. These events are intercepted by theNestedScrollConnection
to adjust the toolbar’s position before they are passed on to the list for normal scrolling. - Modifier Setup:
Modifier.nestedScroll(nestedScrollConnection)
is applied to the mainBox
container. This connects the main scrollable content (LazyColumn
) with the toolbar’sNestedScrollConnection
.
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.