https://kotlinlang.org logo
#compose
Title
# compose
c

CLOVIS

10/28/2023, 7:19 PM
My understanding of Compose is: • when a
State
object changes, the runtime is notified that a recomposition may be needed, • blocks which read the state are recomposed. This would mean that changes to state happening in non-composable blocks are "invisible" to Compose: it knows the state has changed, but it doesn't know where. Compose cannot recompose non-composable blocks of code, anyway. To illustrate this, consider a simple non-composable DSL that lets the user create elements, which are later displayed.
Copy code
class Elements {
    val elements = ArrayList<String>()

    fun element(title: String) {
        elements += title
    }
}

@Composable
fun Foo(block: Elements.() -> Unit) {
    println("Foo recomposes")

    val elements = remember(block) { Elements().apply(block) }.elements

    for (element in elements) {
        Text("Element: $element")
    }
}
At first, this looks normal:
Copy code
@Composable
fun Main() {
    println("Main recomposes")

    Foo {
        println("Foo's block is executed")

        element("First element")
        element("Second element")
    }
}
Both items are displayed, and the output is as we expect:
Copy code
Main recomposes
Foo recomposes
Foo's block is executed
Of course, no state changed, so whether or not Compose detects changes is moot. Let's change the callsite:
Copy code
@Composable
fun Main() {
    println("Main recomposes")
    val elements = remember { mutableStateListOf<String>() }

    TextButton({ elements += List(Random.nextInt(1, 10)) { "Value: ${Random.nextUInt()}" } }) {
        Text("Generate new elements")
    }

    Foo {
        println("Foo's block is executed")

        for (element in elements) {
            element(element)
        }
    }
}
When pressing the button,
Main recomposes
is printed again, but nothing else happens. In my mental model, this is easily explained: •
elements
has changed, so Compose knows it must recompose
Main
• The lambda passed to
Foo
captures
elements
. Compose compares it with the lambda passed during the previous composition;
elements
is referentially-equal to the previous value (it's a mutable list, the reference doesn't change). • Compose decides that
Foo
doesn't need recomposing, since its inputs are the same. So far, so good. However, this is not how
LazyColumn
behaves:
Copy code
@Composable
fun Main() {
    val elements = remember { mutableStateListOf<String>() }

    TextButton({ elements += List(Random.nextInt(1, 10)) { "Value: ${Random.nextUInt()}" } }) {
        Text("Generate new elements")
    }    

    LazyColumn {
        items(elements) {
            Text(it)
        }
    }
}
Now, pressing the button does update the view. How does
LazyColumn
manage to recompose, here? I can't figure out the difference: • both are
@Composable
• both have a single lambda parameter that is not
@Composable
• both only read the State value in the non-recomposable lambda • both capture a reference which doesn't change, so an equality check on the lambda cannot consider it to have changed
👀 1
h

hfhbd

10/28/2023, 7:45 PM
Why did you define elements = remember {}.elements? And not elements = remember { .elements }? And it will work if Elements.elements is MutableStateList, I guess.
And I guess, LazyColumn uses a MutableStateList too? 🤔
c

CLOVIS

10/28/2023, 7:49 PM
Why not elements = remember ( .elements }
It doesn't make a difference,
block
never changes so the remember won't ever be triggered anyway
It will work if Elements.elements is MutableStateList
Try it, it won't.
Foo
doesn't get recomposed, so the lambda isn't called. What the lambda does is irrelevant if it doesn't run in the first place.
s

shikasd

10/29/2023, 1:17 AM
Your Foo impl reads block inside remember, which is only executed during the first composition. As block changes, remember is not executed again, so you get the old elements list instead. This also prevents further recompositions, because you don't read state during recomposition (remember is not executed), so you don't get any updates. You can wrap creating Elements object with
derivedStateOf
, which should be close to what you need to get updates
👆🏻 1
c

CLOVIS

10/29/2023, 10:43 AM
As block changes, remember is not executed again, so you get the old elements list instead.
I thought remember would recompute its result when its dependencies change? That's why I wrote
remember(block) { … }
.
You can wrap creating Elements object with
derivedStateOf
, which should be close to what you need to get updates
I doesn't change anything. I think the problem happens before that, though, because the println at the start of the
Foo
never prints after the first composition, so I don't think Compose even reaches the
remember
/`derivedStateOf` .
After testing a bit more, I am even more convinced that the problem is the lambda's equality. At the callsite, if I replace the
mutableStateListOf
Copy code
@Composable
fun Main() {
    val elements = remember { mutableStateListOf<String>() }

    …
}
…by a
mutableStateOf<List>
:
Copy code
@Composable
fun Main() {
    val elements = remember { mutableStateOf(emptyList<String>()) }

    …
}
then
Foo
is recomposed, and everything updates correctly (presumably because
Foo
's lambda captures
elements
, which gets a new identity once we assign it again). This is even weirder, though, because
LazyColumn
does get recomposed by a
mutableStateListOf
.
This is well explained by the documentation: https://developer.android.com/jetpack/compose/lifecycle#skipping, paraphrasing: > There are some important common types that the compose compiler will treat as stable, even though they are not explicitly marked as stable by using the
@Stable
annotation: > • All Function types (lambdas) This explains why
Foo
doesn't recompose. However, by that logic,
LazyColumn
cannot recompose either. I'm starting to wonder if the compiler has a case specifically for
LazyColumn
and
LazyRow
? All the rules seem to imply that it really shouldn't behave like this.
s

