-
Imperative UI frameworks (like the legacy View API) works in two steps:
- declaring a layout (typically using an XML file); sometimes the layout can also be declared programmatically (but it is less convenient)
- calling setters to update the view components when the modeled data changes.
- The main difficulty of this approach is to synchronize modeled data with the rendered UI
-
Traditional approches using Model-View-Controller work this way to update the UI:
- The user triggers an event on the UI (click, key pressed) or a system or network event is received
- A function is called on the main UI thread to manage this event
- This function modifies the modeled data (add an item to a list, change the attribute of an object...)
- The view (UI) listens for change on the modeled data using a observable-observer pattern; in case of a change, it updates the graphical components (modifying some attributes using setters for example)
- Note: modeled data can use several layers of data structures more or less close to the view (e.g. model and view-model)
-
Challenge: changes on the modeled data can be very various (tiny changes on some attributes or higher-magnitude changes); it may imply changes of different kinds on the views
-
A solution may be to refresh completely the attributes of views for any change (same exhaustive refresh code whatever is the change)
- It will work but it is costly!
- Managing incremental changes requires a lot of attention for the developer
- If we use a static declaration of the view using an XML file, changing attributes by calling setters is possible... but modifying programmatically the structure of the tree of components is not convenient (adding, removing views)
- The modeled data has the responsibility to fire the callback functions installed by the view to observe it... but we can forget to do that (and the view will not be updated and synced with the data).
-
A solution may be to refresh completely the attributes of views for any change (same exhaustive refresh code whatever is the change)
Approach to manage synchronisation of model and view
- Jetpack Compose proposes a declarative way to specify the elements of the GUI with composable functions emitting the graphical elements
- The emitted elements embed all the parameters (text, images displayed...) of the graphical elements
- This parameters (and also the structure of the tree of elements) depend on the model we want to show to the user
- The easiest way to ask a component to render a model is to use an immutable objet as a parameter of a composable function.
- The composable function is recomposed when its call site trigger it with changed parameters.
- With recomposition the tree of components is rebuilt to adapt it to the new parameters.
- But when we climb into the stack trace (towards the root composable function) we must in a function or another host some mutable data (otherless the GUI will be static)
- A good approach is to host mutable data at the most highest level and use immutable parameters for bottom-level composable functions
Example: we want to compute the Body Mass Index (BMI) of a person to determine if this person is underweight or obese. For this, we use:
- A TextField to input the mass
- A TextField to input the height
- And finally a Text displaying the result
First naive approach: using a single component managing all with mutable variables
@Composable fun BMIGlobal() { Log.v("BMIGlobal", "Starting the composition") var mass by remember { mutableStateOf(70f) } // in kg var height by remember { mutableStateOf(1.70f) } // in meters Column() { TextField("$mass", { t -> (t.toFloatOrNull() ?: 0f).let { mass = it } }, label = { Text("Mass in Kg") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) TextField("${height * 100}", { t -> (t.toFloatOrNull() ?: 0f).let { height = it / 100 } }, label = { Text("Height in cm")}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) Text("You weigh $mass Kg and your height is ${height} meters") Text("Your BMI is ${(mass / height / height)}") } }
Variables are declared with var x by remember { mutableStateOf(initialValue) }:
- Using mutableStateOf allows the composer to observe changes on the variables (otherwise if we modify the variables, the composer will not detect it)
- Using remember allows to memorize the mutableState across recompositions: the same variable will be kept (otherwise it would be resetted each time)
- Using by allows to use remember as a delegate: value access and modification can be done directly
- If we do not use by (var x = remember { mutableStateOf(initialValue) }), access and modification must be done with x.value
- It is possible to replace by remember by by rememberSaveable: the value will also survive the destruction/recreation of the activity after a rotation
- One can also use var (x, setX) = mutableStateOf(initialValue) (React inspired notation)
mutableStateOf is useful for a value of an immutable type that can change (e.g. if a mutable state of a String changes, a new String will be used since a String is immutable and cannot be changed in-situ; idem for an Int, Float...).
If the type of variable handled by mutableStateOf is mutable, no change will be detected if we change a property of the object.
Let's replace:
- var mass by remember { mutableStateOf(70f) }
- by var mass by remember { mutableStateOf(mutableListOf(70f)) }
And change the first item of the list: mass[0] = newValue. The change will not be detected since the composer cannot watch an internal change in an object (only a change of the reference of the object). To overcome this problem, special mutable state structures have been introduced to track internal changes:
- mutableStateListOf(v1, ...) for array lists
- mutableStateMapOf(k1 to k2, ...) for maps
Sometimes a value must be computed using several mutable state variable. For this we can use derivedStateOf. For example if we want to declare a centimeterHeight that is computed from the height in meters:
val centimeterHeight by remember { derivedStateOf { height * 100 } }
This value will be automatically updated when height changes.
Creating a single global composable for an activity is not a good practice. We must cut this global composable in several smaller composables, each being responsible of a chunk of the GUI. It promotes code reused and also allow to have composables relying on immutable parameters. Let's try for our example with :
- A BodyParamsInput composable to enter the mass and the height of the person
- A BMIResult composable displaying the result of the BMI computation
- A BMI composable that calls the two previously defined composables
- We create also a data class named BodyParams that is immutable and will be used as an argument for BMIResult and BodyParamsInput
- And we write an extension value bodyMassIndex for BodyParams to compute the BMI
data class BodyParams(val mass: Float, val height: Float) val BodyParams.bodyMassIndex get() = mass / height / height @Composable fun BodyParamsInput(bodyParams: BodyParams, onChange: (BodyParams) -> Unit, modifier: Modifier = Modifier) { Column(modifier) { TextField("${bodyParams.mass}", { t -> onChange(bodyParams.copy(mass= (t.toFloatOrNull() ?: 0f))) }, label = { Text("Mass in Kg") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) TextField("${bodyParams.height * 100}", { t -> (t.toFloatOrNull() ?: 0f).let { bodyParams.copy(height = it / 100) } }, label = { Text("Height in cm")}, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) } } @Composable fun BMIResult(bodyParams: BodyParams, modifier: Modifier = Modifier) { Column(modifier) { Text("You weigh ${bodyParams.mass} Kg and your height is ${bodyParams.height} meters") Text("Your BMI is ${bodyParams.bodyMassIndex}") } } @Composable fun BMI() { var bodyParams by remember { mutableStateOf(BodyParams(70f, 1.7f))} Column() { BodyParamsInput(bodyParams, onChange = {bodyParams = it}) BMIResult(bodyParams, modifier=Modifier.border(1.dp, color=Color.Red)) } }
Approach used:
- State is hoisted in the top composable function (BMI)
-
We write a model class BodyParams that is immutable compatible with mutableStateOf:
- Each change will require to create a new object: change will be detected
- Composables that can modify the model (here BodyParamsInput) cannot modify themselves the model (that is immutable): they expose a parameter onChange to receive a callback function that will be called when there is a change
- Usually composable functions have an optional last parameter named modifier that is used to communicate a modifier from the parent composable to the graphical element that is the root of the child composable
What are the chain of events when we input a digit into one of the text field?
- The onChange callback given to the TextField is called
- It changes the value of the bodyParams state in the parent composable BMI
-
The composer detects that a new BodyParams object is assigned. It triggers the recomposition of BMI.
- → The composer knows that BMI is dependent on bodyParams (because this variable is read during the composition), that's why the recomposition is done
Sometimes it is difficult to represent an entire model in a single immutable class for performance reasons (e.g. if we deal with large lists or maps). In this case we can use mutableStateListOf or mutableStateMapOf.
It is recommanded to gather all the state data inside a single class (inheriting from the ViewModel class) with several State fields (that can be obtained with the mutableStateOf(...) API). One can also use elements of type LiveData, Flow or Observable that can emit a stream of values; elements of these types can easily be converted to a State with the extension methods observeAsState.
Management of side effects
- Side effects are actions that can modify the model (and thus triggering GUI changes).
-
The composer can executes composables using several threads for better performance
- Composables must only use a declarative way to build the elements of the GUI
- Composables must be idempotent (produce the same result if called twice if the state have not changed) ⟶ no random value must be used (or they must be embedded in the state)
-
Composables must NEVER directly do side effects:
- It is not thread-safe
- Executing side effects on the composable may trigger a recomposition storm: the composer will detect changes during the composition and will retrigger an CPU intensive infinite chain of recompositions.
- Side effects should be executed only on the main GUI thread.
-
Callback parameters of the common GUI elements (TextField, Button...) that are triggered when an event arises (touch, click, key input...) are lambdas that are executed on the main thread.
- Side effects are authorized in these callbacks
-
If we want to call ourself code executing a side effect, we must:
- Use a coroutineScope that can be used to execute coroutines that modify the state
- Use a SideEffect composable
- Use a LaunchedEffect composable
- Use a DisposableEffect composable
Coroutine scope
- A coroutine scope allows to execute a coroutine in a special context.
- In Kotlin, a coroutine is a suspendable function that may execute code that is sleeping some time or waiting for IO. Several coroutines can be executed concurrently on the same thread (but not in the same time): turn is given to each coroutine according to the flow of events.
- If we want to execute a coroutine inside an event callback function, we must use a coroutine scope
Example: we create a component that increment a counter not immediately but after a delay:
- We could call Thread.sleep(delay) inside the onClick callback but it will block the main thread (and the UI will become unresponsive).
- We could use a handler to post the runnable to execute with a postDelayed
- The better solution is to use the delay coroutine that suspend the coroutine executing during some time. However the coroutine cannot be executed directly in the onClick callback: we use a coroutine scope that we have created in the composable.
@Composable fun DelayedIncrementation() { var counter by rememberSaveable { mutableStateOf(0) } val coroutineScope = rememberCoroutineScope() Text("$counter", fontSize = 40.sp, modifier = Modifier.clickable { coroutineScope.launch { delay(1000) counter++ } }) }
It is also possible to cancel the previous job (if we click several times in a row in less than one second, only the last incrementation will be done):
@Composable fun DelayedIncrementation() { var counter by rememberSaveable { mutableStateOf(0) } var lastJob by remember { mutableStateOf<Job?>(null) } val coroutineScope = rememberCoroutineScope() Text("$counter", fontSize = 40.sp, modifier = Modifier.clickable { lastJob?.cancel() lastJob = coroutineScope.launch { delay(1000) counter++ } }) }
SideEffect composable
- The SideEffect composable allows to execute an action modifying the state of the model.
- The side effect is executed on each recomposition, i.e. for each change.
- Sometimes a SideEffect can trigger a recomposition storm (if we change the state in the side effect, il will recompose, the recomposition triggers the side effect that changes the state and so on)
@Composable fun SideEffectStorm() { var counter by rememberSaveable { mutableStateOf(0) } Text("$counter", fontSize = 40.sp) SideEffect { counter++ } // bad idea! }
- Usually a SideEffect will be triggered with a condition; e.g. if the state fulfills some condition we send a request to a webserver; coroutines can be executed in the SideEffect but be careful to use the right dispatcher for the coroutine (i.e. the IO dispatcher if we do network operations).
LaunchedEffect composable
- The LaunchedEffect composable is more flexible that the SideEffect: we can pass one or several keys to LaunchedEffect; the effect will be executed when one of the key changes.
- For example if we use LaunchedEffect(true) (or anything else that is a constant as key), the effect will be executed only for the first composition (it will be skipped for the next recompositions).
- If we use LaunchedEffect(some_variable_expression), it will be executed a first time and once each time the value of some_variable_expression changes.
- If the LaunchedEffect is reached with different keys, the previous instance of LaunchedEffect (from the last recomposition) will be cancelled before executing the new instance
- The LaunchedEffect from the last recomposition can also be cancelled if it is not reached during the current composition (being for example into a condition block that is no more executed)
☞ LaunchedEffect is the counterpart in Jetpack Compose of the useEffect hook in React
Example: a counter that is incremented periodically, the increment period being set with a slider between 0 and 1s; we use the period as key of the LaunchedEffect; thus if the period changes, the previous instance of the LaunchedEffect will be cancelled, and a new one using the new period will be used.
@Composable fun PeriodicIncrementation() { var counter by rememberSaveable { mutableStateOf(0) } var incrementPeriod by rememberSaveable() { mutableStateOf(0f) } Column() { Slider(value = incrementPeriod, onValueChange = { incrementPeriod = it }, valueRange = 0f..1f) Text("$counter", fontSize = 40.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } if (incrementPeriod > 0f) LaunchedEffect(incrementPeriod) { while (true) { delay((incrementPeriod * 1000).toLong()) // convert to milliseconds counter++ } } }
produceState allow to produce a watchable State object that is updated using the coroutine passed as parameter (we use assignment with value = ... to modify the state in the coroutine). It is implemented using a LaunchedEffect (implementations exist for zero, one, two or three keys), here is the implementation with two keys :
@Composable fun <T> produceState( initialValue: T, key1: Any?, key2: Any?, @BuilderInference producer: suspend ProduceStateScope<T>.() -> Unit ): State<T> { val result = remember { mutableStateOf(initialValue) } LaunchedEffect(key1, key2) { ProduceStateScopeImpl(result, coroutineContext).producer() } return result }
We can rewrite the PeriodIncrementation using produceState:
@Composable fun getIncrementingState(incrementPeriod: Float) = produceState(initialValue = 0, incrementPeriod) { if (incrementPeriod > 0f) while (true) { delay((incrementPeriod * 1000).toLong()) value++ } } @Composable fun PeriodicIncrementationWithState() { var incrementPeriod by rememberSaveable() { mutableStateOf(0f) } val state = getIncrementingState(incrementPeriod) Column() { Slider(value = incrementPeriod, onValueChange = { incrementPeriod = it }, valueRange = 0f..1f) Text("${state.value}", fontSize = 40.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } }
⚠ Note that produceState uses remember to save the variable and not rememberSaveable: thus the value will be lost when we destroy/recreate the activity by rotating the screen.
⚠ Lambdas employed for side effects uses a closure with the value of parameters of the parent composable. Execution of the lambda occurs after the composition, and maybe after a recomposition. However the recomposition can be done with new values for the parameters: in this case the old values from the closure at the time of the first composition will be used. To avoid this one can declare val param by rememberUpdatedState(funParam).
Exemple: we display the value of the counter one second after the first composition
@Composable fun CounterDisplay(value: Int) { val rememberedValue by rememberUpdatedState(value) var initialValue by remember { mutableStateOf<Int?>(null) } var afterSecondValue by remember { mutableStateOf<Int?>(null) } Column() { Text("Current value: $value") val iv = initialValue val asv = afterSecondValue if (iv != null && asv != null) Text("Initial value: $iv, value one second after the first composition: $asv") else Text("First second not elapsed") } LaunchedEffect(true) { // effect launched only once delay(1000L) // convert to milliseconds initialValue = value afterSecondValue = rememberedValue } }
DisposableEffect composable
- The DisposableEffect composable is like the LaunchedEffect but for effects that requires to execute cleanup code when they are cancelled
- One could also use a LaunchedEffect with a try-finally block doing the cleanup to obtain the same effect
- Useful if we want to free resources, unregister an observer... when the composition is destroyed
Example: a component that display the acceleration magnitude; it requires to register a listener with the sensor API; the listener must be unregistered when we leave the composition (i.e. the composable has been removed from the screen).
@Composable fun Accelerometer() { val acceleration = remember { mutableStateListOf(Float.NaN, Float.NaN, Float.NaN) } val context = LocalContext.current val sensorManager = remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } val accelerometer = remember { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } val listener = remember { object: SensorEventListener { override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} override fun onSensorChanged(event: SensorEvent) { event.values.forEachIndexed { index, value -> acceleration[index] = value } } } } DisposableEffect(true) { sensorManager.registerListener(listener, accelerometer, SensorManager.SENSOR_DELAY_UI) onDispose { sensorManager.unregisterListener(listener) } } Column() { Text("Accelerometer") acceleration.forEach { Text("$it") } } }
⚠ The composition is not leaved when the activity is put into the background; in this case we will continue to watch for acceleration (however the screen is not refreshed on the background)
Lifecycle of the activity
- LocalLifecycleOwner.current.lifecycle allows to obtain information about the lifecycle of the activity hosting the composable.
- Knowing the state of the activity is useful to stop or start some side effects that must be done only if the activity is in foreground.
- It replaces the use of the overriden methods onStart(), onResume(), onPause() and onStop of the activity.
- On can add an observer on the lifecyle (that must be removed when we leave the composition) to be informed about the updated status of the activity.
Example: a State publishing a negative value of the activity is stopped and a positive value when it is started
@Composable fun getLifecycleStartedState(): State<Int> { val lc = LocalLifecycleOwner.current.lifecycle val initial = if (lc.currentState.isAtLeast(Lifecycle.State.STARTED)) 1 else -1 return produceState(initial, lc) { val observer = LifecycleEventObserver { _, event -> when(event) { Lifecycle.Event.ON_START -> value = kotlin.math.abs(value) + 1 Lifecycle.Event.ON_STOP -> value = -(kotlin.math.abs(value) + 1) else -> {} } Log.d("getLifecycleStartedState", "New event: $event, new value=$value") } lc.addObserver(observer) try { delay(Long.MAX_VALUE) // infinite wait (or almost) } finally { Log.d("getLifecycleStartedState", "Remove observer") lc.removeObserver(observer) } } }
Creating a state from a long running task
-
Sometimes a component must display the result of a long computation or information retrieval on the Internet
- Waiting for the result we can display temporary components (a message signaling the a computation is running, a progress bar...)
- When the result is obtained we can update the GUI with the result
-
For a long computation or IO operation a coroutine can be used:
- We can order the code to be executed inside a secondary thread to not block the main thread
-
For this we use:
- withContext(Dispatchers.Default) { ... } for computation code to be run on a secondary thread
-
produceState (as seen previously) can be used to convert a coroutine to a state
- The coroutine will publish values on the state and the composable elements will be informed about these updates
- We can publish intermediary values to manage progress
- We can publish a final value for the result (intermediary values and final value can be of different types that inherit from a common type. for example a sealed class)
-
⚠ Do not forget in the coroutine code to either:
- check with isActive if the job is not cancelled (if ! isActive we must leave the coroutine as soon as positive)
- call yield() that may give the hand to another coroutine; if the current job is cancelled, yield() will raise a CancellationException
Example: let's write a composable that displays the prime factors of a number; this task can be long for a large integer, especially if it is prime (it will require to examine O(sqrt(n)) factors). We use produceState to convert the coroutine to a state and we publish regularly the progress done in the computation.
data class FactorizationState(val progress: Float, val factors: List<BigInteger>, val cancelled: Boolean = false) @Composable fun getFactorizerState(number: BigInteger, cancelled: Boolean) = produceState(FactorizationState(0f, listOf()), number, cancelled) { if (! cancelled) withContext(Dispatchers.Default) { value = FactorizationState(0f, listOf()) val two = BigInteger.valueOf(2) var n = number var nsqrt = sqrt(number.toDouble()) if (n < BigInteger.valueOf(2)) { value = FactorizationState(1f, listOf()) // terminated, no factor found } else { var factors = listOf<BigInteger>() var divider = two var iterationCounter = 0 while (divider * divider <= n) { val dividing = n % divider == BigInteger.ZERO if (dividing) { factors += divider n /= divider nsqrt = sqrt(n.toDouble()) value = FactorizationState(divider.toDouble().div(nsqrt).toFloat(), factors) } else { divider += if (divider == two) BigInteger.ONE else two } if (iterationCounter % 100 == 0) { value = value.copy(progress = divider.toDouble().div(nsqrt).toFloat()) if (!isActive) { value = value.copy(cancelled = true) break // exit since the coroutine has been cancelled } } iterationCounter++ } if (! value.cancelled) { if (n > BigInteger.ONE) factors += n value = FactorizationState(1f, factors) } } } } @Composable fun Factorizer(number: BigInteger, onCancellationRequested: () -> Unit, cancelled: Boolean, modifier: Modifier = Modifier) { var state = getFactorizerState(number, cancelled) Card(modifier = modifier) { Column() { Row(verticalAlignment = Alignment.CenterVertically) { Text("$number") if (! state.value.cancelled && state.value.progress != 1f) IconButton(onClick = { onCancellationRequested() }) { Icon(painter = painterResource(android.R.drawable.ic_delete), contentDescription = "Cancel") } } when { state.value.progress < 1f -> { Row(verticalAlignment = Alignment.CenterVertically) { LinearProgressIndicator(progress = state.value.progress, modifier=Modifier.weight(1f)) if (state.value.cancelled) Text("Cancelled") } } state.value.progress == 1f -> Text("Computation terminated.") } FlowRow() { state.value.factors.forEach { Text("$it", modifier= Modifier .padding(5.dp) .border(width = 1.dp, color = Color.Black) .padding(5.dp)) } } } } } @Composable fun ComposeFactorizer() { var typedNumber by remember { mutableStateOf(BigInteger.ONE) } var validatedNumber by remember { mutableStateOf<BigInteger?>(null) } var cancelled by remember { mutableStateOf(false) } Column() { Row(verticalAlignment = Alignment.CenterVertically) { TextField(value="$typedNumber", onValueChange = { typedNumber = BigInteger(it) }, modifier = Modifier.weight(1f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) Button(onClick = { validatedNumber = typedNumber; cancelled = false; }) { Text("Validate") } } val number = validatedNumber if (number != null) Factorizer(number, onCancellationRequested = {cancelled = true}, cancelled, modifier = Modifier.fillMaxWidth()) else { Text("No number validated yet.") } } }
Transversal states with CompositionLocal
- Providing a state from a composable high in composable tree to a composable at the bottom of the tree requires to pass it to all the intermediary composables.
- We must add arguments to the composables along the branch to supply the parameter.
- If the state is used comprehensively among all the composables, it can be tedious to add an argument to all the composables.
- An alternative is to use a kind of singleton object: one can use an object NameOfSingleton { ... } but it is not very flexible (one global value).
- CompositionLocal is proposed to address this problem
Principle of use of CompositionLocal:
- We create a class with the data (e.g. class MyData(val value: Int))
-
We create one instance by default of the data (that will be used inside all the composables as a singleton unless otherwise specified):
- val LocalMyData = compositionLocalOf { MyData(1) }
-
We can also use blocks to specify an instance that must be used in some parts of the composition tree instead of the default data:
- CompositionLocalProvider(LocalMyData provides MyData(2))
- We can obtain the current instance with LocalMyData.current
// with inline the class behaves as a primitive type in memory inline class Temperature(val celsius: Float) { val farhenheit get() = celsius * 9f / 5f + 32f val kelvin get() = celsius + 273.15f } sealed class TemperatureScale(open val unit: String, open val extractValue: (Temperature) -> Float) object CelsiusScale: TemperatureScale("C", { it.celsius }) object FahrenheitScale: TemperatureScale("F", { it.farhenheit }) object KelvinScale: TemperatureScale("K", { it.kelvin }) // default value is Celsius val LocalTemperatureMode = compositionLocalOf<TemperatureScale> { CelsiusScale } @Composable fun TemperatureDisplayer(label: String, value: Temperature) { val t = "${LocalTemperatureMode.current.extractValue(value)} ${LocalTemperatureMode.current.unit}" Row { Text(label, Modifier.padding(5.dp)) Text(t, Modifier.padding(5.dp)) } } @Composable fun TemperaturesDisplayer(temperatures: Map<String, Temperature>) { LazyColumn { items(temperatures.entries.toList(), { it.key }) { entry -> TemperatureDisplayer(entry.key, entry.value) } } } @Composable fun TemperatureScaleSelector(scale: TemperatureScale, onScaleChanged: (TemperatureScale) -> Unit) { Row { // to use subclasses, the reflect dependency must be installed TemperatureScale::class.sealedSubclasses.forEach { s -> Row(Modifier.padding(10.dp)) { RadioButton(s == scale::class, onClick={ s.objectInstance?.let { onScaleChanged(it) } }) Text(s.objectInstance?.unit ?: "") } } } } @Composable fun EnhancedTemperaturesDisplayer(temperatures: Map<String, Temperature>) { var temperatureScale by remember { mutableStateOf<TemperatureScale>(CelsiusScale) } Column { TemperatureScaleSelector(temperatureScale) { temperatureScale = it } CompositionLocalProvider(LocalTemperatureMode provides temperatureScale) { TemperaturesDisplayer(temperatures) } } } @Composable fun SampleTemperaturesDisplayer() { val map = mapOf( "Absolute zero" to Temperature(-273.15f), "Freezing point of water" to Temperature(0f), "Human body temperature" to Temperature(37f), "Boiling point of water" to Temperature(100f), "Burning point of paper" to Temperature(218f) ) EnhancedTemperaturesDisplayer(temperatures = map) }
⚠ It is not recommended to put a ViewModel inside a CompositionLocal to allow all the components of the hierarchy to pick the elements they are interested in. It is better to supply as arguments of the functions only the parts of the ViewModel that are required by the component.
Using a ViewModel
- Rather than creating numerous state variables in a composable, one can use a central source of data with a ViewModel
-
A ViewModel can contain properties including:
- Mutable states
- LiveData
- Flow
- A ViewModel survives configuration changes (e.g. destruction of the activity due to a rotation) contrary to composables
Example : a composable using a ViewModel for a counter incremented by clicks
class CrazyClickingViewModel: ViewModel() { private var _counter by mutableStateOf(1) val counter get() = _counter fun incrementCounter() { _counter++ } val tick = liveData<Int> { var i = 0 while (true) { emit(i++) delay(100L) } } private var backgroundColorIndex by mutableStateOf(0) companion object { val ALL_COLORS = arrayOf(Color.Blue, Color.White, Color.Red) } fun changeBackgroundColor() { backgroundColorIndex = (backgroundColorIndex + 1) % ALL_COLORS.size } val backgroundColor by derivedStateOf { ALL_COLORS[backgroundColorIndex] } } @Composable fun CrazyClicking() { // dependency to use the viewModel function: implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1" val vm: CrazyClickingViewModel = viewModel() val tick by vm.tick.observeAsState() LaunchedEffect(tick?.div(100) ?: 0) { vm.changeBackgroundColor() } Column( Modifier .background(color = vm.backgroundColor) .clickable { vm.incrementCounter() }) { Text("${vm.counter}") Text("${tick}") } }