molecule: Scheduler breaks keyboard behavior for Compose UI consumers
Compose UI’s CoreTextField
is very picky about getting immediate updates to its values. The common (but useless) example seen in the docs, where the text state is held in a by remember { mutableStateOf("") }
, has no problems updating the text field when typing on the keyboard quickly. However, if you provide the text value from something that doesn’t provide updates synchronously from onValueChange
, the text field starts behaving erratically; characters randomly disappear, the cursor moves around in its own, and other weird stuff.
This issue made us realize that mapping UI state on a background thread will never work. So we’ve been running our presenter composition on the main thread, using an immediate monotonic frame clock:
private object ImmediateMonotonicFrameClock : MonotonicFrameClock {
override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
return onFrame(System.nanoTime())
}
}
This completely solved the keyboard glitchiness.
We’re now looking to migrate to Molecule. But in doing so, we’ve seen the keyboard glitchiness return!
I somewhat narrowed it down to behaviors that depend on the combination of the Molecule’s Dispatcher
and MonotonicFrameClock
:
Dispatcher | MonotonicFrameClock | Behavior |
---|---|---|
AndroidUiDispatcher |
Choreographer |
Bad |
AndroidUiDispatcher |
ImmediateMonotonicFrameClock |
Less bad (but still bad) |
Dispatchers.Main |
Choreographer |
Bad |
Dispatchers.Main |
ImmediateMonotonicFrameClock |
Good |
More specifics on the glitchiness:
Try spamming a character quickly. Occasionally, some will be dropped. When that happens, this appears in logcat:
getSurroundingText on inactive InputConnection
beginBatchEdit on inactive InputConnection
getTextBeforeCursor on inactive InputConnection
getTextAfterCursor on inactive InputConnection
getSelectedText on inactive InputConnection
endBatchEdit on inactive InputConnection
An easy way to check if the issue is happening is to hold the backspace button when there’s some text present. With the issue, the backspace will randomly “stop working”; characters will stop being deleted even though you’re still pressing backspace.
Honestly, I’m not positive as to whether this is Molecule’s or CoreTextField
’s fault. Input appreciated.
Repro project with those dispatcher/frameclock modes
About this issue
- Original URL
- State: open
- Created 3 years ago
- Comments: 17 (8 by maintainers)
Commits related to this issue
- fix(FilterTextField): Fix synchronization issues If the text of a `TextField` is updated from a flow, this can lead to a jumping cursor and swallowed characters [1]. For a discussion of the issue see... — committed to oss-review-toolkit/ort-workbench by mnonnenmacher a year ago
- fix(FilterTextField): Fix synchronization issues If the text of a `TextField` is updated from a flow, this can lead to a jumping cursor and swallowed characters [1]. For a discussion of the issue see... — committed to oss-review-toolkit/ort-workbench by mnonnenmacher a year ago
I put together a sample that allows for easy demoing of the issue and switching between the two clock types.
RecompositionClock.Immediate
does seem to fix the weird behaviour of input fields.https://user-images.githubusercontent.com/1245751/182053296-14df5da3-e45b-4747-aa10-fd49e74f3a54.mp4
I have no had a chance to look, no. I can try to look when I’m back working on this library as part of the Kotlin 1.6.20 upgrade to Compose.
Upon further investigation, we’ve settled on a new architecture that slightly disobeys unidirectional data flow to work around this. It turns out that
BasicTextField2
encourages consumers of the API to hoist itsTextFieldState
into the presentation layer. Our team cannot do this since we share our presentation layer between iOS and Android. As a result, we’ve decided that asynchronously sending text field state to our presenters and then back to the text field is an anti-pattern.Instead, we’ll do a few things differently.
For other people encountering this issue in a multiplatform context, I hope our research helps you out.
We were seeing this issue using even the immediate recomposition mode. Switching to use
BasicTextField2
seems to resolve the weird interaction.I got curious about this behavior, and got inspired by watching “Reimagining text fields in Compose” by Zach Klippenstein https://www.droidcon.com/2023/07/20/reimagining-text-fields-in-compose/ to give it a shot to see how these two would play together.
After doing some trials on top of the repro made by Chris Horner, seems like TextFieldState (from BasicTextField2) is what will solve this problem for molecule without having to use the Immediate RecompositionMode.
Also no need to break the UI -> Presenter -> UI as prashanOS described. Nor do you have to have to have another state in the composable UI itself which would effectively create two sources of truth for that text.
This PR that I made here along with the video here shows that it all seems to work well together, while still allowing you to do other async work which doesn’t affect TextFieldState which is also provided to the UI Composable though molecule
The gist is that inside molecule you can do
val textFieldState = remember { TextFieldState("") }
inside molecule’sbody
composable and provide it inside your exposed Model directly.Actually, disregard the previous comment. Tested a bit more. Breaking the ui -> presenter -> ui loop is not necessary. Simply having a mutable state that you update within the
onValueChange
lambda is sufficient: