4.
Building Lists with Jetpack Compose
Written by Tino Balint
In previous chapters, you learned about different elements in Compose and how to group and position them inside layouts to build complex UIs. Using that knowledge, you could potentially build any screen.
However, you’re missing some functionality that you’ll eventually need. What happens when you have to display more elements than you can fit on the screen? In that case, the elements are all composed, but the limited screen size prevents you from seeing all of them. There are even situations where you want to dynamically add an infinite number of new elements on the screen and still be able to see them all.
The solution to this problem is allowing your content to scroll, either vertically or horizontally. The traditional way of implementing this feature is to use ScrollView, which allows you to scroll content vertically. For horizontal scrolling, you use HorizontalScrollView. Both of them can have only one child view inside them, so to add multiple elements, you need to use a single layout that wraps those elements.
Jetpack Compose gives you a new way to achieve the same result — using scrollable and lazily composed containers.
In this chapter, you’ll learn how to make lists and grids in Jetpack Compose to help you fit all your content on the screen. You’ll learn how to show content that scrolls vertically or horizontally and how to build an alternative for the traditional RecyclerView using composable functions.
Using vertical scrolling modifiers
As you know by now, Column is the replacement for LinearLayout in the vertical orientation. In Jetpack Compose, you can use the same Column composable with extra modifiers that enable scrolling! Let’s see how to implement a simple scrolling Column.
To follow along with the code examples, open Android Studio and select Open an Existing Project. Then, navigate to 04-building-lists-with-jetpack-compose/projects and select the starter folder.
Once the project builds, you’ll see the following structure:
You’ll start off by building a vertically scrollable Column after which you’ll explore its horizontal counterpart. To do that, open ScrollingScreen.kt and you’ll see two composable functions — ScrollingScreen() and MyScrollingScreen():
@Composable
fun ScrollingScreen() {
MyScrollingScreen()
BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}
@Composable
fun MyScrollingScreen() {
//TODO add your code here
}
@Composable
fun BookImage(@DrawableRes imageResId: Int, @StringRes contentDescriptionResId: Int){
Image(
bitmap = ImageBitmap.imageResource(imageResId),
contentDescription = stringResource(contentDescriptionResId),
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(476.dp, 616.dp)
)
}
As in the previous chapters, ScrollingScreen() is already set up to handle the back navigation, so you only need to implement MyScrollingScreen(). There is also BookImage composable which is predefined. It creates an image of a book in a specific size with the image and content description passed as a parameter.
Change the code of MyScrollingScreen() to the following, and include the required imports with the help of Android Studio:
@Composable
fun MyScrollingScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
BookImage(R.drawable.advanced_architecture_android, R.string.advanced_architecture_android)
BookImage(R.drawable.kotlin_aprentice, R.string.kotlin_apprentice)
BookImage(R.drawable.kotlin_coroutines, R.string.kotlin_coroutines)
}
}
Here, you added three existing BookImage composables to the Column. You used existing drawable and string resources for the parameters. To make the Column scrollable, you called verticalScroll() , and passed in rememberScrollState(). This creates a scroll state based on the scroll configuration and handles the scroll behavior during the recomposition so that the position is not lost.
What happens here is that you’ll show a Column, a vertical list of items. But if the items are too large to show them all at once, it will be scrollable and you’ll be able to go through each item respectively.
Build and run the app, then select Scrolling from the navigation menu. You’ll see the three images, one below the other — but unfortunately, they don’t fit on the screen together. Luckily, you made the screen scrollable! :]
Scroll down to see the images that aren’t displayed yet.
Using a scrollable Column is very easy, but there is much more you can do with it. Let’s explore how it works.
Exploring the scrollable modifier
Look at its source code to see what a verticalScroll can do and how it works when you use it:
fun Modifier.verticalScroll(
state: ScrollState,
enabled: Boolean = true,
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
)
First, look at the function parameters. Some of them you already know, but there are a few important new ones:
-
scrollStateis the current state of the scroll. It determines the offset from the top and can also start or stop smooth scrolling and fling animations. -
enabledenables or disables scrolling. If it’s disabled, you can still programmatically scroll to a specific position using thestateproperty. But the user can’t use scrolling gestures. -
flingBehavioris used to perform a fling animation with a given velocity. -
reverseScrollingallows you to reverse the direction of the scroll. In other words, setting it totruelets you scroll up. Note that its default value isfalse.
It’s important to understand that verticalScroll() is a modifier. This means that you can make your custom composables scrollable as well, by applying it to their modifiers, if that suits your use case.
You applied vertical scrolling to a Column. If you want to apply horizontal scrolling, you use a Row instead.
Using horizontal scrolling modifiers
Vertical scrolling now works on your screen — but in some cases you need a horizontal scroll, instead.
Just as you had to use a different component for horizontal scrolling called HorizontalScrollView, Jetpack Compose offers its own composable called Row, but you need to set the modifier . To achieve horizontal scroll, you need to apply horizontalScroll(), which works the same as verticalScroll() but in a different direction.
Let’s implement a scrollable Row. Inside MyScrollingScreen(), replace the Column with a Rowand verticalScroll with a horizontalScroll:
@Composable
fun MyScrollingScreen(modifier: Modifier = Modifier) {
Row(modifier = modifier.horizontalScroll(rememberScrollState())) { // here
...
}
}
You don’t have to do anything else! The scrollable Row is almost identical to the scrollable Column in terms of the default behavior. It sets up the horizontal scroll automatically, using horizontalScroll().
Build and run the app and then select Scrolling again in the navigation menu. You’ll still see the same three images, but now, the scroll works horizontally. And you accomplished this by changing just one line of code!
Scrollable columns and rows are great when you have static content, like in the previous examples. However, they aren’t a good idea for data collections that change at runtime. That’s because scrollable composables compose and render all the elements inside eagerly, which can be a heavy operation when you have a large number of elements to display.
In such cases, as you know from the traditional View system, you’d use a RecyclerView to optimize the loading and rendering of the visible elements on the screen. But how does Jetpack Compose deal with this issue? Let’s find out! :]
Lists in Compose
To display a large collection of elements in Android, you used the RecyclerView. The only elements RecyclerView renders are the ones visible on the screen. Only after the user begins to scroll does it render the new elements and display them on screen. It then recycles the elements that go off the screen into a pool of view holders.
When you scroll back to see the previous elements, it renders them from the pool. Thanks to this behavior, re-rendering is so quick that it’s almost as if the elements were never removed from the screen in the first place. This optimization mechanism gives RecyclerView its name.
Loading data only when it’s needed is called lazy loading and Jetpack Compose doubles down on this method to handle lists. The main two components you use for lazy lists in Compose are the LazyColumn and LazyRow.
Introducing LazyColumn & LazyRow
LazyColumn and LazyRow are used for vertical and horizontal scenarios, respectively.
RecyclerView uses a LayoutManager to set its orientation, but Jetpack Compose doesn’t have LayoutManagers. Instead, you use two different composable functions to change the orientation. The composables work in almost the same way as RecyclerView, but without needing to recycle.
When you use LazyColumn or LazyRow, the framework composes only the elements that it should show on the screen. When you scroll, new elements are composed and the old ones are disposed of. When you scroll back, the old elements are recomposed. Jetpack Compose doesn’t need a recycled ViewHolder pool because its recomposition handles caching more efficiently.
Let’s implement both vertical and horizontal lists to categorize the books you showed earlier.
Creating lists with LazyColumn & LazyRow
There are many awesome books in our raywenderlich.com library and in different categories. It’s best to show them all categorized, so you can easily pick and choose your favorites.
To do this, you’ll build a screen with a vertical list, where each composable item inside the list is another horizontal list. You’ll split the vertical list into book categories and each book category will have a horizontal list of books that belong there. Look at the image below to get a better understanding:
You can see the list of book categories scrolls vertically, while the categories themselves contain books that scroll horizontally. Your task is to duplicate that implementation, except with a dynamic number of categories and books. That way, as write more books, you can just add them to the list!
Now, open ListsScreen.kt. This file contains a predefined property named items with a list of book categories. That’s the data you’ll display on the screen. At the bottom of the file, you’ll find the following composable functions:
@Composable
fun ListScreen() {
MyList()
BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}
@Composable
fun MyList() {
//TODO add your code here
}
@Composable
fun ListItem(bookCategory: BookCategory, modifier: Modifier = Modifier) {
//TODO add your code here
}
ListsScreen() is a provided composable that handles the navigation for you, so you don’t need to worry about it. Your task is to implement MyList() and the ListItem().
Add the following code inside MyList() and include the required imports from the androidx.compose.material package for Text composable and androidx.compose.foundation for other composables:
@Composable
fun MyList() {
LazyColumn {
items(items) { item -> ListItem(item) }
}
}
Here, you added a LazyColumn() and set the items parameter with the items property containing your data. items is a list of objects of the BookCategory type. Each BookCategory contains a String with the category name and a list of images showing the books that should appear in that category.
Within the trailing lambda, for each item parameter inside the list of items, you create a new ListItem. This lambda represents the function to transform each of the objects within items to a list of composable elements.
This way you can call any number of composable functions to represent your items and you can add special logic depending on the item type, its position and more!
Next, you’ll implement ListItem(). Replace ListItem() with the following code and, once again, don’t forget to include the required imports with the help of Android Studio:
@Composable
fun ListItem(bookCategory: BookCategory, modifier: Modifier = Modifier) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = stringResource(bookCategory.categoryResourceId),
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = colorResource(id = R.color.colorPrimary)
)
Spacer(modifier = modifier.height(8.dp))
// TODO
}
}
This looks like a lot of code, but what it does is quite simple. First, you added a Column() as the parent layout of the composable so you can align its children vertically. The Column() uses a padding modifier to add some space near the borders.
The top child of Column() is a Text(). You need this to display the title of the category, which is passed as the text argument. Note how you styled the text by changing the font size, weight and color.
The next element is a Spacer, which adds some space between the category name and the rest of the content. This will let you show the category name on top of the horizontal list of books.
Now add the following code underneath the Spacer, to add the horizontal list of books:
LazyRow {
items(bookCategory.bookImageResources) { items ->
BookImage(items)
}
}
Similar to how you built a vertical list, using a LazyRow you create a horizontal list. It receives the list of book images as a parameter and a lambda that builds BookImages. Also add the BookImage() in a separate function:
@Composable
fun BookImage(imageResource: Int) {
Image(
modifier = Modifier.size(170.dp, 200.dp),
painter = painterResource(id = imageResource),
contentScale = ContentScale.Fit,
contentDescription = stringResource(R.string.book_image)
)
}
A BookImage is a wrapper for an Image composable. Image() displays the book image for each element in the list. You used a size modifier to set a static size of 170dp width and 200dp height.
Since the list you passed as an argument to LazyRow() contains resource IDs instead of the actual images, you need to use painterResource() to retrieve the correct asset. Finally, by using ContentScale.Fit, you make the image adapt to the size you specified earlier and set the content description with the provided string.
Now, build and run the app. Once the main screen loads, click the List button in the navigation menu. Your app will show the following screen:
As you see, the books are sorted by category. You can scroll vertically to browse book categories and horizontally to browse books in each category.
By the way, if you’re interested in any of the books you see, you can find them in our book library! :]
Lists are very easy to use and understand, especially because their signature requires only a few parameters to make them work. Let’s dive a bit deeper into their implementation.
Exploring Lists
Now that you understand the difference and how to implement specific lists, take a look at the signature for LazyColumn and LazyRow:
@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
content: LazyListScope.() -> Unit
)
@Composable
fun LazyRow(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
horizontalArrangement: Arrangement.Horizontal =
if (!reverseLayout) Arrangement.Start else Arrangement.End,
verticalAlignment: Alignment.Vertical = Alignment.Top,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
content: LazyListScope.() -> Unit
)
The most important parameter to notice here is content which is used for the content inside the list. This content is of a LazyListScope type and not your usual Composable type.
Take a look at the LazyListScope interface to learn why is it so important.
interface LazyListScope {
fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)
fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)
@ExperimentalFoundationApi
fun stickyHeader(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)
}
The interface provides a set of functions which help you when building lists:
-
items()allows you to set a list of item data you would like to use in each of your list items. Once you set the data, you also need to provide anitemContentwhich is a composable used for displaying every item in your list. -
item()allows you to add a new composable item to your list. Note that you can use different composable types every time. -
stickyHeader()allows you to set the header composable that will remain visible on the top of the list, even after you scroll down to see new items. Note that this function is annotated with@ExperimentalFoundationApiwhich means that it’s still in experimental stage and might change or be removed in the future.
Unlike the RecyclerView, lists in Jetpack Compose don’t require an adapter, view holder layout managers and an RV element in your XML files just to make it work. Using one of the two very simple functions, you can either show a horizontal or a vertical list that is performant and customizable!
There are also extension functions like itemsIndexed, which has same features as items() but also provides you with an index for each of your items.
That’s all for the theory. So far you’ve implemented simple lists and a list of horizontal lists for your books. The last thing you need to learn how to do is build grids.
Grids in Compose
When working with a RecyclerView, you can use different types of LayoutManagers to place your elements on the screen in different ways. To make grids, for example, you use a GridLayoutManager and then set the number of columns inside the grid.
Unfortunately, Jetpack Compose doesn’t include a ready-to-use, stable, component to accomplish the same thing. However, thanks to the power of Compose, building your own component isn’t hard. You’ll see how to do this step-by-step in this section.
The grid you’ll implement resembles what you saw in the last list example. This time, however, the elements won’t scroll horizontally but will be fixed in place, instead. To better visualize the problem, look at the following image:
As you see, your grid contains ten elements distributed across three columns. The last row shows only one element in the first column, because that’s the last element in your list. There are two more elements next to it, but they’re marked as invisible in the image. That’s a little trick to position the last element properly in the first column — you add invisible elements to occupy the rest of the space. Otherwise, the last element would be in the center of the row.
There are the basic requirements of the grid, but let’s dive into the code to make your own grid.
Implementing a grid
Open GridScreen.kt and take a moment to look inside. You’ll find the usual function to handle the navigation and a list containing the icons that you’ll use as the grid’s content. At the bottom of the file, you’ll find the following composable functions that you need to implement:
@Composable
fun GridView(columnCount: Int) {
//TODO add your code here
}
@Composable
fun RowItem(rowItems: List<IconResource>) {
//TODO add your code here
}
@Composable
fun RowScope.GridIcon(iconResource: IconResource) {
//TODO add your code here
}
Implementing GridView
First, you’ll deal with GridView(). This composable takes a parameter named columnCount, which determines the maximum number of elements you need to place in each row.
Add the following code to the body of GridView:
@Composable
fun GridView(columnCount: Int) {
val itemSize = items.size
val rowCount = ceil(itemSize.toFloat() / columnCount).toInt()
val gridItems = mutableListOf<List<IconResource>>()
var position = 0
}
To fill the grid, you use the prepared list of icons called items. First, you store the item size, because you’ll use it multiple times.
You then calculate the number of rows you need to display the items. You get this value by dividing the number of items by the column count and using ceil() to ensure that you include the last row, even if it isn’t full. Now add the next piece of code start building a grid:
@Composable
fun GridView(columnCount: Int) {
...
for (i in 0 until rowCount) {
val rowItem = mutableListOf<IconResource>()
for (j in 0 until columnCount) {
if (position.inc() <= itemSize) {
rowItem.add(IconResource(items[position++], true))
}
}
// TODO
}
Next, for each row, you create a list of items that hold an IconResource. This is a model class that contains an icon resource and holds a Boolean property to set the icon’s visibility.
All items added inside the row this way are set to visible by passing in true as the second constructor parameter. Because grids have rows and columns, you need to use a nested for loop to prepare all the items. The next step is to add empty dummy views and finally build the list:
@Composable
fun GridView(columnCount: Int) {
...
for (i in 0 until rowCount) {
val rowItem = mutableListOf<IconResource>()
for (j in 0 until columnCount) {
if (position.inc() <= itemSize) {
rowItem.add(IconResource(items[position++], true))
}
}
// here
val itemsToFill = columnCount - rowItem.size
for (j in 0 until itemsToFill) {
rowItem.add(IconResource(Icons.Filled.Delete, false))
}
gridItems.add(rowItem)
}
// here
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(gridItems) { items ->
RowItem(items)
}
}
}
You calculate if there’s a need to include dummy invisible items by subtracting the current row size from the required columnCount. If columnCount is larger than rowItem.size it means you’re in the last row and it isn’t full. In that case, you add dummy icons along with the isVisible property as false, to make them invisible.
Finally, you use a LazyColumn, passing the rows that you calculated in gridItems.
RowItem() is a composable that renders each row inside the column. Implementing this is your next task. :]
Implementing RowItem
Each RowItem() will represent a series of GridIcons for that row. Replace the code of the RowItem() with the following:
@Composable
fun RowItem(rowItems: List<IconResource>) {
Row {
for (element in rowItems)
GridIcon(element)
}
}
Here, you use a Row to lay out the different items within a given row. Each item is then a GridIcon, which you’ll implement next.
Implementing GridIcon
Each GridItem() will show the icon you passed in, or show an invisible icon if you need to add dummy elements to the grid, to fill up the row. Replace the GridIcon with the following code to achieve such behavior:
@Composable
fun RowScope.GridIcon(iconResource: IconResource) {
val color = if (iconResource.isVisible)
colorResource(R.color.colorPrimary)
else Color.Transparent
Icon(
imageVector = iconResource.imageVector,
tint = color,
contentDescription = stringResource(R.string.grid_icon),
modifier = Modifier
.size(80.dp, 80.dp)
.weight(1f)
)
}
Here’s a breakdown of the previous code block. First, you calculated the color of the icon using the visibility property. Since Jetpack Compose doesn’t have an option to set a composable to invisible, you’ll achieve this result by using a transparent color.
Next, you add the calculated color as a tint to the Icon and set the size and weight modifiers. To use the weight modifier, Compose needs a Scope, which you get from the Row parent of GridIcon by making the GridIcon an extension function of the RowScope, you get to use all the members from the RowScope, such as weight(). weight() is important to spread the icons evenly between other icons inside a Row().
Build and run the app, then click the Grid button in the navigation menu. You’ll see the following screen:
Awesome! You have a grid of icons on the screen, placed in three columns. You can increase the number of icons inside the items list to make the grid scrollable. To experiment with different column counts, replace the value of columnCount inside GridScreen() with the desired value to see the result. Keep in mind that you’re limited to the number of columns that fit the screen.
There is also a built in-composable for grids called LazyVerticalGrid which is under @ExperimentalFoundationApi. This means that the composable will likely drastically change or be removed in the future. If you are still willing to try it out, replace the code inside the GridScreen with the following:
@ExperimentalFoundationApi
@Composable
fun GridScreen() {
LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
cells = GridCells.Fixed(3),
content = {
items(items) { item ->
GridIcon(IconResource(item, true))
}
}
)
BackButtonHandler {
JetFundamentalsRouter.navigateTo(Screen.Navigation)
}
}
Also add a GridIcon() counterpart, that isn’t an extension function:
@Composable
fun GridIcon(iconResource: IconResource) {
val color = if (iconResource.isVisible)
colorResource(R.color.colorPrimary)
else Color.Transparent
Icon(
imageVector = iconResource.imageVector,
tint = color,
contentDescription = stringResource(R.string.grid_icon),
modifier = Modifier
.size(80.dp, 80.dp)
)
}
Build and run the app, then click the Grid button in the navigation menu. You’ll see the same result as with the custom version.
You added a LazyVerticalGrid with three parameters: modifier, cells and content. Cells describes how columns form. There are two types of GridCells:
- Fixed sets the fixed amount of cells on the screen.
-
Adaptive adds as many rows or columns as possible to fit the screen with the provided
minSizeas the minimum size parameter.
The content works the same as with LazyRow or LazyColumn. You provide the collection of data and a composable which is used for every grid cell. LazyVerticalGrid then calculates and positions your composable in a grid depending on the cells parameter.
Congratulations! You’ve learned a lot about how to lay out large numbers of elements in Jetpack Compose.
Key points
- Use
Columnwith theverticalScrollmodifier to make the content vertically if it doesn’t fit the screen. - Use
Rowwith thehorizontalScrollmodifier to make the content scroll horizontally if it doesn’t fit the screen. - You can make your own composables scrollable by adding the
verticalScrollorhorizontalScrollmodifiers. - Use scrollers only for a fixed amount of content.
- For dynamic and larger amounts of content, use lists instead.
- The composable alternatives to
RecyclerVieware calledLazyColumnandLazyRowfor the vertical and horizontal scenarios, respectively. - You can group lists inside each other to make content scrollable in both directions.
- To make grids, use a custom implementation.
- If you use
LazyVerticalGrid, keep in mind that it might soon be changed or removed. - Use a transparent color or set an alpha to zero to make an invisible composable.
- Alternatively, you can use
LazyRowandLazyColumncomponents if you want to manually add items to the list, allowing you to build headers and footers. Learn more about them here: https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/package-summary#lazycolumn.
Where to go from here?
In this chapter, you learned how to make scrollable content, scrollable lists for dynamically created elements and custom grids.
You’re ready to implement this UI functionality in your own apps. This wrapped up the entire first section! In the next section and the next chapter, you’ll learn how to build more complex custom composables using all the knowledge you’ve gained so far.
See you there! :]