Jetpack Compose – How to Fix Size Variable Not Updating in Android

androidandroid-jetpack-composecompose-recompositionkotlin

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 Flows distinctUntilChanged().

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 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.

Related Question