Implementing a fully custom UI with complex animations: Collapsing Header
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.
Job Offers
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