Skip to content

Instantly share code, notes, and snippets.

@diareuse
Created October 17, 2023 08:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save diareuse/5169e1e1b28ce31dee40b8cdbf84beea to your computer and use it in GitHub Desktop.
Save diareuse/5169e1e1b28ce31dee40b8cdbf84beea to your computer and use it in GitHub Desktop.
CollapsingTopAppBar for Jetpack Compose
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.icons.*
import androidx.compose.material.icons.automirrored.filled.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.nestedscroll.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.tooling.preview.*
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.*
import kotlin.math.max
private const val NavigationIcon = "navigationIcon"
private const val Title = "title"
private const val Actions = "actions"
private const val ExpandedTitle = "expandedTitle"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollapsingTopAppBar(
scrollBehavior: TopAppBarScrollBehavior,
title: @Composable () -> Unit,
navigationIcon: @Composable () -> Unit,
actions: @Composable () -> Unit,
modifier: Modifier = Modifier,
arrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp),
expandedTitlePadding: PaddingValues = PaddingValues(start = 12.dp, top = 24.dp),
titleStyle: TextStyle = MaterialTheme.typography.titleLarge,
expandedTextStyle: TextStyle = MaterialTheme.typography.displaySmall
) {
val alignment = Alignment.CenterVertically
val density = LocalDensity.current
val direction = LocalLayoutDirection.current
val expandedTitlePaddingStart = with(density) {
expandedTitlePadding.calculateStartPadding(direction).roundToPx()
}
val expandedTitlePaddingTop = with(density) {
expandedTitlePadding.calculateTopPadding().roundToPx()
}
val fraction = scrollBehavior.state.collapsedFraction
val expandFraction = fraction.coerceAtMost(.5f) * 2
val hideFraction = (fraction - .5f).coerceIn(0f, .5f) * 2
val expandedTitleStyle = lerp(expandedTextStyle, titleStyle, expandFraction)
val spacing = with(density) { arrangement.spacing.roundToPx() }
Layout(
modifier = modifier.statusBarsPadding(),
content = {
Box(Modifier.layoutId(NavigationIcon)) { navigationIcon() }
Box(Modifier.layoutId(Title)) {
ProvideTextStyle(titleStyle) {
title()
}
}
Box(Modifier.layoutId(Actions)) { actions() }
Box(Modifier.layoutId(ExpandedTitle)) {
ProvideTextStyle(expandedTitleStyle) {
title()
}
}
}
) { measurables, constraints ->
// Stage 1: Measure
var cs = constraints.copy(minWidth = 0, minHeight = 0)
cs = cs.minusWidth(spacing)
val navigationIcon = measurables.first { it.layoutId == NavigationIcon }.measure(cs)
cs = cs.minusWidth(navigationIcon.width)
val actions = measurables.first { it.layoutId == Actions }.measure(cs)
cs = cs.minusWidth(actions.width).fixedWidth()
val title = measurables.first { it.layoutId == Title }.measure(cs)
val expandedTitle = measurables.first { it.layoutId == ExpandedTitle }.measure(constraints)
// Stage 2: Arrange
val pinnedHeight = maxOf(navigationIcon.height, actions.height, title.height)
val (offsetNavigationIcon, offsetTitle, offsetActions) = arrangement.arrange(
density = density,
direction = direction,
fullSize = constraints.maxWidth,
navigationIcon.width, title.width, actions.width
)
val expandedX = lerp(
start = offsetNavigationIcon + expandedTitlePaddingStart,
stop = offsetTitle,
fraction = expandFraction
)
val expandedY = lerp(
start = pinnedHeight + expandedTitlePaddingTop,
stop = alignment.align(title.height, pinnedHeight),
fraction = expandFraction
)
// Stage 3: Modify scroll behavior
val height = max(pinnedHeight, expandedY + expandedTitle.height)
if (scrollBehavior.state.heightOffsetLimit != -height.toFloat()) {
scrollBehavior.state.heightOffsetLimit = -height.toFloat()
}
// Stage 4: Layout
val offset = (height * hideFraction).toInt().coerceAtLeast(0)
layout(constraints.maxWidth, height - offset) {
navigationIcon.placeRelative(
x = offsetNavigationIcon,
y = alignment.align(navigationIcon.height, pinnedHeight) - offset
)
actions.placeRelative(
x = offsetActions,
y = alignment.align(actions.height, pinnedHeight) - offset
)
expandedTitle.placeRelative(
x = expandedX,
y = expandedY - offset
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
private fun CollapsingTopAppBarPreview() = MaterialTheme {
val behavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
topBar = {
CollapsingTopAppBar(
modifier = Modifier.padding(12.dp),
scrollBehavior = behavior,
title = { Text("I'm title") },
navigationIcon = {
IconButton(onClick = { /*TODO*/ }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
},
actions = {
IconButton(onClick = { /*TODO*/ }) {
Icon(Icons.Default.MoreVert, null)
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier.nestedScroll(behavior.nestedScrollConnection),
contentPadding = padding.plus(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(100) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.background(Color.Blue)
)
}
}
}
}
private fun Constraints.minusWidth(width: Int): Constraints {
val newMinWidth = (minWidth - width).coerceAtLeast(0)
val newMaxWidth = (maxWidth - width).coerceAtLeast(newMinWidth)
return copy(minWidth = newMinWidth, maxWidth = newMaxWidth)
}
private fun Constraints.fixedWidth(width: Int = maxWidth) = copy(
minWidth = width,
maxWidth = maxWidth
)
private fun Arrangement.Horizontal.arrange(
density: Density,
direction: LayoutDirection,
fullSize: Int,
vararg itemSizes: Int
) = IntArray(itemSizes.size).apply {
density.arrange(fullSize, itemSizes, direction, this)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment