I'll try to describe the problem with a short example.
Lets assume we have a child composable representing a custom switch, and we want to keep it immutable, so we need to pass the initial state and also a lambda to change its source of truth when the switch is switched by the user:
@Composable
fun CustomSwitch(
title: String? = null,
checked: Boolean = false,
onSwitchChanged: ((Boolean) -> Unit)? = null
){
//...
}
And we now have a parent composable, representing a section of the screen where there are many switches. If we want to keep it immutable as well, we need to expose all the properties of the childrens upwards via parameters:
@Composable
fun PreferencesCard(
switch1Title: String? = null,
switch1Checked: Boolean = false,
OnSwitch1Changed: ((Boolean) -> Unit)? = null,
switch2Title: String? = null,
switch2Checked: Boolean = false,
OnSwitch2Changed: ((Boolean) -> Unit)? = null,
){
CustomSwitch(switch1Title, switch1Checked, OnSwitch1Changed)
CustomSwitch(switch2Title, switch2Checked, OnSwitch2Changed)
//Other composables
}
DISCLAIMER: In this example this PreferencesCard
composable is really dumb as it does nothing and it could be replaced by a Column
or some kind of "open" composable taking just function bodies. But that is only because I want to keep the code simple, please assume it has other children and does its own thing as well.
With this approach, as we go up in the hierarchy of composables, we need to carry along all the children params, resulting in really long parameter lists in the parents. This problem is characteristic of declarative UI frameworks, and for instance in React it is known as Property Drilling. It produces complex and unmaintenable code, as any change in the children (e.g.: adding a new parameter) results in changes to all its parents. It goes against the very concept of encapsulation.
If we want to still keep the parent composable immutable, one solution is to encapsulate children state and listeners so that the parameter lists are shorter:
data class PreferencesCardState(val switch1Title: String?, val switch1Checked: Boolean, val switch2Title: String?, val switch2Checked: Boolean)
interface PreferencesCardListener {
fun onSwitch1Changed(b: Boolean): Unit
fun onSwitch2Changed(b: Boolean): Unit
}
@Composable
fun PreferencesCard(
state: PreferencesCardState,
listener: PreferencesCardListener
){
CustomSwitch(state.switch1Title, state.switch1Checked, {value -> listener?.onSwitch1Changed(value)})
CustomSwitch(state.switch2Title, state.switch2Checked, {value -> listener?.onSwitch2Changed(value)})
//Other composables
}
From the state point of view this is fine, as Compose is smart enough as to only change the children when only a children parameter changes (data classes with immutable properties are stable). But with the lambdas we can run into the infamous problem of "unstable lambdas": on every recomposition of the PreferencesCard
, a new pair of lambdas is produced, which in turn cause the recomposition of every CustomSwitch
composable (lambdas are treated by Compose as state).
There is this trick that prevents recomposition due to "unstable lambdas", which is to pass method references in place of the lambdas, but those references (often pointing at mutators defined in the ViewModel) still have to be passed down from the root composable, unless we are willing to pass the entire ViewModel as parameter (which is a bad practice).
So how could we avoid the prop drilling while keeping all the composables immutable?
remember {{}}
for unstable lambdas that I suspect affects performance and of course, you need to annotate your listener with@Stable
. Unfortunately, according to the official docs, "property drilling is preferred over wrapper classes... – Diatomfun PrefCard(switchRow: @Composable () -> Unit)
this way the user can add and listen to all the switches themselves and there's no need for all the other params. – Kaolin