Blog Infos
Author
Published
Topics
Published

 

At Exyte we try to contribute to open-source as much as we can, from releasing libraries and components to writing articles and tutorials. One type of tutorials we do is replicating — taking a complex UI component, implementing it using new frameworks and writing a tutorial alongside. We started with SwiftUI some years ago, but this time we finally foray into Android, using Google’s declarative UI framework: Jetpack Compose.

This is the third article of the dribbble Replicating series. The previous post demonstrated implementing the action panel. This one will focus on implementing the Collapsing Header.

Collapsing header reacts to scrolling down the song list, and shrinks when we swipe down to make more space for the content. So its status is tied to that of the song list, and we will store some of the song list states, and pass it to the header.

val headerState = rememberCollapsingHeaderState(key = insets.topInset, topInset = insets.topInset)

We remember the value returned by the calculation if the key is equal to the previous composition, otherwise produce and remember a new value by calling topInset.

@Composable
@Stable
fun rememberCollapsingHeaderState(key: Any, topInset: Dp) = remember(key1 = key) {
    CollapsingHeaderState(topInset = topInset)
}

The class of the state itself:

class CollapsingHeaderState(topInset: Dp) {
    val maxHeaderCollapse = MAX_HEADER_COLLAPSE
    val headerMaxHeight = topInset + MAX_HEADER_HEIGHT
    var headerHeight: Dp by mutableStateOf(headerMaxHeight)

    private val headerElevation by derivedStateOf {
        if (headerHeight > headerMaxHeight - MAX_HEADER_COLLAPSE) 0.dp else 2.dp
    }

    fun findHeaderElevation(isSharedProgressRunning: Boolean): Dp =
        if (isSharedProgressRunning) 0.dp else headerElevation

    companion object{
        val MAX_HEADER_COLLAPSE = 120.dp
        val MAX_HEADER_HEIGHT = 450.dp
    }
}

To determine the time to reduce the header size, we need to catch the scrolling events and pass them on at the correct moments. To do this we’ve written a Modifier extension – we use NestedScrollConnection to define the scrolling process and calculate when to resize the header. We use the onPreScroll override function to calculate the necessary parameters. To get a better idea of what’s going on, let’s take a look at the gif again and separate the function into logical chunks.

 

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

No results found.

fun Modifier.collapsingHeaderController(
    maxOffsetPx: Float,
    firstVisibleItemIndexProvider: () -> Int,
    onScroll: (currentOffsetY: Float) -> Unit,
): Modifier = composed {
    val scrollListener by rememberUpdatedState(newValue = onScroll)

    val connection = remember {
        object : NestedScrollConnection {
            //...
        }
    }
}

Storing the real offset and last notified value.

var lastNotifiedValue = 0f
var currentOffsetPx = 0f

We declare a nested function that updates the value and notifies the listener only if the value has changed.

fun maybeNotify(value: Float) {
    if (lastNotifiedValue != value) {
        lastNotifiedValue = value
        scrollListener(value)
    }
}

onPreScroll override fun is called every time the user starts to scroll. We need to set values to the listener as long as the list offset is less than or equal to the height of the header. To better understand this, take a look at the animation below.

override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    val delta = available.y
    val firstVisibleIndex = firstVisibleItemIndexProvider()
    currentOffsetPx = (currentOffsetPx + delta).coerceAtMost(0f)
//...
}

isOffsetInAllowedLimits variable tells us that the offset is in the right range and we can notify the listener.

val isOffsetInAllowedLimits = currentOffsetPx >= -maxOffsetPx

isScrollingUpWhenHeaderIsDecreased is responsible for the moment of scrolling up, when we still have the first element visible. The header should shrink in this case. If we are scrolling up, the header should increase instead.

val isScrollingUpWhenHeaderIsDecreased = delta < 0 && firstVisibleIndex == 0
val isScrollingDownWhenHeaderIsIncreased = delta > 0 && firstVisibleIndex == 0

calculateOffsetAndNotify() and setCurrentOffsetAndNotify() are used for calculating the offset and notifying the listener if required.

fun setCurrentOffsetAndNotify() {
    currentOffsetPx = currentOffsetPx.coerceAtLeast(-maxOffsetPx)
    maybeNotify(currentOffsetPx)
}

fun calculateOffsetAndNotify(): Offset =
    if (isOffsetInAllowedLimits) {
        setCurrentOffsetAndNotify()
        Offset(0f, delta)
    } else {
        maybeNotify(currentOffsetPx)
        Offset.Zero
    }

Combining what we described above, and in case the offset does not fit our conditions, return Offset.Zero.

return when {
    isScrollingUpWhenHeaderIsDecreased || isScrollingDownWhenHeaderIsIncreased -> {
        calculateOffsetAndNotify()
    }
    else -> Offset.Zero
}

Here’s the full version all in one place. And this is an example of using the controller:

val headerState =
rememberCollapsingHeaderState(key = insets.topInset, topInset = insets.topInset)

SongList(
   modifier = Modifier
       .collapsingHeaderController(
           maxOffsetPx = headerState.maxHeaderCollapse.toPxf(),
           firstVisibleItemIndexProvider = { scrollState.firstVisibleItemIndex },
       ) { currentScroll ->
           headerState.headerHeight =
               headerState.headerMaxHeight + currentScroll.toDp(density)
       },
)

Note that we use the controller on the SongList, because it is the scrolling state of the list that determines the state of the header, as determined in the very beginning of the article.

Once we have recorded and remembered the header state, we resize the header very simply:

Header(
   modifier = Modifier
       .fillMaxWidth()
       .height(headerState.headerHeight)
)

This concludes the collapsible header implementation. The fourth and final part will tackle animated transitions. See you there!

This article was originally published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.

Menu