Reputation: 435
I am writing an Android app using Jetpack Compose. I have a Composable called MultiSelectGroup
which needs to modify and return a list of selections whenever a FilterChip
is clicked. Here is the code:
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MultiSelectGroup(
items: List<String>,
currentSelections: List<String>,
onSelectionsChanged: (List<String>) -> Unit,
) {
FlowRow {
items.forEach { item ->
FilterChip(
label = { Text(item) },
selected = currentSelections.contains(item),
onClick = {
val newSelectedChips = currentSelections.toMutableList().apply {
if (contains(item)) {
remove(item)
} else {
add(item)
}
}
onSelectionsChanged(newSelectedChips)
},
)
}
}
}
This component is currently called using the following code:
val allItems = remember { (1..6).map {"$it"} }
val selectedItems = remember {
mutableStateOf(emptyList<String>())
}
MultiSelectGroup(
items = allItems,
currentSelections = selectedItems,
onSelectionsChanged = { selectedItems.value = it },
)
The problem is that this approach seems to be fairly inefficient in terms of recompositions; every time a FilterChip
is clicked, all FilterChip
s are recomposed whether they change visually or not. My current understanding is that this is because the list is being re-set and, with List
being a more unstable data type, Compose decides to just re-render all components dependant on the List
rather than just the elements in that List
have changed.
I have already considered hoisting the "updating list" logic in the onClick
of each FilterChip
out of the component - however it seems sensible for this component to do this logic as this behaviour will always be the same and would only be duplicated each time MultiSelectGroup
is used. I have also tried to use combinations of mutableStateList
, key
and derivedStatedOf
but I'm yet to find a solution that works.
Is a component like this doomed to always recompose each of its children? Or is there a way to optimise the recompositions for this kind of view? Thanks in advance!
Upvotes: 1
Views: 3439
Reputation: 339
As mentioned in Android documentation:
Compose always considers collection classes unstable. such as List, Set and Map. This is because it cannot be guaranteed that they are immutable.
In a nutshell, the Compose compiler classifies parameters of Composable functions as either Stable or Unstable. Stability determines if a composable function can be skipped during recomposition.
To address this performance issue, Android documentation recommends use Kotlin Immutable Collections. These are guaranteed to be immutable, and Compose treats them as Stable.
fun MultiSelectGroup(
items: ImmutableList<String>,
currentSelections: ImmutableList<String>,
onSelectionsChanged: (ImmutableList<String>) -> Unit,
)
Notes:
Ensure that the collection’s generic types are also stable. Primitive types and strings are considered stable by default.
You can use the stability report, exported by the Compose compiler to inspect stability issues and identify which composables are skippable.
Upvotes: 2
Reputation: 67149
You current function is unstable as you mentioned with params
restartable scheme("[androidx.compose.ui.UiComposable]") fun MultiSelectGroup(
unstable items: List<String>
unstable currentSelections: List<String>
stable onSelectionsChanged: Function1<List<String>, Unit>
)
You can create a class that contains text and item selection status instead of two lists.
data class Item(val text: String, val selected: Boolean = false)
And you can replace List
with SnapshotStateList
which is stable
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun MultiSelectGroup(
items: SnapshotStateList<Item>,
onSelectionsChanged: (index: Int, item: Item) -> Unit,
) {
SideEffect {
println("MultiSelectGroup scope recomposing...")
}
FlowRow {
SideEffect {
println("FlowRow scope recomposing...")
}
items.forEachIndexed { index, item ->
FilterChip(
label = { Text(item.text) },
selected = item.selected,
onClick = {
onSelectionsChanged(index, item.copy(selected = item.selected.not()))
},
)
}
}
}
And use it as
@Preview
@Composable
private fun Test() {
val allItems = remember { (1..6).map { Item(text = "$it") } }
val selectedItems = remember {
mutableStateListOf<Item>().apply {
addAll(allItems)
}
}
MultiSelectGroup(
items = selectedItems,
onSelectionsChanged = { index: Int, item: Item ->
selectedItems[index] = item
}
)
}
And it will be
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MultiSelectGroup(
stable items: SnapshotStateList<Item>
stable onSelectionsChanged: Function2<@[ParameterName(name = 'index')] Int, @[ParameterName(name = 'item')] Item, Unit>
)
You can actually send a list instead of SnapshotList but then FlowRow scope will be recomposed. Using a SnapshotStateList neither scopes are non-skippable.
Also you might not use a callback since you can update SnapshotStateList
inside MultiSelectGroup
but i'd rather not updating properties of inputs inside function.
Upvotes: 5