androidlambdaandroid-jetpack-composerecomposemutablestateof

Android lambda update mutablestatelistof not recomposing


I have a mutable state list the I want to update from a seperate composable function via a lambda. The code updates the list with the new data but it does not recompose the UI.

What this is suppose to do it:

On button click, open an overlay of two TextFields to edit information form the mutablestatelistof. When finished, close the overlay and update the UI based on the info entered in the overlay.

Fairly simply concept but the recomposition is the issue.

    val groupComponents = remember {
        mutableStateListOf<GroupedComponents>()
    }

    val boxVisible = remember {
        mutableStateOf(false)
    }

    val boxElementIndex = remember {
        mutableIntStateOf(0)
    }

    val boxGroupIndex = remember {
        mutableIntStateOf(0)
    }

    val boxName = remember {
        mutableStateOf("")
    }

    val boxCommand = remember {
        mutableStateOf("")
    }

    EditCustomUIBoxOverlay(
        boxVisible,
        boxName,
        boxCommand,
        close = {
            boxVisible.value = !boxVisible.value
        },
        processBoxData = { name, command ->
            val list = groupComponents[boxGroupIndex.value].elements
            list[boxElementIndex.value].name = name.value
            list[boxElementIndex.value].data = command.value
            groupComponents[boxGroupIndex.value].copy(
                elements = list
            )
        })

@Composable
fun EditCustomUIBoxOverlay(
    boxVisible: MutableState<Boolean>,
    boxName: MutableState<String>,
    boxCommand: MutableState<String>,
    processBoxData: (MutableState<String>, MutableState<String>) -> Unit,
    close: () -> Unit
) {
    AnimatedVisibility(
        visible = boxVisible.value,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color(0.0f, 0f, 0f, 0.5f)),
            contentAlignment = Alignment.Center
        ) {
            ElevatedCard(
                modifier = Modifier
                    .padding(15.dp)
                    .defaultMinSize()) {
                Row(
                    modifier = Modifier.defaultMinSize(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    TextField(value = boxName.value, onValueChange = { it ->
                        boxName.value = it
                    }, label = { Text(text = "Button Name") })
                }
                Row(
                    modifier = Modifier.defaultMinSize(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    TextField(value = boxCommand.value, onValueChange = { it ->
                        boxCommand.value = it
                    }, label = { Text(text = "Enter Command") })
                }
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    Button(onClick = {
                        close()
                        processBoxData(boxName, boxCommand)
                    }) {
                        Text(text = "Done")
                    }
                    Button(onClick = { close() }) {
                        Text(text = "Cancel")
                    }
                }
            }
        }
    }
}

If I skip the seperate creation of a new composable function and just add the code straight, it works as I want it to. It updates the mutable state list and recomposes UI on changes.

Below works

AnimatedVisibility(
        visible = boxVisible.value,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color(0.0f, 0f, 0f, 0.5f)),
            contentAlignment = Alignment.Center
        ) {
            ElevatedCard(
                modifier = Modifier
                    .padding(15.dp)
                    .defaultMinSize()) {
                Row(
                    modifier = Modifier.defaultMinSize(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    TextField(value = boxName.value, onValueChange = { it ->
                        boxName.value = it
                    }, label = { Text(text = "Button Name") })
                }
                Row(
                    modifier = Modifier.defaultMinSize(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    TextField(value = boxCommand.value, onValueChange = { it ->
                        boxCommand.value = it
                    }, label = { Text(text = "Enter Command") })
                }
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center
                ) {
                    Button(onClick = {
                        boxVisible.value = !boxVisible.value
                        val list = groupComponents[boxGroupIndex.value].elements
                        list[boxElementIndex.value].name = boxName.value
                        list[boxElementIndex.value].data = boxCommand.value
                        groupComponents[boxGroupIndex.value].copy(
                            elements = list
                        )
                    }) {
                        Text(text = "Done")
                    }
                    Button(onClick = { close() }) {
                        Text(text = "Cancel")
                    }
                }
            }
        }
    }

Does creating a seperate function with a lambda callback lose the ability to access the mutable state list as expected?

I have tried moving variables inside and out of the composable function, moving the location of where the function gets called, changing the parameters in the composable function. All these give the same issue, the data changes as needed but the UI does not recompose.


Solution

  • In Jetpack Compose, you should never pass a MutableState<T> as parameter to other Composables, as this violates the unidirectional data flow pattern. In your code, the data is flowing upwards the Composable hierarchy. If you do this, you will stumble across weird issues like this.

    Instead, you should pass down the actual value T and when a child Composable wants to update T, it uses a callback function to inform the parent Composable about the new value that should be set.

    In your case, it would look like this:

    @Composable
    fun EditCustomUIBoxOverlay(
        boxVisible: Boolean,
        boxName: String,
        boxCommand: String,
        processBoxData: (newName: String, newCommand: String) -> Unit,
        close: () -> Unit
    ) {
    
        var updatedBoxName by remember { mutableStateOf(boxName) }
        var updatedBoxCommand by remember { mutableStateOf(boxCommand) }
    
        AnimatedVisibility(
            visible = boxVisible,
            enter = fadeIn(),
            exit = fadeOut()
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(color = Color(0.0f, 0f, 0f, 0.5f)),
                contentAlignment = Alignment.Center
            ) {
                ElevatedCard(
                    modifier = Modifier
                        .padding(15.dp)
                        .defaultMinSize()) {
                    Row(
                        modifier = Modifier.defaultMinSize(),
                        horizontalArrangement = Arrangement.Center
                    ) {
                        TextField(
                            value = updatedBoxName, 
                            onValueChange = { it ->
                                updatedBoxName = it
                            }, 
                            label = { Text(text = "Button Name") })
                    }
                    Row(
                        modifier = Modifier.defaultMinSize(),
                        horizontalArrangement = Arrangement.Center
                    ) {
                        TextField(
                            value = updatedBoxCommand, 
                            onValueChange = { it ->
                                updatedBoxCommand = it
                            }, 
                            label = { Text(text = "Enter Command") })
                    }
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.Center
                    ) {
                        Button(
                            onClick = {
                                processBoxData(updatedBoxName, updatedBoxCommand)
                                close()
                            }
                        ) {
                            Text(text = "Done")
                        }
                        Button(onClick = { close() }) {
                            Text(text = "Cancel")
                        }
                    }
                }
            }
        }
    }
    

    Then, from the parent Composable, you could call it like this:

    @Composable
    fun MainComposable() {
    
        var boxVisible by remember { mutableStateOf(true) }
        var boxName by remember { mutableStateOf("") }
        var boxCommand by remember { mutableStateOf("") }
    
        EditCustomUIBoxOverlay(
            boxVisible = boxVisible,
            boxName = boxName,
            boxCommand = boxCommand,
            processBoxData = { newBoxName, newBoxCommand ->
                boxName = newBoxName,
                boxCommand = newBoxCommand
            },
            close = {
                boxVisible = false
            }
        )
    }
    

    Also note that when you perform an operation like this,

    groupComponents[boxGroupIndex.value].copy( elements = list )
    

    actually nothing is updated, as you don't do anything with the updated list item returned by the copy function. Usually, you want to assign the modified element back to the list as follows, and then Jetpack Compose will trigger a recomposition:

    groupComponents[boxGroupIndex.value] = groupComponents[boxGroupIndex.value].copy(
        elements = list
    )