0

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 FilterChips 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!

James Olrog
  • 344
  • 2
  • 10
  • I'm not sure about what you mean by [key] that you mentioned of your tries. Have you tried setting unique keys to your items? See this [doc](https://developer.android.com/jetpack/compose/lists#item-keys) for clarification. – Ajay Chandran Aug 04 '23 at 12:22
  • @AjayChandran as `FlowRow` is not a `LazyRow`/`LazyColumn`, it does not take `keys` as a parameter. You can use a `key` in any composable thought - but I was unable to get there to work. e.g: ` chipItems.forEach { chip -> key(chip) {...} } ` – James Olrog Aug 04 '23 at 14:58

2 Answers2

1

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.

Thracian
  • 43,021
  • 16
  • 133
  • 222
-1

Google has given a set of best practices you can follow to avoid unnecessary recomposition.

https://developer.android.com/jetpack/compose/performance/bestpractices

Chirag Thummar
  • 665
  • 6
  • 16