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?
Best Answer
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 providepointerInput
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
andwidth
. At the timepointerInput
is first calledsize
isSize(0f, 0f)
, so that object is captured and forwidth
(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 seeSize(0f, 0f)
.The more interesting question now is why you do see all changes to
width
since it is captured the same assize
. The difference is thatwidth
is actually a delegated value because you declared it withby
. That is just syntax sugar in Kotlin so in your code you can accesswidth
as aFloat
where the actual object is aMutableState<Float>
. During compilation Kotlin replaces yourFloat
variablewidth
with aMutableState<Float>
and everywhere where you access the variable it actually callswidth.value
.That means that in your
pointerInput
lambda you actually access an object of typeMutableState<Float>
(and then callvalue
) when accessingwidth
. And that's the difference tosize
: The lambda captures thisMutableState<Float>
object, but any changes towidth
actually only update this same object's internalvalue
property, it never creates a new object. That is in contrast tosize
where everytime a newSize(...)
object is created. That is whywidth
works andsize
doesn't, so it is more like a coincidence thatwidth
works at all.When
width
changes a recomposition is triggered that actually executespointerInput
again, so one might think that all of the above doesn't matter because everytime a new lambda is passed topointerInput
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 specifiedUnit
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 ofUnit
), 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
withby
delegation you actually have a State object under the hood, likewidth
, that is captured in the lambda. It works for the same reasons whywidth
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 insize
. That doesn't matter though because the lambda uses only the first state object that was created this way, and that automatically updates whenwidth
(orheight
, for that matter) changes, that's howderivedStateOf
works. But you should better alsoremember
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:
This also simply works as expected.