ylliX - Online Advertising Network
Collapsing Toolbar in Compose— PART 2

Collapsing Toolbar in Compose— PART 2


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.

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 Setup: Modifier.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)
}
}



Source link

Leave a Reply

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