Jetpack Compose lazy column all items recomposes when a single item update
S

2

13

I'm trying to do some list operations and I've run into the issue of all items recomposing when a single item update.

https://prnt.sc/8_OAi1Krn-qg

My models;

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

@Stable
data class PersonsWrapper(val persons: List<Person>)

My ViewModel and update function;

private val initialList = listOf(
    Person(id = 0, name = "Name0"),
    Person(id = 1, name = "Name1"),
    Person(id = 2, name = "Name2"),
    Person(id = 3, name = "Name3"),
    Person(id = 4, name = "Name4"),
    Person(id = 5, name = "Name5"),
    Person(id = 6, name = "Name6"),
)

val list = mutableStateOf(PersonsWrapper(initialList))

fun updateItemSelection(id: Int) {
    val newList = list.value.persons.map {
        if (it.id == id) {
            it.copy(isSelected = !it.isSelected)
        } else {
            it
        }
    }
    list.value = list.value.copy(persons = newList)
}

and my composable function ;

@Composable
fun ListScreen(personsWrapper: PersonsWrapper, onItemClick: (Int) -> Unit) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(2.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        items(personsWrapper.persons, key = { it.id }) {
            ListItem(item = it, onItemClick = onItemClick)
        }
    }
}

All model classes seem stable in compose_reports;

