Compose, Animated sticky header

Tezov
ITNEXT
Published in
8 min readMay 4, 2024

--

In this narrative, I’ll guide you through the process of creating a reusable layout featuring an animated sticky header and a scrollable body. For those transitioning from earlier days of Android development, achieving this effect is akin to utilizing a coordinator layout.

To accomplish this, we’ll utilize the NestedScrollConnection from Compose. As I prefer an approach that allows for reusable code, I’ll structure it as a standalone class. This class can be easily applied repeatedly, accommodating various animated headers and body content.

Of course, at the end, you’ll find the complete source code as a GitHub repository ;)

Unsplash : Maxim Ilyahov

Table of Contents

  1. Handy density extensions
  2. Layout public invoke
  3. NestedScrollConnection
    a. progress and progressPx
    b. onPreScroll
    c. positive direction
    d. negative direction
  4. Use case -> Auto sizing title + divider appearence
  5. Source code and final thoughts

This is what we gonna do :

1. Handy density extensions

Just for a warm-up, we’ll need some extensions to convert dp to px and px to dp. This can be easily accomplished:

object ExtensionDensity {

inline val Dp.toPx @Composable get() = this.toPx(LocalDensity.current.density)
fun Dp.toPx(density: Float) = value * density

val Int.toDp @Composable get() = this.toDp(LocalDensity.current.density)
fun Int.toDp(density: Float) = Dp((this / density))

val Float.toDp @Composable get() = this.toDp(LocalDensity.current.density)
fun Float.toDp(density: Float) = Dp((this / density))

}

// Now it can be easily used:

// - inside a @Composable scope
@Composable
fun anyComposableScope(
pxSizeInt: Int,
pxSizeFloat: Float,
dpSize: Dp,
) {
log.d("TAG", "pxSizeInt to dp: ${pxSizeInt.toDp}")
log.d("TAG", "pxSizeFloat to dp: ${pxSizeFloat.toDp}")

log.d("TAG", "dpSize to px: ${pxSizeFloat.toPx}")
}

// - inside any scope (need to supply the density)
fun anyNotComposableScope(
density: Float,
pxSizeInt: Int,
pxSizeFloat: Float,
dpSize: Dp,
) {
log.d("TAG", "pxSizeInt to dp: ${pxSizeInt.toDp(density)}")
log.d("TAG", "pxSizeFloat to dp: ${pxSizeFloat.toDp(density)}")

log.d("TAG", "dpSize to px: ${pxSizeFloat.toPx(density)}")
}

You’ll notice that I frequently encapsulate functions, extensions, etc., within an object named. I find this way beneficial for organizing my code with a semblance of namespace. While Kotlin doesn’t inherently have namespaces, using objects serves a similar purpose effectively.

2. Layout public invoke

This part will showcase the function that we’ll need to call in order to utilize our Animated sticky header layout:

object ColumnCollapsibleHeader {

data class Properties(
val min: Dp = 0.dp, // Min header size
val max: Dp = Dp.Infinity, // Max header size
)

@Composable
private fun rememberCollapsibleHeaderState(
maxPxToConsume: Float,
initialProgress: Float = 1f,
initialScroll: Int = 0,
) = remember {

// This class will be dissected in the following section.
CollapsibleHeaderState(maxPxToConsume, initialProgress, initialScroll)
}

@Composable
operator fun invoke(
modifier: Modifier = Modifier,
properties: Properties,

// header will be inside a BoxScope
header: @Composable BoxScope.(progress: Float, progressPx: Dp) -> Unit,

// body will ne inside a ColumnScope and vertically scrollable
body: @Composable ColumnScope.() -> Unit,

) {
val density = LocalDensity.current.density
val sizePx = remember {
(properties.max - properties.min).toPx(density)
}

val collapsibleHeaderState = rememberCollapsibleHeaderState(sizePx)

Column(
modifier = modifier
) {

Box(
modifier = Modifier.fillMaxWidth()
) {
header(
// we give to the header the progress (0-1.0f)
collapsibleHeaderState.progress,
// we give also to the header the progress in dp
properties.min + collapsibleHeaderState.progressPx.toDp(density)
)
}

Column(
Modifier
.fillMaxSize()

// here we intercept the scroll and do our math
// to consummed it or not
.nestedScroll(collapsibleHeaderState)
.verticalScroll(collapsibleHeaderState.scrollState)
) {
body()
}
}

}
}

