One Off the Slack: Stateful Rows in a LazyColumn()

With a RecyclerView, having rows with stateful widgets, like an EditText or a CheckBox, was a headache. With rows being recycled, you needed to keep track of the state as the user scrolled and those stateful widgets got reused for other rows. The good news is that Compose with LazyColumn() handles this case a lot better. However, it does require you to think through your state management.

Mohamed Aouled Issa ran into this, asking about the same concern both in Slack and in Stack Overflow. Mohamed had a LazyColumn() where each row was a Greeting() composable, with state in the Greeting():

@Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
   LazyColumn(modifier = modifier) {
       items(items = names) { name ->
           Greeting(name = name)
           Divider(color = Color.Black)
       }
   }
}
@Composable
fun Greeting(name: String) {
   var isSelected by remember { mutableStateOf(false) }
   val backgroundColor by animateColorAsState(if (isSelected) Color.Red else Color.Transparent)

   Text(
       text = "Hello $name!",
       modifier = Modifier
           .padding(24.dp)
           .background(color = backgroundColor)
           .clickable(onClick = { isSelected = !isSelected })
   )
}

As he wrote in Slack:

Just when you scroll until the item is no more visible, when you scroll back the isSelected state is lost. IMO this is wrong cuz the state should be cached anyway and when recomposing the compiler should consider the previous state.

I pointed out in a Stack Overflow comment that the state does not work the way that Mohamed thinks:

remember() remembers for an individual composition (i.e., a particular invocation of a composable), until that composition is disposed. The point behind โ€œLazyโ€ is that those compositions are not retained as they get scrolled well out of view, to minimize memory consumption.

And, when Mohamed asked about hoisting the state out of Greeting(), Googleโ€™s Jim Sproch wrote an evergreen Slack entry:

Hoisting state is never a bad idea ๐Ÿ˜‰

Jim later pointed out that Compose offers mutableStateMapOf(), which is a State-based edition of a MutableMap. Mohamed eventually used that to track the state of each row in NameList() rather than in Greeting():

@Composable
fun NameList(names: List<String>, modifier: Modifier = Modifier) {
    val selectedStates = remember {
        mutableStateMapOf<Int, Boolean>().apply {
            names.mapIndexed { index, _ ->
                index to false
            }.toMap().also {
                putAll(it)
            }
        }
    }
    LazyColumn(modifier = modifier) {
        itemsIndexed(items = names) { index, name ->
            Greeting(
                name = name,
                isSelected = selectedStates[index] == true,
                onSelected = {
                    selectedStates[index] = !it
                }
            )
            Divider(color = Color.Black)
        }
    }
}

The initialization of selectedStates probably could be simplified, to something like:

   val selectedStates = remember { 
      names.mapIndexed { index, _ -> index to false }.toMutableStateMap()
    }

Or, in this case, rather than a Map, you could use a List<Boolean> via toMutableStateList(), with the names indices mapping to state indices.

But, one way or another, you want the states to be outside the LazyColumn() and be applied to the items as they get composed based on user scroll gestures.


Read the original thread in the kotlinlang Slack workspace. Not a member? Join that Slack workspace here!