EmergeTools logo
🙏 Supported by Emerge Tools
The platform for all your mobile performance needs

Collapsible Top App Bar

Collapsible Top App Bar in Jetpack Compose

CollapsibleTopAppBar Demo

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!