stable class Person {
  stable val id: Int
  stable val name: String
  stable val isSelected: Boolean
  <runtime stability> = Stable
}
stable class PersonsWrapper {
  unstable val persons: List<Person>
}

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListScreen(
  stable personsWrapper: PersonsWrapper
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListItem(
  stable item: Person
  stable onItemClick: Function1<Int, Unit>
)

When I want to change the selected status of a single item in the list, the entire list is recomposed. I also tried this with ImmutableList and Persistant list from kotlinx.collections. But problem is not solved.

How to avoid uneccessary recomposition when list operations?

Selfpropulsion answered 6/12, 2022 at 7:59 Comment(0)
F
27

MutableState works using structural equality which check if you update state.value with new instance. You are creating a new instance of your list on each time you select a new item.

You can use SnapshotStateList which triggers recomposition when you add, delete or update existing item with new instance. SnapshotStateList is a List which gets item with time O(1) complexity for getting an item with item[index] instead of iterating whole list with O(n) in worst case.

Using mutableStateListOf only

Result is only single item gets recomposed.

enter image description here

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

You can update your ViewModel with SnapshotState list as

class MyViewModel : ViewModel() {

    private val initialList = listOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

    val people = mutableStateListOf<Person>().apply {
        addAll(initialList)
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
    }
}

ListItem composable

@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {
    Column(
        modifier = Modifier.border(3.dp, randomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

Your list

@Composable
fun ListScreen(people: List<Person>, onItemClick: (Int) -> Unit) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(2.dp),
        modifier = Modifier.fillMaxSize()
    ) {

        items(items = people, key = { it.hashCode() }) {

            ListItem(item = it, onItemClick = onItemClick)
        }
    }
}

The code i use for visually checking recomposition

fun randomColor() = Color(
    Random.nextInt(256),
    Random.nextInt(256),
    Random.nextInt(256),
    alpha = 255
)

With ViewState

Result

enter image description here

sealed class ViewState {
    object Loading : ViewState()
    data class Success(val data: List<Person>) : ViewState()
}

And update ViewModel as

class MyViewModel : ViewModel() {

    private val initialList = listOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

    private val people: SnapshotStateList<Person> = mutableStateListOf<Person>()

    var viewState by mutableStateOf<ViewState>(ViewState.Loading)
        private set

    init {
        viewModelScope.launch {
            delay(1000)
            people.addAll(initialList)
            viewState = ViewState.Success(people)
        }
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
        viewState = ViewState.Success(people)
    }
}

1000 ms and delay is for demonstration. In real app you will get data from REST or db.

Screen that displays list or Loading using ViewState

@Composable
fun ListScreen(
    viewModel: MyViewModel,
    onItemClick: (Int) -> Unit
) {

    val state = viewModel.viewState
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        when (state) {
            is ViewState.Success -> {

                val people = state.data
                LazyColumn(
                    verticalArrangement = Arrangement.spacedBy(2.dp),
                    modifier = Modifier.fillMaxSize()
                ) {
                    items(items = people, key = { it.id }) {
                        ListItem(item = it, onItemClick = onItemClick)
                    }
                }
            }

            else -> {
                CircularProgressIndicator()
            }
        }
    }
}

Stability Edit

First of all when you scroll items that out of Viewport and they are back in Viewport they are recomposed, that's how LazyColumn works and that's why it recomposes less items unlike Column with vertical scroll. It recomposes items that are visible and the one in direction of scroll.

To show that if you implement your code like above items won't recomposed unless there is Stability issue with items in your implementation.

If you don't see anything inside SideEffect that functions is definitely not recomposing no matter what Layout Inspector shows. Also a Composable can't skip recomposition when we call a new Modifier via Modifier.background(getRandomColor) on Text composables, so if there is no visual change it's not recomposed.

The Composables below return stability as

restartable scheme("[androidx.compose.ui.UiComposable]") fun MainScreen(
  unstable viewModel: MyViewModel
)
restartable scheme("[androidx.compose.ui.UiComposable]") fun ListScreen(
  unstable people: List<Person>
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ListItem(
  stable item: Person
  stable onItemClick: Function1<Int, Unit>
)
restartable skippable scheme("[0, [0]]") fun StabilityTestTheme(
  stable darkTheme: Boolean = @dynamic isSystemInDarkTheme($composer, 0)
  stable dynamicColor: Boolean = @static true
  stable content: Function2<Composer, Int, Unit>
)

Note: This is a restartable and skippable Composable, if your list item is recomposing make sure that inputs of your Composable are stable.

@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {

    SideEffect {
        println("Recomposing ${item.id}, selected: ${item.isSelected}")
    }

    Column(
        modifier = Modifier.border(3.dp, getRandomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

ListScreen Composable is unstable because of people: List<Person> but it only recomposes when MainScreen is recomposed.

@Composable
fun ListScreen(
    people: List<Person>,
    onItemClick: (Int) -> Unit
) {

    SideEffect {
        println("ListScreen is recomposing...$people")
    }

    Column {
        Text(
            text = "Header",
            modifier = Modifier.border(2.dp, getRandomColor()),
            fontSize = 30.sp
        )
        Spacer(modifier = Modifier.height(20.dp))
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
            modifier = Modifier
                .fillMaxSize()
                .border(3.dp, getRandomColor(), RoundedCornerShape(8.dp))
        ) {
            items(
                items = people,
                key = { it.hashCode() }
            ) {
                ListItem(item = it, onItemClick = onItemClick)
            }
        }
    }
}

And added a Button to schedule recomposition to show that ListScreen is recomposed when recomposition is triggered in MainScreenScope

@Composable
fun MainScreen(
    viewModel: MyViewModel
) {

    var counter by remember {
        mutableStateOf(0)
    }
    Column {
        val people = viewModel.people

        Text(text = "Counter $counter")

        Button(onClick = { counter++ }) {
            Text(text = "Increase Counter")
        }

        Spacer(modifier = Modifier.height(40.dp))

        ListScreen(
            people = people,
            onItemClick = {
                viewModel.toggleSelection(it)
            }
        )
    }
}

You should be able to see in layout inspector that clicking any item skips others but clicking Button recomposes ListScreen and header.

If you scroll down and up you will see that items re-enter composition as expected.

As you can see in gif

  1. Clicking any item does triggers recomposition only for that item
  2. Clicking Button triggers recomposition for every ListItem
  3. Clicking Button triggers recomposition for ListScreen

enter image description here

Second issue happens as you can see above ViewModel was unstable and calling

viewModel.toggle() or viewModel::toggle is unstable.

stability also applies to lambdas or callbacks as you can test in this sample

https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter4_state/Tutorial4_2_7LambdaRecomposition.kt

You can save this lambda inside remember

val onClick = remember {
    { index: Int ->
        viewModel.toggleSelection(index)
    }
}

And call ListScreen as

ListScreen(
    people = people,
    onItemClick = onClick
)

Now you will see that any composition triggered in MainScreen only Text(header) and ListScreen will be composed not the ListItems.

enter image description here

Last part is making ListScreen stable. If you change

@Composable
fun ListScreen(
    people: List<Person>,
    onItemClick: (Int) -> Unit
) 

to

@Composable
fun ListScreen(
    people: SnapshotStateList<Person>,
    onItemClick: (Int) -> Unit
) 

You can also refer this answer

Preventing unnecessary recompositions on list updates in Jetpack Compose

nothing will be recomposed when Button or in your case it might something else triggering recomposition.

enter image description here

And full demo if you wish to test it.

class MainActivity : ComponentActivity() {

    private val mainViewModel by viewModels<MyViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            StabilityTestTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MainScreen(mainViewModel)
                }
            }
        }
    }
}

