Reputation: 416
I have the following layout that uses Jetpack Compose. It is fairly complicated and uses a scrollstate that is shared between multiple views to facilitate the animation. This works perfectly if using a normal column. However, when I try to use a LazyVerticalGrid, I get this error.
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed.
I can make it run by setting the LazyVerticalGrid's height to 1000 so that it isn't infinite, but it results in 2 scrollables. One is the header and toolbar area, and the other is the LazyVerticalGrid. Is there any way to do this that shares the scrollstate between all of the composables? I understand that you can't have infinite maximum height constraints, but there must be something that can be done here........
See Animation here https://imgur.com/a/62FgaDl
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.core.view.WindowCompat
import com.example.twittercollapsingtoolbar.ui.theme.BrownTransparent
import com.example.twittercollapsingtoolbar.ui.theme.Gray
import com.example.twittercollapsingtoolbar.ui.theme.TwitterCollapsingToolbarTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TwitterCollapsingToolbarTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
TwitterCollapsingToolbar()
}
}
}
actionBar?.hide()
WindowCompat.setDecorFitsSystemWindows(window, false)
}
}
val headerHeight = 140.dp
val toolbarHeight = 56.dp
val collapseRange = headerHeight - toolbarHeight
val avatarSize = 90.dp
val paddingMedium = 16.dp
val paddingSmall = 4.dp
@Composable
fun TwitterCollapsingToolbar() {
val scrollState = rememberScrollState()
val titleHeight = remember { mutableStateOf(0f) }
val collapseRangePx = with(LocalDensity.current) { collapseRange.toPx() }
val avatarSizePx = with(LocalDensity.current) { avatarSize.toPx() }
val profileNameTopPaddingPx = with(LocalDensity.current) { paddingSmall.toPx() }
val paddingMediumPx = with(LocalDensity.current) { paddingMedium.toPx() }
val collapseRangeReached = remember {
derivedStateOf {
scrollState.value >= (collapseRangePx)
}
}
val avatarZIndex = remember {
derivedStateOf {
if (collapseRangeReached.value)
0f
else
2f
}
}
Scaffold(
) { padding ->
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.zIndex(0f)
.background(Color.Black)
.fillMaxSize()
.verticalScroll(scrollState)
) {
Spacer(Modifier.height(headerHeight))
Text(
text = stringResource(id = R.string.edit_profile),
color = Gray,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 12.dp, vertical = 8.dp)
.border(1.dp, Gray, RoundedCornerShape(20.dp))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.height(1000.dp)
) {
item(span = {
GridItemSpan(maxLineSpan)
}) {
Text(
text = "Title",
modifier = Modifier
.padding(top = paddingSmall, start = paddingMedium)
.onGloballyPositioned {
titleHeight.value = it.size.height.toFloat()
}
)
}
item(span = {
GridItemSpan(maxLineSpan)
}) {
Text("Items")
}
items(20) { index ->
ItemListItem()
}
}
}
Header(scrollState, collapseRangePx, Modifier.zIndex(1f), collapseRangeReached)
Avatar(
scrollState,
collapseRangePx,
paddingMediumPx,
collapseRangeReached,
avatarZIndex
)
Toolbar(
scrollState,
collapseRangePx,
titleHeight,
avatarSizePx,
profileNameTopPaddingPx,
collapseRangeReached,
Modifier
.zIndex(3f)
.statusBarsPadding()
)
ToolbarActions(
Modifier
.zIndex(4f)
.statusBarsPadding()
)
}
}
}
@Composable
fun ItemListItem() {
Card() {
Column {
val imageModifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clip(RoundedCornerShape(topEnd = 0.dp, bottomStart = 12.dp, bottomEnd = 12.dp))
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "Title",
contentScale = ContentScale.Fit,
modifier = imageModifier
)
Text(
"Title",
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(16.dp),
)
}
}
}
@Composable
fun Header(
scrollState: ScrollState, collapseRangePx: Float, modifier: Modifier,
showToolbar: State<Boolean>
) {
AnimatedVisibility(
visible = !showToolbar.value,
enter = fadeIn(animationSpec = tween(600)),
exit = fadeOut(animationSpec = tween(600)),
modifier = modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(headerHeight)
.graphicsLayer {
val collapseFraction = (scrollState.value / collapseRangePx).coerceIn(0f, 1f)
val yTranslation = lerp(
0.dp,
-(headerHeight - toolbarHeight),
collapseFraction
)
translationY = yTranslation.toPx()
val blur = lerp(0.dp, 3.dp, collapseFraction)
if (blur != 0.dp) {
renderEffect = BlurEffect(blur.toPx(), blur.toPx(), TileMode.Decal)
}
}
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun Avatar(
scrollState: ScrollState,
collapseRangePx: Float,
paddingPx: Float,
switch: State<Boolean>,
avatarZIndex: State<Float>
) {
Box(
modifier = Modifier
.zIndex(avatarZIndex.value)
.graphicsLayer {
val collapseFraction = (scrollState.value / collapseRangePx).coerceIn(0f, 1f)
val scaleXY = lerp(
1.dp,
0.5.dp,
collapseFraction
)
val yTranslation = lerp(
headerHeight - (avatarSize / 2),
toolbarHeight - (avatarSize * ((1.dp - scaleXY) / 2.dp)),
collapseFraction
)
translationY = if (switch.value)
(toolbarHeight.toPx() - (avatarSize.toPx() * ((1.dp - scaleXY) / 2.dp)) -
(scrollState.value - collapseRange.toPx()))
else
yTranslation.toPx()
translationX = paddingPx
scaleX = scaleXY.value
scaleY = scaleXY.value
}
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = "",
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.border(paddingSmall, Color.Black, CircleShape)
)
}
}
@Composable
fun Toolbar(
scrollState: ScrollState,
collapseRangePx: Float,
titleHeight: MutableState<Float>,
avatarSizePx: Float,
profileNameTopPaddingPx: Float,
showToolbar: State<Boolean>,
modifier: Modifier
) {
val showTitle by remember {
derivedStateOf {
scrollState.value >=
collapseRangePx + avatarSizePx / 2 + profileNameTopPaddingPx + titleHeight.value
}
}
val title = buildAnnotatedString {
withStyle(style = SpanStyle(fontSize = 20.sp, fontWeight = FontWeight.W700)) {
append(stringResource(id = R.string.profile_name))
}
append("\n")
withStyle(style = SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.W400)) {
append(stringResource(id = R.string.tweets))
}
}
AnimatedVisibility(
visible = showToolbar.value,
enter = fadeIn(animationSpec = tween(600)),
exit = fadeOut(animationSpec = tween(600)),
modifier = modifier
) {
TopAppBar(
navigationIcon = {},
title = {
if (showTitle) {
Text(text = title)
}
},
elevation = 0.dp
)
}
}
@Composable
fun ToolbarActions(modifier: Modifier) {
Row(
modifier = modifier
.fillMaxWidth()
.height(toolbarHeight)
) {
IconAction(
Modifier.padding(top = paddingMedium, start = paddingMedium),
Icons.Default.ArrowBack
)
Spacer(modifier = Modifier.weight(1f))
IconAction(
Modifier.padding(top = paddingMedium, end = paddingMedium),
Icons.Default.MoreVert
)
}
}
@Composable
private fun IconAction(modifier: Modifier, image: ImageVector) {
IconButton(
onClick = {},
modifier = modifier
.clip(CircleShape)
.size(32.dp)
.background(BrownTransparent)
) {
Icon(
imageVector = image,
contentDescription = "",
tint = Color.White,
modifier = Modifier.padding(paddingSmall)
)
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
TwitterCollapsingToolbarTheme {
TwitterCollapsingToolbar()
}
}
Upvotes: 1
Views: 2202
Reputation: 67248
1-) You can't measure a Composable with Constraints.Infinity, which comes from vertical scroll to child, but it doesn't mean you have to set a fixed heigt. You can use Modifier.heighIn(max=1000.dp) which means your Composable is measured between 0-1000.dp. If its content's total height is less than 1000.dp, for instance 400.dp, it gets height of the content. This is optional you can set fixed height or use maxiumum non-infinite bounds.
2-) You can just set userScrollEnabled = false
on LazyVerticalGrid to move it based on Column scroll but this alone will not scroll grid when Column reaches end.
You can create a NestedScrollConnection and set scroll of grid using preScroll function. You can check out this answer and this one.
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = -available.y
coroutineScope.launch {
lazyGridState.scrollBy(delta)
}
return Offset.Zero
}
}
}
And using it
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.zIndex(0f)
.background(Color.Black)
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
.verticalScroll(scrollState)
) {
Spacer(Modifier.height(headerHeight))
Text(
text = "Edit Profile",
color = Gray,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 12.dp, vertical = 8.dp)
.border(1.dp, Gray, RoundedCornerShape(20.dp))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = lazyGridState,
userScrollEnabled = false,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
// modifier = Modifier.height( 1000.dp)
modifier = Modifier.heightIn(max = 1000.dp)
) {
}
You can also do it without setting userScrollEnabled to false by scrolling Column when a LazyGrid is scrolled with
val coroutineScope = rememberCoroutineScope()
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = -available.y
coroutineScope.launch {
if (scrollState.isScrollInProgress.not()) {
scrollState.scrollBy(delta)
}
}
return Offset.Zero
}
}
}
Result
And to optimize this further remove verticalScroll from Column and set LazyVerticalGrid height to screen height and add Text with edit profile as item of LazyGrid. Doing this will only recompose 6 items and will have better scroll experience than scrolling vertical scroll or LazyVerticalGrid.
By the way if implementation didn't rely on scrollState it could also be removed and values can be set inside onPreScroll function but in current implementation Header, Toolbar, ToolbarActions rely on vertical scroll state.
@Composable
fun TwitterCollapsingToolbar() {
val scrollState = rememberScrollState()
val titleHeight = remember { mutableStateOf(0f) }
val collapseRangePx = with(LocalDensity.current) { collapseRange.toPx() }
val avatarSizePx = with(LocalDensity.current) { avatarSize.toPx() }
val profileNameTopPaddingPx = with(LocalDensity.current) { paddingSmall.toPx() }
val paddingMediumPx = with(LocalDensity.current) { paddingMedium.toPx() }
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
val lazyGridState = rememberLazyGridState()
val collapseRangeReached = remember {
derivedStateOf {
scrollState.value >= (collapseRangePx)
}
}
val avatarZIndex = remember {
derivedStateOf {
if (collapseRangeReached.value)
0f
else
2f
}
}
val coroutineScope = rememberCoroutineScope()
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = -available.y
coroutineScope.launch {
if (scrollState.isScrollInProgress.not()) {
scrollState.scrollBy(delta)
}
}
return Offset.Zero
}
}
}
Scaffold(
) { padding ->
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.zIndex(0f)
.background(Color.Black)
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = lazyGridState,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.height(screenHeightDp)
) {
item(
span = {
GridItemSpan(maxLineSpan)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
// This layout is to match size to parent because
// inside LazyVerticalGrid we are constraint with horizontal
// padding
.layout { measurable: Measurable, constraints: Constraints ->
val placeable = measurable.measure(
constraints.copy(
minWidth = screenWidthDp.roundToPx(),
maxWidth = screenWidthDp.roundToPx()
)
)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
}
}
) {
Spacer(Modifier.height(headerHeight - 16.dp))
Text(
text = "Edit Profile",
color = Color.Gray,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 12.dp, vertical = 8.dp)
.border(1.dp, Color.Gray, RoundedCornerShape(20.dp))
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
item(span = {
GridItemSpan(maxLineSpan)
}) {
Text(
text = "Title",
modifier = Modifier
.padding(top = paddingSmall, start = paddingMedium)
.onGloballyPositioned {
titleHeight.value = it.size.height.toFloat()
}
)
}
item(span = {
GridItemSpan(maxLineSpan)
}) {
Text("Items")
}
items(20) { index ->
ItemListItem()
}
}
}
Header(scrollState, collapseRangePx, Modifier.zIndex(1f), collapseRangeReached)
Avatar(
scrollState,
collapseRangePx,
paddingMediumPx,
collapseRangeReached,
avatarZIndex
)
Toolbar(
scrollState,
collapseRangePx,
titleHeight,
avatarSizePx,
profileNameTopPaddingPx,
collapseRangeReached,
Modifier
.zIndex(3f)
.statusBarsPadding()
)
ToolbarActions(
Modifier
.zIndex(4f)
.statusBarsPadding()
)
}
}
}
Upvotes: 4