Open menu with table of contents Android Lists
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Android Lists

Stuttgart Media University

1 Agenda

  • Why lazy lists?
  • LazyColumn, LazyRow, LazyVerticalGrid
  • Describing the items: item, items, itemsIndexed
  • Stable item keys
  • Padding, spacing and scroll state
  • Clickable items
  • Observable lists: mutableStateListOf
  • Performance notes
  • Legacy: RecyclerView
  • Assignment 2

2 Why Lazy Lists?

  • A Column composes all of its children, whether they are visible or not. With 5 movies that is fine, with 5000 search results it is not (slow, memory)
  • A lazy list composes and lays out only the items that are visible on screen (plus a small buffer), items scrolled out of view are disposed again
  • Lazy lists are the Compose equivalent of the RecyclerView (see legacy slide), without the Adapter/ViewHolder boilerplate
// Every item is composed, the Column is just made scrollable
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    movies.forEach { movie -> MovieItem(movie = movie, onMovieClick = {}) }
}

// Only the visible items are composed
LazyColumn {
    items(items = movies) { movie -> MovieItem(movie = movie, onMovieClick = {}) }
}

Rule of thumb: a handful of fixed elements -> Column/Row; a list of data -> LazyColumn/LazyRow.

3 LazyColumn, LazyRow, LazyVerticalGrid

  • LazyColumn scrolls vertically, LazyRow horizontally
  • LazyVerticalGrid / LazyHorizontalGrid arrange the items in a grid, the number of columns is defined by GridCells
    • GridCells.Fixed(2): exactly two columns
    • GridCells.Adaptive(minSize = 128.dp): as many columns as fit, each at least 128 dp wide
  • All of them take a LazyListScope/LazyGridScope block instead of composable children
LazyRow {
    items(items = movies) { movie -> MoviePoster(movie = movie) }
}

LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
    items(items = movies) { movie -> MoviePoster(movie = movie) }
}

Documentation: Lists and grids

4 Describing the Items

The block of a lazy list is a small DSL (LazyListScope), not a composable:

LazyColumn {
    // a single item, e.g. a header
    item {
        Text(text = "Saved movies", style = MaterialTheme.typography.titleLarge)
    }

    // one item per element of a list
    items(items = movies) { movie ->
        MovieItem(movie = movie, onMovieClick = onMovieClick)
    }

    // the same, with the index
    itemsIndexed(items = movies) { index, movie ->
        Text(text = "${index + 1}. ${movie.title}")
    }

    // a fixed number of items
    items(count = 3) { index -> Text(text = "Placeholder $index") }
}
  • items(items = ...) for a List is an extension function: import androidx.compose.foundation.lazy.items
  • Several item/items calls can be mixed in one list (header, content, footer)

5 Stable Item Keys

By default an item is identified by its position. When the list changes (remove, insert, reorder) Compose cannot tell which item moved. A key gives every item a stable identity (MovieList.kt of assignment 5):

@Composable
fun MovieList(movieList: List<Movie>, onMovieClick: (Movie) -> Unit, modifier: Modifier = Modifier) {
    LazyColumn(modifier = modifier) {
        // The key helps Compose to reuse the items when the list changes
        items(items = movieList, key = { it.imdbId }) { movie ->
            MovieItem(movie = movie, onMovieClick = onMovieClick)
        }
    }
}

What the key buys:

  • remember/rememberSaveable state inside an item stays with that item, also after a removal above it
  • The scroll position stays on the same item when items are inserted above
  • Modifier.animateItem() can animate insertions, removals and moves

6 Stable Item Keys: Pitfalls

  • Keys must be unique within the list. Two items with the same key crash at runtime with IllegalArgumentException: Key "..." was already used
    • If a data source can deliver the same imdbId twice in one result (the OMDb API does): deduplicate the data (distinctBy { it.imdbId }) before it reaches the list
  • Keys must be saveable in a Bundle (String, Int, Long, enums, Parcelable), because Compose restores the item state after the Activity was recreated
  • Do not use the index as key, that is the default behaviour anyway
  • Assignment 2 and 3 use items(items = movieList) without key: the Movie data class has no unique id yet (two Bond movies could have the same title). Assignment 5 introduces the imdbId

7 Padding, Spacing and Scroll State

@Composable
fun MovieList(movieList: List<Movie>, onMovieClick: (Movie) -> Unit, modifier: Modifier = Modifier) {
    val listState = rememberLazyListState()

    LazyColumn(
        modifier = modifier,
        state = listState,
        contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items = movieList, key = { it.imdbId }) { movie ->
            MovieItem(movie = movie, onMovieClick = onMovieClick)
        }
    }
}
  • contentPadding pads the content, not the container: the first item gets the top padding, the last one the bottom padding, and the list still scrolls under the padding (unlike Modifier.padding)
  • Arrangement.spacedBy puts space between the items without a Spacer per item
  • rememberLazyListState exposes firstVisibleItemIndex and allows programmatic scrolling with listState.animateScrollToItem(0) (a suspend function, call it from a coroutine, lecture 10)

8 Clickable Items

The item composable from the solution of assignment 2 (MainActivity.kt):

@Composable
fun MovieItem(movie: Movie, onMovieClick: (Movie) -> Unit) {
    Column(modifier = Modifier
        .testTag("movieItem")
        .clickable { onMovieClick(movie) }) {
        Text(text = movie.title)
        Text(text = movie.year)
        Text(text = movie.actor)
        HorizontalDivider()
    }
}
  • Modifier.clickable { } makes any composable clickable (ripple, accessibility, focus)
  • The item does not decide what happens on click, it only reports the event upwards via the onMovieClick lambda: the list owns the data, so the list removes the movie (next slide)
  • Modifier.testTag("movieItem") is what the UI test of assignment 2 uses to find the items (onAllNodesWithTag("movieItem")[0])
  • Modifier order matters: testTag and clickable apply to the whole Column

