Skip to content

Instantly share code, notes, and snippets.

@wotosts
Created April 11, 2023 14:28
Show Gist options
  • Save wotosts/cb38c0e293d4ebcf5bdd34790b7e7169 to your computer and use it in GitHub Desktop.
Save wotosts/cb38c0e293d4ebcf5bdd34790b7e7169 to your computer and use it in GitHub Desktop.
Android Jetpack Compose CollapsingAppBar
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.animateTo
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.TopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Color.Companion.White
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapsingAppBar(
modifier: Modifier = Modifier,
backgroundColor: Color = White,
title: String = "",
collapsedTitleStyle: TextStyle,
expandedTitleStyle: TextStyle,
expandedTitleStartPadding: Dp = 16.dp,
expandedTitleBottomPadding: Dp = 16.dp,
minHeight: Dp = 56.dp,
scrollBehavior: TopAppBarScrollBehavior,
collapsingContent: @Composable () -> Unit = { }
) {
var collapsingContentHeight by remember { mutableStateOf(0) }
val offset = scrollBehavior.state.heightOffset
val minHeightPx = LocalDensity.current.run { minHeight.toPx() }
SideEffect {
val limit = minHeightPx - collapsingContentHeight.toFloat()
if (scrollBehavior.state.heightOffsetLimit != limit) {
scrollBehavior.state.heightOffsetLimit = limit
}
}
val appBarDragModifier = if (!scrollBehavior.isPinned) {
Modifier.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
scrollBehavior.state.heightOffset = offset + delta
},
onDragStopped = { velocity ->
settleAppBar(
scrollBehavior.state,
velocity,
scrollBehavior.flingAnimationSpec,
scrollBehavior.snapAnimationSpec
)
}
)
} else {
Modifier
}
val baseOffset = if (scrollBehavior.state.heightOffsetLimit > minHeightPx) -minHeightPx else (minHeightPx - collapsingContentHeight)
val titleCollapsedFraction = offset / baseOffset
val collapsedTitleAlpha =
CubicBezierEasing(.8f, 0f, .8f, .15f).transform(titleCollapsedFraction)
val expandedTitleAlpha = 1f - titleCollapsedFraction
Surface(
modifier = modifier
.background(backgroundColor)
.systemBarsPadding()
.statusBarsPadding()
.then(appBarDragModifier)
) {
Layout(
content = {
Box(modifier = Modifier.layoutId("collapsingContent")) { collapsingContent() }
Box(
Modifier
.layoutId("toolbar")
.fillMaxWidth()
.padding(horizontal = 8.dp)
.height(minHeight)
.background(White)
) {
// add composable in toolbar
}
Text(
modifier = Modifier
.layoutId("expandedTitle")
.alpha(expandedTitleAlpha)
.padding(
top = 3.dp,
start = expandedTitleStartPadding,
bottom = expandedTitleBottomPadding
),
text = title, color = Primary, style = expandedTitleStyle
)
Text(
modifier = Modifier
.layoutId("collapsedTitle")
.alpha(collapsedTitleAlpha),
text = title, color = Primary, style = collapsedTitleStyle
)
},
modifier = Modifier
.statusBarsPadding()
.systemBarsPadding(),
) { measurables, constraints ->
val ccPlaceable =
measurables.first { it.layoutId == "collapsingContent" }.measure(constraints)
val tbPlaceable =
measurables.first { it.layoutId == "toolbar" }.measure(constraints)
val titlePlaceable =
measurables.first { it.layoutId == "expandedTitle" }.measure(constraints)
val collapsedTitlePlaceable =
measurables.first { it.layoutId == "collapsedTitle" }.measure(constraints)
collapsingContentHeight =
max(ccPlaceable.height, tbPlaceable.height + titlePlaceable.height)
val maxWidth =
listOf(ccPlaceable.width, tbPlaceable.width).max()
val currentHeight =
collapsingContentHeight + scrollBehavior.state.heightOffset
layout(maxWidth, currentHeight.toInt()) {
ccPlaceable.placeRelative(
0, offset.roundToInt()
)
titlePlaceable.placeRelative(
0,
minHeight.roundToPx() + offset.roundToInt()
)
tbPlaceable.placeRelative(0, 0)
collapsedTitlePlaceable.placeRelative(
(maxWidth - collapsedTitlePlaceable.width) / 2,
(minHeightPx - collapsedTitlePlaceable.height).toInt() / 2
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
private suspend fun settleAppBar(
state: TopAppBarState,
velocity: Float,
flingAnimationSpec: DecayAnimationSpec<Float>?,
snapAnimationSpec: AnimationSpec<Float>?
): Velocity {
// Check if the app bar is completely collapsed/expanded. If so, no need to settle the app bar,
// and just return Zero Velocity.
// Note that we don't check for 0f due to float precision with the collapsedFraction
// calculation.
if (state.collapsedFraction < 0.01f || state.collapsedFraction == 1f) {
return Velocity.Zero
}
var remainingVelocity = velocity
// In case there is an initial velocity that was left after a previous user fling, animate to
// continue the motion to expand or collapse the app bar.
if (flingAnimationSpec != null && abs(velocity) > 1f) {
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = velocity,
)
.animateDecay(flingAnimationSpec) {
val delta = value - lastValue
val initialHeightOffset = state.heightOffset
state.heightOffset = initialHeightOffset + delta
val consumed = abs(initialHeightOffset - state.heightOffset)
lastValue = value
remainingVelocity = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
}
}
// Snap if animation specs were provided.
if (snapAnimationSpec != null) {
if (state.heightOffset < 0 &&
state.heightOffset > state.heightOffsetLimit
) {
AnimationState(initialValue = state.heightOffset).animateTo(
if (state.collapsedFraction < 0.5f) {
0f
} else {
state.heightOffsetLimit
},
animationSpec = snapAnimationSpec
) { state.heightOffset = value }
}
}
return Velocity(0f, remainingVelocity)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment