Jetpack Compose increases developer productivity in views, especially when it comes to RecyclerView and Adapter. We only need to
define a composable (LazyRow/LazyColumn),
set the items and components for each item,
… yeah, that’s all!
There are other composable such as Row and Column too. In this article, we’ll see some use cases of Row/Column, LazyRow/LazyColumn, and how to handle the scroll action inside them.
Disclaimer: All source code exposed here has been developed with artifact December 2022 release of Compose Bom. Please note that some source code may change with new versions.
Row/Column
Row
/Column
can be used to arrange some views with a specific orientation (horizontal or vertical). In other words, we can treat Row/Column like LinearLayout
in XML. The difference is scroll can be set directly to Row/Column modifier using verticalScroll
or horizontalScroll.
@Composable | |
fun PreviewScreen( | |
screenName: String, | |
modifier: Modifier = Modifier | |
) { | |
Surface(modifier = modifier.fillMaxSize()) { | |
Column(modifier = modifier.verticalScroll()) { | |
PreviewAnimation() | |
Text( | |
text = stringResource(id = R.string.preview_of_page, screenName), | |
style = MaterialTheme.typography.titleLarge | |
) | |
Text( | |
text = stringResource(id = R.string.preview_page_description, screenName), | |
style = MaterialTheme.typography.bodyLarge | |
) | |
} | |
} | |
} |
LazyRow/LazyColumn
Compose provides LazyRow
/LazyColumn
to replace RecyclerView in XML. Use these components when we need to:
- Display items with unknown/large sizes, with
items()
API inside LazyRow/LazyColumn (LazyListScope
). - Only compose and lay out items that are visible in the component’s viewport (same principle as RecyclerView).
LazyColumn { | |
items(posts) { post -> | |
PostCard(post) | |
} | |
} |
Nested Scroll with Nested Items
Let’s take an example: Instagram home screen. The screen is scrollable with infinite posts. There’s also a horizontal scroll in the story section.
Typically, first, we will add a Column
to wrap the story and post section as one vertical scroll. Then, use LazyRow
and LazyColumn
for each section as below.
@Composable | |
fun HomeScreenWrong(modifier: Modifier = Modifier) { | |
val scrollState = rememberScrollState() | |
val context = LocalContext.current | |
Surface(modifier = modifier.fillMaxWidth()) { | |
Column(modifier = Modifier.verticalScroll(scrollState)) { | |
LazyRow(modifier = modifier.fillMaxWidth()) { | |
items(stories) { story -> | |
StoryThumbnail(story = story) | |
} | |
} | |
LazyColumn(modifier = modifier.fillMaxWidth()) { | |
items(posts) { post -> | |
PostCard( | |
post = safePost, | |
postCardListener = getPostCardListener(context), | |
modifier = Modifier.padding(vertical = 8.dp)) | |
} | |
} | |
} | |
} | |
} |
If we try to preview the results, an error from Android Studio will be shown, which prevents us from creating nested scrolls with the same orientation without fixed height/width. By default view system (XML), it’s similar to adding RecyclerView
inside a NestedScrollView
. It may hurt app performance since the recycler’s ability is omitted when it’s inside a nested scroll.
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a header before the list of items please add a header as a separate item() before the main items() inside the LazyColumn scope. There are could be other reasons for this to happen: your ComposeView was added into a LinearLayout with some weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a custom layout. Please try to remove the source of infinite constraints in the hierarchy above the scrolling container. at androidx.compose.foundation.CheckScrollableContainerConstraintsKt.checkScrollableContainerConstraints-K40F9xA(CheckScrollableContainerConstraints.kt:35) at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke-0kLqBqw(LazyList.kt:192) at androidx.compose.foundation.lazy.LazyListKt$rememberLazyListMeasurePolicy$1$1.invoke(LazyList.kt:191) at androidx.compose.foundation.lazy.layout.LazyLayoutKt$LazyLayout$1$2$1.invoke-0kLqBqw(LazyLayout.kt:71) .....
To achieve nested scroll, instead of creating a new LazyColumn inside a Column, we should directly wrap all the composable inside parent LazyColumn
as below. The concept is similar to ConcatAdapter.
@Composable | |
fun HomeScreen(modifier: Modifier = Modifier) { | |
val scrollState = rememberScrollState() | |
val context = LocalContext.current | |
Surface(modifier = modifier.fillMaxWidth()) { | |
LazyColumn(modifier = Modifier.verticalScroll(scrollState)) { | |
// LazyRow still able to be use, since it has different orientation with parent LazyColumn | |
item { | |
LazyRow(modifier = modifier.fillMaxWidth()) { | |
items(getStories()) { story -> | |
StoryThumbnail(story = story) | |
} | |
} | |
} | |
items(getPosts()) { post -> | |
PostCard( | |
post = post, | |
postCardListener = getPostCardListener(context), | |
modifier = Modifier.padding(vertical = 8.dp)) | |
} | |
} | |
} | |
} |
And the result is… Tadaaa! The nested scrolls work both horizontally and vertically.
Last, since the use case is common, let’s create a general composable component to make it reusable. The complete source code of the results can be shown below.
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.LazyListScope | |
import androidx.compose.foundation.lazy.items | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
data class ChildLayout( | |
val contentType: String = "", | |
val content: @Composable (item: Any?) -> Unit = {}, | |
val items: List<Any> = emptyList() | |
) | |
@Composable | |
fun VerticalScrollLayout( | |
modifier: Modifier = Modifier, | |
vararg childLayouts: ChildLayout | |
) { | |
LazyColumn(modifier = modifier.fillMaxSize()) { | |
childLayouts.forEach { child -> | |
if (child.items.isEmpty()) { | |
loadItem(child) | |
} else { | |
loadItems(child) | |
} | |
} | |
} | |
} | |
/** | |
* Use single item compose if no scroll or only horizontal scroll needed | |
*/ | |
private fun LazyListScope.loadItem(childLayout: ChildLayout) { | |
item(contentType = childLayout.contentType) { | |
childLayout.content(null) | |
} | |
} | |
/** | |
* Use load multiple items to the lazy column when nested vertical scroll is needed | |
*/ | |
private fun LazyListScope.loadItems(childLayout: ChildLayout) { | |
items(items = childLayout.items) { item -> | |
childLayout.content(item) | |
} | |
} | |
/** | |
* Compose items only if general item is successfully casted to defined class | |
*/ | |
@Suppress("UNCHECKED_CAST") | |
@Composable | |
fun <T: Any> LoadItemAfterSafeCast( | |
generalItem: Any?, | |
composeWithSafeItem: @Composable (item: T) -> Unit | |
) { | |
(generalItem as? T)?.let { safeItem -> | |
composeWithSafeItem(safeItem) | |
} | |
} |
import android.content.Context | |
import android.widget.Toast | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.Divider | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Surface | |
import androidx.compose.material3.surfaceColorAtElevation | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import androidx.navigation.NavController | |
import com.syntia.instagramcompose.domain.model.Post | |
import com.syntia.instagramcompose.domain.model.Story | |
import com.syntia.instagramcompose.domain.model.StoryType | |
import com.syntia.instagramcompose.ui.component.layout.ChildLayout | |
import com.syntia.instagramcompose.ui.component.layout.VerticalScrollLayout | |
import com.syntia.instagramcompose.ui.navigation.navigateToSingleProfile | |
import com.syntia.instagramcompose.ui.theme.InstagramComposeTheme | |
import com.syntia.instagramcompose.ui.view.component.post.card.PostCard | |
import com.syntia.instagramcompose.ui.view.component.post.card.listener.PostCardListener | |
import com.syntia.instagramcompose.ui.view.component.story.StoriesSection | |
import com.syntia.instagramcompose.util.LoadItemAfterSafeCast | |
/** | |
* Usages | |
*/ | |
@Composable | |
fun HomeScreen( | |
modifier: Modifier = Modifier | |
) { | |
val context = LocalContext.current | |
Surface(modifier = modifier.fillMaxWidth()) { | |
VerticalScrollLayout( | |
modifier = Modifier, | |
ChildLayout( | |
contentType = HomeScreenContents.STORIES_SECTION, | |
content = { | |
StoriesSection( | |
stories = getStories(), | |
modifier = Modifier.padding( | |
start = 16.dp, | |
top = 16.dp, | |
bottom = 16.dp, | |
end = 0.dp | |
) | |
) | |
} | |
), | |
ChildLayout( | |
contentType = HomeScreenContents.DIVIDER, | |
content = { | |
Divider( | |
color = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), | |
thickness = 1.dp) | |
} | |
), | |
ChildLayout( | |
contentType = HomeScreenContents.POST_CARDS, | |
items = getPosts(), | |
content = { item -> | |
LoadItemAfterSafeCast<Post>(item) { safePost -> | |
PostCard( | |
post = safePost, | |
postCardListener = getPostCardListener(context, navController), | |
modifier = Modifier.padding(vertical = 8.dp)) | |
} | |
} | |
) | |
) | |
} | |
} |
Happy coding.
This article was originally published on proandroiddev.com