9 Observable Lists: mutableStateListOf

Removing a movie on click (solution of assignment 2):

@Composable
fun MovieList(modifier: Modifier = Modifier) {
    val movieList = remember {
        mutableStateListOf<Movie>(
            Movie("Dr. No", "1962", "Sean Connery"),
            Movie("From Russia with Love", "1963", "Sean Connery"),
            Movie("Goldfinger", "1964", "Sean Connery")
        )
    }

    LazyColumn(modifier = modifier) {
        items(items = movieList) { movie: Movie ->
            MovieItem(movie = movie, onMovieClick = { currentMovie: Movie ->
                movieList.remove(currentMovie)
            })
        }
    }
}

10 mutableStateListOf vs. mutableListOf

  • Compose only recomposes when observable state changes. A mutableListOf() is a plain ArrayList: remove() changes its content, but nobody tells Compose, the screen stays as it is
  • mutableStateListOf() returns a SnapshotStateList, a MutableList that Compose observes: add, remove, clear, ... trigger recomposition of the LazyColumn
  • Wrap it in remember { }, otherwise a new list is created on every recomposition and the removal is lost
  • Alternative: an immutable List in mutableStateOf and replace the whole list: movies = movies - movie. This is what a ViewModel with StateFlow<List<Movie>> does later (lecture 11)
val a = remember { mutableListOf(1, 2, 3) }        // a.remove(2) -> no recomposition, UI stale
val b = remember { mutableStateListOf(1, 2, 3) }   // b.remove(2) -> LazyColumn recomposes
var c by remember { mutableStateOf(listOf(1, 2, 3)) }
c = c - 2                                          // new list assigned -> recomposition

Note: changing a property of a Movie inside the list is not observed either, replace the element instead.

11 Performance Notes

  • Provide stable, unique keys as soon as your items have an id
  • Keep the item lambda cheap: no sorting, filtering, formatting or image decoding inside items { }, prepare the data before (ViewModel, lecture 11) or use a side effect (lecture 10)
  • Give items a fixed size where possible (e.g. posters Modifier.size(80.dp)): 0-pixel items (an image before it has loaded) make the list compose far too many items at once
  • Use immutable item types: a data class with val properties is stable, Compose can skip recomposing an item whose Movie did not change. Classes Compose cannot prove stable can be annotated with @Stable/@Immutable
  • Never nest a lazy list in a scrollable parent with the same direction (LazyColumn inside Column(verticalScroll) throws), use item { } for headers and footers instead
  • contentType = { ... } lets Compose reuse compositions between items of the same type in heterogeneous lists

Details: Lists and grids - performance, Stability in Compose

12 Legacy: RecyclerView (View system)

  • The scrollable list of the XML/View world, you will meet it in every older code base
  • Three collaborating classes instead of one LazyColumn:
    • RecyclerView.Adapter creates item views and binds data to them (onCreateViewHolder, onBindViewHolder, getItemCount)
    • ViewHolder pattern: a holder caches the findViewById() results of one item view; scrolled-out views are recycled and re-bound with new data
    • LayoutManager decides the arrangement: LinearLayoutManager (vertical/horizontal), GridLayoutManager, StaggeredGridLayoutManager
  • Item layouts are separate XML files, click listeners are wired manually in the adapter
  • The lazy lists do the same recycling automatically, the adapter is replaced by the items { } lambda
  • Interop: an existing RecyclerView (or any View) can be shown in Compose with AndroidView (lecture 06)

40%

13 Assignment 2

Assignment 2: Introduction to LazyColumn

What you need from this lecture for it:

  • LazyColumn with items(items = movieList) { movie -> MovieItem(...) } to show the Bond movies
  • MovieItem: a Column with three Texts, Modifier.testTag("movieItem") and Modifier.clickable { onMovieClick(movie) }
  • The list is a mutableStateListOf<Movie>() in remember { }, so movieList.remove(movie) in the click handler recomposes the list
  • The item reports the click upwards with a lambda parameter, the list owns and changes the data

14 Summary

  • Lazy lists (LazyColumn, LazyRow, LazyVerticalGrid) compose only the visible items, a Column composes everything
  • The LazyListScope DSL describes the content with item, items and itemsIndexed
  • Stable, unique keys (key = { it.imdbId }) keep item state and scroll position correct when the list changes; duplicate keys crash
  • contentPadding, Arrangement.spacedBy and rememberLazyListState control layout and scrolling
  • Items get the data and a click lambda as parameters; Modifier.clickable reports the click, the owner of the list changes the data
  • Only mutableStateListOf (or a new List in mutableStateOf) triggers recomposition, a plain mutableListOf does not
  • RecyclerView + Adapter + ViewHolder is the legacy View-system equivalent

15 Recap Questions

  • Why is a LazyColumn preferable to a scrollable Column for a list of search results?
  • What is the difference between item { }, items(list) and itemsIndexed(list)?
  • What does a key in items() buy you, and what happens when two items share a key?
  • What is the difference between contentPadding and Modifier.padding on a LazyColumn?
  • Why does removing an element from a mutableListOf() not update the list on screen?
  • Why does MovieItem get an onMovieClick: (Movie) -> Unit parameter instead of removing the movie itself?
  • Which test tag does the UI test of assignment 2 look for, and where do you put it?
  • What are Adapter, ViewHolder and LayoutManager in a RecyclerView, and which part of a LazyColumn replaces them?

Questions?