I'm learning Jetpack Compose and created a simple example to understand state management. I have a button that updates a width
variable, and I want to calculate a size
based on the width
and height
variables. However, the size
variable is not updating as expected when width
changes.
Here is the simplified code to demonstrate the issue:
Example 1 (Not Working):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
// Issue in this line. When width or height changes, size is not recalculated.
// size remains the same as initial state Size(0, 0).
// I expect that the size to be always sync with the width and height
var size = Size(width, height)
Button(
onClick = { width++ },
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
println(size)
println(width)
}
}
) {
Text("OK")
}
}
When I click the button, width
increments correctly, but size
always prints Size(0.0, 0.0)
when I drag the button. I tried using mutableStateOf
for size
, but it still doesn't work.
Example 2 (Using derivedStateOf, Working):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
val size by derivedStateOf { Size(width, height) }
Button(
onClick = { width++ },
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
println(size)
println(width)
}
}
) {
Text("OK")
}
}
The only solution that works is using derivedStateOf
, but the documentation states that derivedStateOf
should be used when inputs are changing more often than needed for recomposition, which is not the case here.
You should use the
derivedStateOf
function when your inputs to a composable are changing more often than you need to recompose. This often occurs when something is frequently changing, such as a scroll position, but the composable only needs to react to it once it crosses a certain threshold.derivedStateOf
creates a new Compose state object you can observe that only updates as much as you need. In this way, it acts similarly to the Kotlin FlowsdistinctUntilChanged()
.Caution:
derivedStateOf
is expensive, and you should only use it to avoid unnecessary recomposition when a result hasn't changed.
Example 3 (Seems to Work, But Why?):
@Composable
@Preview
fun App() {
var width by remember { mutableStateOf(0f) }
var height by remember { mutableStateOf(0f) }
val size by mutableStateOf(Size(width, height))
println(width)
println(size)
Button(
onClick = { width++ }
) {
Text("OK")
}
}
In this example, size
appears to update correctly, but I don't understand why.
Question:
I understand that derivedStateOf
is often used for performance optimizations, but I'm trying to understand why size
doesn't update correctly with mutableStateOf
or as a regular variable in the first example. Also, why does the third example seem to work?
This is a bit tricky. In addition to the other answers I'll try to explain in more detail what exactly is going on in your three examples.
In your first example, you pass a lambda to pointerInput
(the part in the curly braces). That means you only pass a function: The code in the lambda isn't actually executed right away, you just provide pointerInput
with a function that can be executed later. Since you access variables in your lambda that are outside of the lambda Kotlin captures the values of the variables in a Closure. That just means the values are stored somewhere so they can be later accessed when the lambda is actually executed.
The two variables that are accessed by the lambda are size
and width
. At the time pointerInput
is first called size
is Size(0f, 0f)
, so that object is captured and for width
(at least is seems so, read on to learn more) 0f
is captured. So whenever the lambda is executed, these two values are accessed. That's why you only see Size(0f, 0f)
.
The more interesting question now is why you do see all changes to width
since it is captured the same as size
. The difference is that width
is actually a delegated value because you declared it with by
. That is just syntax sugar in Kotlin so in your code you can access width
as a Float
where the actual object is a MutableState<Float>
. During compilation Kotlin replaces your Float
variable width
with a MutableState<Float>
and everywhere where you access the variable it actually calls width.value
.
That means that in your pointerInput
lambda you actually access an object of type MutableState<Float>
(and then call value
) when accessing width
. And that's the difference to size
: The lambda captures this MutableState<Float>
object, but any changes to width
actually only update this same object's internal value
property, it never creates a new object. That is in contrast to size
where everytime a new Size(...)
object is created. That is why width
works and size
doesn't, so it is more like a coincidence that width
works at all.
When width
changes a recomposition is triggered that actually executes pointerInput
again, so one might think that all of the above doesn't matter because everytime a new lambda is passed to pointerInput
with the then-current objects, but pointerInput only updates the lambda (and restarts it) when the key(s) you provide as parameters change. And since you specified Unit
for that key pointerInput always keeps the old lambda that was created the first time pointerInput was called. This is explained in more detail in the documentation of pointerInput.
You can simply fix your code if you provide size
as the key (instead of Unit
), then everytime that changes the values in the lambda are captured again and everything works as intended.
Now on to your second example which fortunately is much easier: Since you also use derivedStateOf
with by
delegation you actually have a State object under the hood, like width
, that is captured in the lambda. It works for the same reasons why width
worked in the first example.
Except there is one caveat: Since you didn't remember
the derived state on each recomposition a new state is created and saved in size
. That doesn't matter though because the lambda uses only the first state object that was created this way, and that automatically updates when width
(or height
, for that matter) changes, that's how derivedStateOf
works. But you should better also remember
that state so it isn't needlessly recreated on recomposition.
And in your third example you do not even need a mutable state since the print statements are not placed inside a lamba:
val size = Size(width, height)
This also simply works as expected.