@Composable
fun MainScreen(
    viewModel: MyViewModel
) {

    var counter by remember {
        mutableStateOf(0)
    }

    val onClick = remember {
        { index: Int ->
            viewModel.toggleSelection(index)
        }
    }

    Column(
        modifier = Modifier.padding(8.dp),

        ) {
        val people = viewModel.people

        Text(text = "Counter $counter")

        Button(onClick = { counter++ }) {
            Text(text = "Increase Counter")
        }

        Spacer(modifier = Modifier.height(40.dp))

        ListScreen(
            people = people,
            onItemClick = onClick
        )
    }
}

@Composable
fun ListScreen(
    people: SnapshotStateList<Person>,
    onItemClick: (Int) -> Unit
) {

    SideEffect {
        println("ListScreen is recomposing...$people")
    }

    Column {
        Text(
            text = "Header",
            modifier = Modifier.border(2.dp, getRandomColor()),
            fontSize = 30.sp
        )
        Spacer(modifier = Modifier.height(20.dp))
        LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
            modifier = Modifier
                .fillMaxSize()
                .border(3.dp, getRandomColor(), RoundedCornerShape(8.dp))
        ) {
            items(
                items = people,
                key = { it.hashCode() }
            ) {
                ListItem(item = it, onItemClick = onItemClick)
            }
        }
    }
}

@Composable
private fun ListItem(item: Person, onItemClick: (Int) -> Unit) {

    SideEffect {
        println("Recomposing ${item.id}, selected: ${item.isSelected}")
    }

    Column(
        modifier = Modifier.border(3.dp, getRandomColor())
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    onItemClick(item.id)
                }
                .padding(8.dp)
        ) {
            Text("Index: Name ${item.name}", fontSize = 20.sp)
            if (item.isSelected) {
                Icon(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .background(Color.Red, CircleShape),
                    imageVector = Icons.Default.Check,
                    contentDescription = "Selected",
                    tint = Color.Green,
                )
            }
        }
    }
}

data class Person(val id: Int, val name: String, val isSelected: Boolean = false)

class MyViewModel : ViewModel() {

    private val initialList = List(30) { index: Int ->
        Person(id = index, name = "Name$index")
    }

    val people = mutableStateListOf<Person>().apply {
        addAll(initialList)
    }

    fun toggleSelection(index: Int) {
        val item = people[index]
        val isSelected = item.isSelected
        people[index] = item.copy(isSelected = !isSelected)
    }
}

fun getRandomColor() = Color(
    Random.nextInt(256),
    Random.nextInt(256),
    Random.nextInt(256),
    alpha = 255
)
Flotage answered 6/12, 2022 at 10:8 Comment(6)
Thanks, your solution is worked. Also, If we had a ViewState data class holding the list then how would we update it? example; data class ViewState(val persons: List<Person> = emptyList(), any other properties) private val viewState = MutableStateFlow(ViewState())Selfpropulsion
@Selfpropulsion updated answer, and added a ViewState example.Flotage
This solution is still not solving the issue. You could see in the AS layout inspector how each item of the LazyColumn is recomposed.Aldebaran
It doesn't matter how many times it shows. It's not always correct. Real debugging can be done either changing colors or using SideEffect to check what's actual recomposition count isFlotage
What if we don't know which item has been changed? So we need somethink like DiffUtil, is not?Caesura
@AlexeyNikitin ` key = { it.hashCode() }` and using SnapshotState handles that. You can check updated answer's stability sectionFlotage
H
3

I think its because you are using .map {..}, it creates a new entirely set of list.

Returns a list containing the results of applying the given transform function to each element in the original collection.

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

and by doing this

 list.value = list.value.copy(persons = newList)

your are essentially creating an entirely new set of list assigning them to your LazyColumn resulting it to re-compose entirely.


I would suggest the following:

Use a SnapshotStateList (mutableStateListOf)

private val initialList = mutableStateListOf(
        Person(id = 0, name = "Name0"),
        Person(id = 1, name = "Name1"),
        Person(id = 2, name = "Name2"),
        Person(id = 3, name = "Name3"),
        Person(id = 4, name = "Name4"),
        Person(id = 5, name = "Name5"),
        Person(id = 6, name = "Name6"),
    )

and just simply use the list iterator to modify the structure (in your case)

fun updateItemSelection(id: Int) {

        val iterator = list.value.persons.listIterator()

        while (iterator.hasNext()) {
            val current = iterator.next()
            if (current.id == id) {
                iterator.set(current.copy(isSelected = !current.isSelected))
            }
        }

      //  list.value = list.value.copy(persons = newList) // <- you don't need to assign a new list here as well. Remove this line
    }
Hagbut answered 6/12, 2022 at 9:34 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.