Question

Why are every composables recomposed?

Could you explain why both TextField are being recomposed when I enter text in one ?

Using Compose (composeBom = "2024.06.00"), Voyager 1.0.0, Kotlin 1.9.23:

class RootScreen : Screen {

    @Composable
    override fun Content() {
        val model = getScreenModel<RootScreenModel>()
        val uiState = model.uiState.collectAsState()
        println(uiState.value)
        Column {
            TextField(value = uiState.value.textField1, onValueChange = { model.updateTextField1(it) })
            TextField(value = uiState.value.textField2, onValueChange = { model.updateTextField2(it) })
        }
    }
}
class RootScreenModel: ScreenModel {
    private val _uiState = MutableStateFlow(ScreenUiState("", ""))
    val uiState: StateFlow<ScreenUiState> = _uiState.asStateFlow()

    fun updateTextField1(value: String) {
        _uiState.update {
            it.copy(textField1 = value)
        }
    }

    fun updateTextField2(value: String) {
        _uiState.update {
            it.copy(textField2 = value)
        }
    }
}
data class ScreenUiState(val textField1: String, val textField2: String)

I activated Layout Inspector for Android Studio and I can see that both TextField are being recomposed for every new letter. Is it possible to recompose only the one that changed ?

Thanks !

 3  86  3
1 Jan 1970

Solution

 3

The core problem is that your ViewModel is not considered as stable by the Compose Compiler. It is not considered stable because it has a property of type MutableStateFlow which is unstable.
As a result, lambdas that are calling your ViewModel methods are not stable either. Any Composable containing such an unstable lambda cannot be skipped during recomposition and will be recomposed with each recomposition of the composition scope.

As a workaround, you can manually remember the lambdas:

val updateTextField1 = remember(model) { 
    { value: String -> model.updateTextField1(value) } 
}
val updateTextField2 = remember(model) { 
    { value: String -> model.updateTextField2(value) } 
}

Column {
    TextField(value = uiState.textField1, onValueChange = updateTextField1)
    TextField(value = uiState.textField2, onValueChange = updateTextField2)
}

Then, they will be successfully skipped during recomposition.


Experiment:

We can try to modify our ViewModel so that it becomes stable. If we replace the MutableStateFlow by a stable type as listed in the criteria in the official documentation, we end up with something like this:

class RootScreenModel: ViewModel() {  // ViewModel now is stable

    var textField1 by mutableStateOf("")  // String is stable
    var textField2 by mutableStateOf("")  // String is stable

    fun updateTextField1(value: String) {
        textField1 = value
    }

    fun updateTextField2(value: String) {
        textField2 = value
    }
}

Now, our ViewModel only has stable properties and thus itself is stable. Compose is now able to skip recompositions of the TextFields:

 TextField(value = model.textField1, onValueChange = { model.updateTextField1(it) })
 TextField(value = model.textField2, onValueChange = { model.updateTextField2(it) })

Note:

There is a bug in Jetpack Compose preventing you from using method references. The bug is described on the Google Issue Tracker (#1, #2) and also discussed in this stackoverflow post. Usually, you would use method references to access the ViewModel functions like this:

TextField(value = uiState.value.textField1, onValueChange = model::updateTextField1)
TextField(value = uiState.value.textField2, onValueChange = model::updateTextField2)

The problem with that approach is that Jetpack Compose considers any method references as unstable. Your model.updateTextField1 function is considered unstable, thus the whole onValueChange lambda is considered unstable. As a result, the TextField again will recompose whenever the current Composable scope recomposes.

2024-07-09
BenjyTec