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

Alexander Maryanovsky

09/19/2023, 6:05 PM
I’ve encountered a problem, which I think is a known shortcoming, but I wonder if there are any good general solutions to it. When a composition is responding to external events (e.g. a key event), and the response depends on and modifies compose “state”, naive code will assume that recomposition will happen between external events, but this is not guaranteed. <continued in thread>
For example, a naive
TextField
implementation can do something like this
Copy code
@Composable
fun TextField(
    value: String,
    onValueChanged: (String) -> Unit
) {
    Box(
        modifier = Modifier
           .onKeyEvent { event ->
                onValueChanged(value + event.keyChar)
           }
    ) {
        ...
    }
}
But if two key events are received before a recomposition occurs, value will not have changed between them, and so only the last event will actually affect the value.
The real
TextField
implementation uses an internal buffer which it modifies in response to events and resets when a (re)composition occurs.
But this is somewhat surprising, and hard(er) to work around if the state is more complex than
TextFieldValue
.
c

Casey Brooks

09/19/2023, 6:16 PM
Ultimately, you can never be sure of what happens with your
onValueChanged
. The parent might process the value synchronously, or slowly. It might post the value to another thread for processing. It might completely ignore it. Similar, you can’t ever be sure of what
value
is either. It might be hardcoded, or in a
State
variable, or something else. Because of this, your
TextField
composable shouldn’t assume anything about what happens outside itself. A safer practice is to either do what the real
TextField
does and maintain/update a copy of its own internal state which it can make guarantees about, or else be completely passive and only pass the keyEvent up, so that whatever parent is listening for the value can append it to the
value
State itself.
I find the MVI pattern to be really helpful for safely managing updates to complex states. You wrap each possible change to the state into some “Input” type, and from a central location at the root of your UI screen process each change one-by-one with the previous State, producing a new State. You get guarantees that each update is atomic, and if you use sealed classes to wrap your “inputs”, you can pass multiple types of updates through a single
(Input)->Unit
lambda passed through the whole UI tree.
a

Alexander Maryanovsky

09/19/2023, 6:22 PM
I don’t think the problem here is that the code assumes anything about
onValueChanged
. The problem is in the timing of receiving feedback from
onValueChanged
. By the way, using an internal buffer with
onValueChanged = {}
can actually be problematic, as it will continue to fill up. Not entirely sure how
TextField
deals with it.
Passing
Input
up the tree can work for an application, but not for a standalone utility widget.
c

Casey Brooks

09/19/2023, 6:36 PM
The code assumes that there will be some parity between
value
and how it processes
onValueChanged
, but you don’t actually know that they two are related. But if know you’re working directly with a
State
, you get lots of guarantees about the value and ordering of the data within a local context. For example,
onKeyEvent
gets dispatched on the UI thread, and
States
will immediately reflect whatever value you set on the same thread. So multiple quick updates to an internal
State
will correctly order and update those events, since they’re in the same thread, and therefore see the same Snapshot. But if the value is passed up to a parent composable, Compose doesn’t know that
value
and
onValueChanged
are meant to be part of the same snapshot from within the
TextField
, so it can’t guarantee that calling
onValueChanged
is synchronous with respect to
value
, and so you only get a new
value
upon recomposition.
This snippet is effectively the “internal buffer” that the Material
TextField
uses.
Copy code
@Composable
fun TextField(
    value: String,
    onValueChanged: (String) -> Unit
) {
    val _state by remember(value) { mutableStateOf(value) }

    Box(
        modifier = Modifier
           .onKeyEvent { event ->
                _state = _state + event.keyChar
                onValueChanged(_state)
           }
    ) {
        ...
    }
}
a

Alexander Maryanovsky

09/19/2023, 6:41 PM
That will have a problem if called with
onValueChanged = {}
or more realistically perhaps something like
Copy code
onValueChanged = { value = it.substring(0, 10) }
trying to limit the TextField to 10 characters.
But anyway, I understand the buffer workaround. I was wondering if there’s a more general solution.
c

Casey Brooks

09/19/2023, 6:50 PM
I’m not sure if this would work, but one solution might be to pass the value in as a lambda, rather than a specific value. So that each time
onKeyEvent
is called, it’s fetching the latest value from the
State
where it is stored and which receives the
onValueChanged
update, rather than working with the composed value
a

Alexander Maryanovsky

09/19/2023, 6:55 PM
Or just pass the State (but that’s frowned upon)
c

Casey Brooks

09/19/2023, 6:57 PM
The cleanest solution to integrate into the rest of your app is probably to move the necessary logic (like character limit) into the
TextField
and manage its own state internally. Passing the value as a lambda is definitely some non-standard Compose code, too, just like passing the State directly
z

Zach Klippenstein (he/him) [MOD]

09/19/2023, 7:12 PM
This is why we’re exposing TextFieldState for the BasicTextField rewrite
a

Alexander Maryanovsky

09/19/2023, 7:22 PM
So the advice is to always pass a state wrapper?
z

Zach Klippenstein (he/him) [MOD]

09/19/2023, 8:09 PM
I don’t know about “always”, but to solve this, probably yea. With
TextFieldState
you can modify the state whenever and it will immediately be visible to anything using the state.
👍 1
2 Views