When we want to use our layout, we provide the properties:

  • minimum header height
  • maximum header height

We also provide our composable content:

  • The header content will be full width and wrap height. To facilitate header content animation, the function will receive the progress as unit-normalized progress (0 to 1) and also the progress in dp directly. These values can then be utilized directly within any modifier of the header content.
  • The body content will be full width and occupy the full remaining height. Additionally, it will be embedded inside a vertical scrollable layout.

3. NestedScrollConnection

The NestedScrollConnection from Compose allows you to intercept the scroll before it is consumed by someone else. Even better, you can consume the scroll partially or fully before it is transmitted to the next receiver.

Got it! This opens the door for us to create a sticky animated header that can consume the scroll for various animated purposes like expanding or shrinking, and more. This interaction seamlessly integrates with the scrollable body content, ensuring a perfectly smooth user experience. Let’s dive in and get started!

a. progress and progressPx

private class CollapsibleHeaderState(
private val maxPxToConsume: Float, // Maximum px we allow to be consumed
initialProgress: Float,
initialScroll: Int,
) : NestedScrollConnection {

val scrollState = ScrollState(initialScroll) // Compose scrollState

// mutable state to update the view
// you can't see it now, but we are inside a remember
private val _progress = mutableStateOf(initialProgress)

var progress: Float // progress 0-1
get() = _progress.value
set(value) {
pxConsumed = maxPxToConsume * (1f - value)
}

val progressPx get() = maxPxToConsume * progress // progress in px

// remember pxConsumed
private var pxConsumed: Float = maxPxToConsume * (1f - initialProgress) // initial value
set(value) {
field = value

//update progress 0-1 at each set
_progress.value = (1f - value / maxPxToConsume)
}

....

}
  • “scrollState” is a Compose state that we need to provide to the “verticalScroll” modifier. This variable informs us about the actual scroll position.
  • “progress” and “progressPx” will serve as the public output variables used by the header to animate itself.
  • “pxConsumed” represents the actual pixel value that we’ve consumed and not forwarded to the vertical scroll view (our body).

b. OnPreScroll

onPreScroll is an overridable method from NestedScrollConnection. It provides us with the available scroll position that needs to be consumed by someone. We return the value of the offset we’ve decided to consume, which is zero if we consume nothing. All the remaining unconsumed values will be dispatched to the next registered consumer.

private class CollapsibleHeaderState(
private val maxPxToConsume: Float, // Maximum px we allow to be consumed
initialProgress: Float,
initialScroll: Int,
) : NestedScrollConnection {

....

// convenient method to compute the scroll direction
private fun isDirectionPositive(value: Float) = value.sign < 0f

// here where we will intercept the scroll
// we receive the available px scrolled by user
// and we return the amount we have consummed
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
return if (isDirectionPositive(available.y)) {
onDirectionPositive(available)
} else {
onDirectionNegative(available)
}
}

....

}

In this function, I simply check the direction of the scrolling and call another function with a descriptive name for improved readability.

c. Positive direction

The positive direction indicates scrolling that reveals the bottom content, meaning we’re scrolling towards the top of the screen. Therefore, the y-axis available is negative. Admittedly, this logic can be a bit mind-bending!

private fun onDirectionPositive(available: Offset): Offset {
if (progress <= 0f) {
return Offset.Zero
}
val allowedToBeConsumed = maxPxToConsume - pxConsumed
val notConsumed = (abs(available.y) - allowedToBeConsumed)
if (notConsumed <= 0f) {
pxConsumed -= available.y
return available
}
pxConsumed = maxPxToConsume.toFloat()
return Offset(0f, -allowedToBeConsumed)
}

If the progress is already at 0, indicating the minimum value, we don’t consume more.

