Skip to content

Instantly share code, notes, and snippets.

@racka98
Last active September 9, 2022 11:33
Show Gist options
  • Save racka98/1d0a6f6016495167fbd4389021dd5540 to your computer and use it in GitHub Desktop.
Save racka98/1d0a6f6016495167fbd4389021dd5540 to your computer and use it in GitHub Desktop.
Collapsing Toolbar with Jetpack Compose using NestedScrollConnection
/**
* Collapsing Toolbar that can be used in a topBar slot of Scaffold.
* It has a back button, default bottom rounded corners
* & a box scope which holds content centered by default.
* You need to implement nestedScrollConnection to set the offset values
* See Usage of this in DashboardScreen or TasksScreen or GoalsScreen
*
* To use this Toolbar without a heading text just make toolbarHeading `null`
* To Disable the back button at the top set showBackButton to false
*
* With nestedScrollConnection know that the maximum offset that can be
* reached is -132.0
*/
@Composable
fun CollapsingToolbarBase(
modifier: Modifier = Modifier,
toolbarHeading: String?,
showBackButton: Boolean = true,
onBackButtonPressed: () -> Unit = { },
contentAlignment: Alignment = Alignment.Center,
shape: Shape = Shapes.large,
collapsedBackgroundColor: Color = MaterialTheme.colorScheme.background,
backgroundColor: Color = MaterialTheme.colorScheme.background,
toolbarHeight: Dp,
minShrinkHeight: Dp = 100.dp,
toolbarOffset: Float,
onCollapsed: (Boolean) -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val scrollDp = toolbarHeight + toolbarOffset.dp
val collapsed by remember(scrollDp) {
mutableStateOf(
scrollDp < minShrinkHeight + 20.dp
)
}
val animatedCardSize by animateDpAsState(
targetValue = if (scrollDp <= minShrinkHeight) minShrinkHeight else scrollDp,
animationSpec = tween(300, easing = LinearOutSlowInEasing)
)
val animatedElevation by animateDpAsState(
targetValue = if (scrollDp < minShrinkHeight + 20.dp) 10.dp else 0.dp,
animationSpec = tween(500, easing = LinearOutSlowInEasing)
)
val animatedTitleAlpha by animateFloatAsState(
targetValue = if (!toolbarHeading.isNullOrBlank()) {
if (scrollDp <= minShrinkHeight + 20.dp) 1f else 0f
} else 0f,
animationSpec = tween(300, easing = LinearOutSlowInEasing)
)
val animatedColor by animateColorAsState(
targetValue = if (scrollDp < minShrinkHeight + 20.dp) collapsedBackgroundColor
else backgroundColor,
animationSpec = tween(300, easing = LinearOutSlowInEasing)
)
LaunchedEffect(key1 = collapsed) {
onCollapsed(collapsed)
}
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = animatedElevation,
shape = shape
)
.background(
color = animatedColor,
shape = shape
)
) {
Box(
modifier = modifier
.height(animatedCardSize)
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (showBackButton) {
IconButton(
onClick = onBackButtonPressed,
modifier = Modifier
.padding(Dimens.SmallPadding.size)
) {
Icon(
imageVector = Icons.Rounded.ArrowBack,
contentDescription = stringResource(id = R.string.back_icon),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
toolbarHeading?.let {
Text(
text = toolbarHeading,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(
alpha = animatedTitleAlpha
),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier
.padding(horizontal = Dimens.SmallPadding.size)
)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.alpha(1 - animatedTitleAlpha),
contentAlignment = contentAlignment,
content = content
)
}
}
}
@Composable
private fun SomeScreenTopBar(
tabPage: TabDestinations,
profilePicUrl: String,
toolbarOffset: Float,
toolbarCollapsed: Boolean,
onCollapsed: (Boolean) -> Unit,
updateTabPage: (TabDestinations) -> Unit,
) {
CollapsingToolbarBase(
modifier = Modifier
.statusBarsPadding(),
toolbarHeading = null,
toolbarHeight = 120.dp,
toolbarOffset = toolbarOffset,
showBackButton = false,
minShrinkHeight = 60.dp,
shape = RectangleShape,
onCollapsed = {
onCollapsed(it)
}
) {
Column(
modifier = Modifier
.animateContentSize()
.fillMaxWidth(),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
AnimatedVisibility(
visible = !toolbarCollapsed,
enter = fadeIn(),
exit = fadeOut()
) {
FancySearchBar(
extraButton = {
ProfilePicture(
pictureUrl = profilePicUrl
)
}
)
}
LazyRow {
item {
TasksTabBar(
modifier = Modifier
.padding(horizontal = 16.dp),
tabPage = tabPage,
onTabSelected = {
updateTabPage(it)
}
)
}
}
}
}
}
@Composable
fun YourScreen() {
// CollapsingToolbar NestedScrollConnection Impl
val toolbarHeight = 120.dp
val toolbarCollapsed = rememberSaveable { mutableStateOf(false) }
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource,
): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
// Returning Zero so we just observe the scroll but don't execute it
return Offset.Zero
}
}
}
Scaffold(
topBar = {
SomeScreenTopBar(
tabPage = tabPage,
profilePicUrl = "https://via.placeholder.com/150",
toolbarOffset = toolbarOffsetHeightPx.value,
toolbarCollapsed = toolbarCollapsed.value,
onCollapsed = {
toolbarCollapsed.value = it
},
updateTabPage = {
navController.navigate(it.route)
},
)
}
) {
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection) // Attach the nestedScrollConnection
.fillMaxSize(),
verticalArrangement = Arrangement
.spacedBy(Dimens.MediumPadding.size)
) {
items(someList) { item ->
SomeEntry(
entry = item
)
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment