
🙏 Supported by Emerge Tools
The platform for all your mobile performance needs
Collapsible Top App Bar
Author: Fabricio Vergara
Collapsible Top App Bar in Jetpack Compose

import androidx.compose.foundation.ScrollState | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.BoxWithConstraints | |
import androidx.compose.foundation.layout.BoxWithConstraintsScope | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.RowScope | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.widthIn | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.LazyListState | |
import androidx.compose.foundation.lazy.rememberLazyListState | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material.Card | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.material.Scaffold | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.CompositionLocalProvider | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.State | |
import androidx.compose.runtime.compositionLocalOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.saveable.rememberSaveable | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.layout.onGloballyPositioned | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.text.style.TextOverflow | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.IntSize | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.lerp | |
import androidx.compose.ui.unit.max | |
import kotlin.math.absoluteValue | |
import kotlin.math.min | |
@Preview | |
@Composable | |
private fun PreviewCollapsibleTopAppBarLazyColumn() { | |
MaterialTheme { | |
val listState = rememberLazyListState() | |
CollapsibleScaffold( | |
state = listState, | |
topBar = { | |
CollapsibleTopAppBar( | |
modifier = Modifier.background(Color.Red), | |
onBack = {}, | |
actions = { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.Blue) | |
) | |
} | |
) { | |
Text( | |
text = fraction.toString(), | |
textAlign = TextAlign.Center, | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.Green) | |
) | |
} | |
} | |
) { insets -> | |
LazyColumn(state = listState, contentPadding = insets) { | |
items(100) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(8.dp) | |
.height(80.dp) | |
) { | |
Text( | |
text = "Item $it", | |
modifier = Modifier.fillMaxSize() | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
@Preview | |
@Composable | |
private fun PreviewCollapsibleTopAppBarColumn() { | |
MaterialTheme { | |
val scrollState = rememberScrollState() | |
CollapsibleScaffold( | |
state = scrollState, | |
topBar = { | |
CollapsibleTopAppBar( | |
modifier = Modifier.background(Color.Red), | |
onBack = {}, | |
actions = { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.Blue) | |
) | |
} | |
) { | |
Text( | |
text = fraction.toString(), | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.Green) | |
) | |
} | |
} | |
) { insets -> | |
Column(modifier = Modifier.verticalScroll(scrollState)) { | |
Spacer(modifier = Modifier.padding(insets)) | |
repeat(100) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(8.dp) | |
.height(80.dp) | |
) { | |
Text( | |
text = "Item $it", | |
textAlign = TextAlign.Center, | |
modifier = Modifier.fillMaxSize() | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
object CollapsibleTopAppBarDefaults { | |
// Replicating the value in androidx.compose.material.AppBar.AppBarHeight which is private | |
val minHeight = 56.dp | |
val maxHeight = 320.dp | |
/** | |
* When content height reach this point we start applying padding start and end | |
*/ | |
const val startScalingFraction = 0.5f | |
} | |
private val LocalScrollOffset = compositionLocalOf<State<Int>> { | |
mutableStateOf(Int.MAX_VALUE) | |
} | |
private val LocalInsets = compositionLocalOf { | |
PaddingValues(0.dp) | |
} | |
@Composable | |
fun CollapsibleScaffold( | |
state: ScrollState, | |
modifier: Modifier = Modifier, | |
topBar: @Composable () -> Unit = {}, | |
content: @Composable (insets: PaddingValues) -> Unit | |
) { | |
CollapsibleScaffoldInternal( | |
offsetState = rememberOffsetScrollState(state), | |
modifier = modifier, | |
topBar = topBar, | |
content = content | |
) | |
} | |
@Composable | |
fun CollapsibleScaffold( | |
state: LazyListState, | |
modifier: Modifier = Modifier, | |
topBar: @Composable () -> Unit = {}, | |
content: @Composable (insets: PaddingValues) -> Unit | |
) { | |
CollapsibleScaffoldInternal( | |
offsetState = rememberOffsetScrollState(state), | |
modifier = modifier, | |
topBar = topBar, | |
content = content | |
) | |
} | |
@Composable | |
private fun CollapsibleScaffoldInternal( | |
offsetState: State<Int>, | |
modifier: Modifier = Modifier, | |
topBar: @Composable () -> Unit = {}, | |
content: @Composable (insets: PaddingValues) -> Unit | |
) { | |
Scaffold(modifier = modifier, backgroundColor = Color.Transparent) { insets -> | |
Box { | |
content( | |
PaddingValues( | |
top = CollapsibleTopAppBarDefaults.maxHeight + 8.dp, | |
bottom = 16.dp | |
) | |
) | |
CompositionLocalProvider( | |
LocalScrollOffset provides offsetState, | |
LocalInsets provides insets | |
) { | |
topBar() | |
} | |
} | |
} | |
} | |
@Composable | |
fun CollapsibleTopAppBar( | |
title: String, | |
modifier: Modifier = Modifier, | |
onBack: (() -> Unit)? = null, | |
actions: (@Composable RowScope.() -> Unit)? = null | |
) { | |
CollapsibleTopAppBar( | |
modifier = modifier, | |
actions = actions, | |
navigationIcon = onBack?.let { { BackButton(onClick = it) } } | |
) { | |
CollapsibleTopAppBarTitle( | |
text = title, | |
modifier = modifier.align(Alignment.CenterStart) | |
) | |
} | |
} | |
@Composable | |
fun CollapsibleTopAppBar( | |
modifier: Modifier = Modifier, | |
onBack: (() -> Unit)? = null, | |
actions: (@Composable RowScope.() -> Unit)? = null, | |
content: (@Composable CollapsibleTopAppBarScope.() -> Unit) = { } | |
) { | |
CollapsibleTopAppBar( | |
modifier = modifier, | |
actions = actions, | |
content = content, | |
navigationIcon = onBack?.let { { BackButton(onClick = it) } } | |
) | |
} | |
@Composable | |
fun CollapsibleTopAppBar( | |
modifier: Modifier = Modifier, | |
actions: (@Composable RowScope.() -> Unit)? = null, | |
navigationIcon: (@Composable () -> Unit)? = null, | |
content: (@Composable CollapsibleTopAppBarScope.() -> Unit) = { } | |
) { | |
CollapsibleTopAppBarInternal( | |
scrollOffset = LocalScrollOffset.current.value, | |
insets = LocalInsets.current, | |
modifier = modifier.background(Color.Transparent), | |
navigationIcon = navigationIcon, | |
actions = actions, | |
content = content | |
) | |
} | |
@Composable | |
private fun CollapsibleTopAppBarInternal( | |
scrollOffset: Int, | |
insets: PaddingValues, | |
modifier: Modifier = Modifier, | |
navigationIcon: (@Composable () -> Unit)? = null, | |
actions: (@Composable RowScope.() -> Unit)? = null, | |
content: @Composable CollapsibleTopAppBarScope.() -> Unit | |
) { | |
val density = LocalDensity.current | |
val actionsSize = remember { mutableStateOf(IntSize.Zero) } | |
val navIconSize = remember { mutableStateOf(IntSize.Zero) } | |
val actionWidth = with(density) { actionsSize.value.width.toDp() } | |
val backWidth = with(density) { navIconSize.value.width.toDp() } | |
val bodyHeight = CollapsibleTopAppBarDefaults.maxHeight - CollapsibleTopAppBarDefaults.minHeight | |
val maxOffset = with(density) { | |
bodyHeight.roundToPx() - insets.calculateTopPadding().roundToPx() | |
} | |
val offset = min(scrollOffset, maxOffset) | |
val fraction = 1f - kotlin.math.max(0f, offset.toFloat()) / maxOffset | |
val currentMaxHeight = bodyHeight * fraction | |
BoxWithConstraints(modifier = modifier) { | |
val maxWidth = maxWidth | |
Row( | |
modifier = Modifier | |
.height(CollapsibleTopAppBarDefaults.minHeight) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Box( | |
modifier = Modifier.onGloballyPositioned { | |
navIconSize.value = it.size | |
} | |
) { | |
if (navigationIcon != null) { | |
navigationIcon() | |
} | |
} | |
Spacer(modifier = Modifier.weight(1f)) | |
Row( | |
modifier = Modifier | |
.widthIn(0.dp, maxWidth / 3) | |
.onGloballyPositioned { actionsSize.value = it.size } | |
) { | |
if (actions != null) { | |
actions() | |
} | |
} | |
} | |
val scaleFraction = (fraction / CollapsibleTopAppBarDefaults.startScalingFraction).coerceIn(0f, 1f) | |
val paddingStart = if (fraction > CollapsibleTopAppBarDefaults.startScalingFraction) { | |
0.dp | |
} else { | |
lerp(backWidth, 0.dp, scaleFraction) | |
} | |
val paddingEnd = if (fraction > CollapsibleTopAppBarDefaults.startScalingFraction) { | |
0.dp | |
} else { | |
lerp(actionWidth, 0.dp, scaleFraction) | |
} | |
/** | |
* When content height reach minimum size, we start translating it to fit the toolbar | |
*/ | |
val startTranslateFraction = CollapsibleTopAppBarDefaults.minHeight / CollapsibleTopAppBarDefaults.maxHeight | |
val translateFraction = (fraction / startTranslateFraction).coerceIn(0f, 1f) | |
val paddingTop = if (fraction > startTranslateFraction) { | |
CollapsibleTopAppBarDefaults.minHeight | |
} else { | |
lerp(0.dp, CollapsibleTopAppBarDefaults.minHeight, translateFraction) | |
} | |
BoxWithConstraints( | |
modifier = Modifier | |
.padding(top = paddingTop, start = paddingStart, end = paddingEnd) | |
.height(max(CollapsibleTopAppBarDefaults.minHeight, currentMaxHeight)) | |
.fillMaxWidth() | |
.align(Alignment.BottomStart) | |
) { | |
val scope = remember(fraction, this) { | |
CollapsibleTopAppBarScope(fraction = fraction, scope = this) | |
} | |
content(scope) | |
} | |
} | |
} | |
@Composable | |
fun CollapsibleTopAppBarTitle(text: String, modifier: Modifier = Modifier) { | |
Text( | |
text = text, | |
modifier = modifier, | |
style = MaterialTheme.typography.subtitle1, | |
textAlign = TextAlign.Center, | |
overflow = TextOverflow.Ellipsis, | |
maxLines = 1 | |
) | |
} | |
class CollapsibleTopAppBarScope( | |
val fraction: Float, | |
scope: BoxWithConstraintsScope | |
) : BoxWithConstraintsScope by scope | |
@Composable | |
private fun rememberOffsetScrollState(state: ScrollState): MutableState<Int> { | |
val offsetState = rememberSaveable() { mutableStateOf(0) } | |
LaunchedEffect(key1 = state.value) { | |
offsetState.value = state.value | |
} | |
return offsetState | |
} | |
@Composable | |
private fun rememberOffsetScrollState(state: LazyListState): MutableState<Int> { | |
val offsetState = rememberSaveable() { mutableStateOf(0) } | |
LaunchedEffect(key1 = state.layoutInfo.visibleItemsInfo) { | |
val fistItem = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 } | |
val offset = fistItem?.offset?.absoluteValue ?: Int.MAX_VALUE | |
offsetState.value = offset | |
} | |
return offsetState | |
} |
Have a project you'd like to submit? Fill this form, will ya!
If you like this snippet, you might also like:
Maker OS is an all-in-one productivity system for developers!
I built Maker OS to track, manage & organize my life. Now you can do it too!