Else we consume everything we are allowed to consumed (maxValue minus minValue] from properties). We make sure we never consume more than there is available. pxConsumed is always updated and it will automatically update the progress.

I’m not addressing minHeight and maxHeight in this context. They are implicit within maxPxToConsume. Since this CollapsibleHeaderState can be used for anything (only along the vertical axis), and considering that I’m not aware of the header at all in this context, nor is it being utilized to animate or constrain the height of this header.”

d. Negative direction

The last part concerns the negative direction, indicating that we are scrolling towards the bottom of the device, revealing more content from the top. This time the y-axis available is positive

private fun onDirectionNegative(available: Offset): Offset {
if (progress >= 1f) {
return Offset.Zero
}
val availableToBeConsumed = available.y - scrollState.value
if (availableToBeConsumed <= 0f) {
return Offset.Zero
}
val allowedToBeConsumed = pxConsumed
val notConsumed = availableToBeConsumed - allowedToBeConsumed
if (notConsumed <= 0) {
pxConsumed -= availableToBeConsumed
return available
}
pxConsumed = 0f
return Offset(0f, allowedToBeConsumed)

}

If the progress is already at 1, indicating the maximum value, we don’t consume more.

We wait for the available y value to exceed the current scrollstate.value, signifying that we are in the zone where we can begin consumption.

Then the computation is the same as in the positive direction. We consume what we can, but never more than what is available. Just need to be carefull of the signed number.

I understand, it’s not always easy to grasp. To better comprehend it, I logged the numbers to understand them precisely.

4. Use case -> Auto sizing title + divider appearence

Now, I’ll demonstrate how to use it on any screen. I’ll keep it simple on the animated part, with just an auto-resizing title and a horizontal divider that appears at the minimum size.

ColumnCollapsibleHeader(
modifier = Modifier.fillMaxSize(),
properties = ColumnCollapsibleHeader.Properties(
min = 40.dp,
max = 120.dp
),
header = { progress, progressDp ->
Header(progress = progress, progressDp = progressDp)
},
body = {
(1..20).forEach {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.padding(vertical = 2.dp)
.background(generateRandomColor())
)
}
}
)

The body will simply be some rectangles with random color. As for the header, it will resemble this:

private const val DIVIDER_HEADER_VISIBILITY_START = 0.3f

@Composable
fun BoxScope.Header(
progress: Float,
progressDp: Dp
) {
Column(
modifier = Modifier.height(progressDp), // Here the height ajustement
verticalArrangement = Arrangement.Bottom
) {
Text(
modifier = Modifier
.padding(
// Here padding of the title
// - small height header -> small padding 8dp
// - big height header -> big padding 32.dp
// title will move horizontally
start = 8.dp + (24.dp * progress),
end = 8.dp,
top = 4.dp,
bottom = 4.dp
),
text = "Header Title",
style = MaterialTheme.typography.headlineLarge,
// Here the resizing from 16sp to 54sp
fontSize = (16 + ((54 - 16) * progress)).sp
)
Divider(
modifier = Modifier
.fillMaxWidth()
.alpha(
// Here the appearance, disappearance of the divider
if(progress >= DIVIDER_HEADER_VISIBILITY_START) {
0.0f
}
else {
(DIVIDER_HEADER_VISIBILITY_START - progress) / DIVIDER_HEADER_VISIBILITY_START
}
),
thickness = 4.dp,
color = Color.Black
)

}
}

As you can see, it’s quite straightforward to use this layout, and with a bit of imagination, you can animate anything. You’re not even constrained to animate the height of the header. Simply utilize the progress / progressDp to animate anything you desire!

5. Source code and final thoughts

First, find the source code available in this repository:

tezov.medium.adr.collpsible_header_layout

I did this with a Column. To do it with LazyColumn, it will need more work, since you can’t know the current scroll position of LazyColumn.

Don’t hesitate to comment or request more information. If you find this story helpful, consider following or subscribing to the newsletter. Thanks.

I’ll gladly also accept any support to help me to write more content.

--

--

Born with a C real-time system background. Nowadays, i'm a fanatic Android developer and Swift lover.