shikasd

10/29/2023, 2:20 PM
Lol no,
LazyColumn
just uses the derived state to trigger updates. You just need to do
val elements by remember(block) { derivedStateOf { Elements().apply(block).elements } }
I thought remember would recompute its result when its dependencies change? That's why I wrote remember(block) { … } .
My bad, should have clarified this better. The state is read inside the block and that changes, so you want to subscribe to updates somehow. Block instance is the same on recomposition because you get memoized lambda (I think it is memoized keyed with state instance)
a

Albert Chang

10/29/2023, 2:26 PM
As Andrei said using
derivedStateOf
does work. Also,
changes to state happening in non-composable blocks are "invisible" to Compose: it knows the state has changed, but it doesn't know where. Compose cannot recompose non-composable blocks of code, anyway.
This is not correct. As long as the block containing state reads is invoked in a scope that is observed by compose, the state reads will be recorded. There are three kinds of scopes that are observed in compose: composition scope, layout scope and draw scope, of which layout scope and draw scope are both non-composable scope.
Copy code
Box(
    modifier = Modifier
        .layout { measurable, constraints ->
            // Layout scope
            val placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                placeable.place(0, 0)
            }
        }
        .drawBehind {
            // Draw scope
        }
) {
    // Composition scope
}
Changes of the states read (directly or indirectly) in these scopes will cause the rerun of the corresponding phase (recomposition / relayout / redraw).
s

shikasd

10/29/2023, 2:30 PM
That's also true, but I don't think OP uses layout/draw to observe anything. Another important thing to understand is that Compose determines observation scope by essentially running try/catch around functions, so if you have a state read in nested non composable function executed inside composition, you that state recorded as read.
Also also, layout block is technically two observation scopes, one for measure and one for placement. If you read the state inside placement scope, only that part will be executed.
z

Zach Klippenstein (he/him) [MOD]

10/29/2023, 5:11 PM
c

CLOVIS

10/29/2023, 9:08 PM
@Zach Klippenstein (he/him) [MOD] sorry, I didn't know about that rule.
You just need to do val elements by remember(block) { derivedStateOf { Elements().apply(block).elements } }
Ah, sorry. I thought you meant using derivedStateOf by itself, without remember. I'll try that tomorrow.
👍🏻 1
remember(block) { derivedStateOf(…) }
does not work when the call-site uses
MutableStateList
, but it does work when the callsite uses
State<List<>>
. Are you sure this isn't caused by the lambda being stable because it captures no mutable references?
s

shikasd

10/31/2023, 9:08 PM
No, it should be the same regardless In your case it does indeed recreate a lambda, which causes reexecution of remember, but it should record state reads inside block and refresh derived state the same way
c

CLOVIS

11/02/2023, 9:42 PM
I really don't understand what's the code I should be writing. Let's take another example:
Copy code
@Composable
fun Foo(block: () -> Unit) {
    println("Foo recomposes")
    block()
}

@Composable
fun Main() {
    val elements = remember { mutableStateListOf<Int>() }

    Foo {
        for (element in elements)
            println("Element: " + element)
    }

    Button(onClick = { elements.add(Random.nextInt()) }) {
        Text("Generate elements")
    }
}
This does print more and more elements the more times the button is pressed. I don't understand what the difference between this and my code is. Even if I call the lambda directly as the first line in my composable, it doesn't recompose when the list changes.
s

shikasd

11/02/2023, 11:52 PM
I'm not exactly sure what's the thing you are struggling with, I just ran your sample with modification (
derivedStateOf
) I suggested earlier and it works as expected:
Copy code
class Elements {
    val elements = ArrayList<String>()

    fun element(title: String) {
        elements += title
    }
}

@Composable
fun Foo(block: Elements.() -> Unit) {
    println("Foo recomposes")

    val elements by remember(block) {
        derivedStateOf { Elements().apply(block).elements }
    }

    for (element in elements) {
        Text("Element: $element")
    }
}

@Composable
fun Main() {
    println("Main recomposes")
    val elements = remember { mutableStateListOf<String>() }

    TextButton({ elements += List(Random.nextInt(1, 10)) { "Value: ${Random.nextUInt()}" } }) {
        Text("Generate new elements")
    }

    Foo {
        println("Foo's block is executed")

        for (element in elements) {
            element(element)
        }
    }
}
a

Albert Chang

11/03/2023, 7:55 AM
The problem of your code is that you were putting the invocation of the block inside the lambda of
remember
, which is not observed.