It seems like text input is lagging behind
# compose
j
It seems like text input is lagging behind
I hope you can see it on these videos (On my device the videos are blinking sadly)
but I typed on my keyboard on the emulator:
my name is joost
with dispatchers.main, the final result was:
m name ist jooyt
which is very weird 😄
with the immediate dispatcher it worked as expected
Btw I use a
MutableStateFlow<ViewState>
, where the userName is just a String in the ViewState and it is updated as such:
Copy code
viewState.value = viewState.value.copy(
    userName = userName,
    isButtonEnabled = !userName.isBlank() && !viewState.value.password.isNullOrBlank()
)
so nothing weird there
a
generally speaking the dispatcher shouldn't cause anything to be dropped here.
Dispatchers.Main
isn't the intended end state but at worst it should be incurring a single frame of latency before changes are reflected in the UI, not affecting correctness. Can you post some more of the related code you're using to update the StateFlow?
j
@Adam Powell I'm not saying it is dropping information, I am saying that the information comes in late and therefore messes up the order
Using dispatchers.main means the block will run at the end of the queue
and not directly
the main.immediate will directly run the code if it is triggered on the main thread
I think with the Lifecycle helpers like viewLifecycleScope.launchWhenResumed etc. there was also the update to use the immediate dispatcher so it would behave more like LiveData (which also directly sets the value on setValue instead of posting it to the queue)
I think not using the immediate dispatcher is a bad thing as there is a slight delay between the trigger and actually running the the code block
a
if there is only one source of truth then order is the only thing that matters for correctness, not timing. Information coming in a frame late can't disrupt the order.
j
well in my case (I hope you could see it) it actually messes up the typed text
a
I'm not contesting that updating more promptly is more ideal for other reasons, but if it's affecting order, then using immediate is only masking the other problem you're facing 🙂
j
and as I said I don't think I did really weird things
like I use a mutable state flow to store it
is that a weird thing to do?
(I might be wrong in using that component)
a
not necessarily, depending on how the next value for that MutableStateFlow is computed and how many code paths have write access to it
j
in this case, there is only 1 mutable state flow and it is only updated during typing
no other coroutines are launched
a
Can you post some more of the surrounding code from where that update happens?
j
will do
👍 1
Copy code
OutlinedTextField(
            value = viewState.userName ?: "",
            onValueChange = viewModel::updateUserName,
            modifier = Modifier.fillMaxWidth(),
            label = { Text("User name") }
        )
so this is the component
this is in the viewModel:
Copy code
override val viewState = MutableStateFlow(LoginViewState())

    override fun updateUserName(userName: String) {
        viewState.update {
            copy(
                userName = userName,
                isButtonEnabled = viewState.value.password.isNotNullOrBlack() && userName.isNotNullOrBlack()
            )
        }
    }
Copy code
data class LoginViewState(
    override var userName: String? = null,
    override var userNameError: String? = null,
    override var password: String? = null,
    override var passwordError: String? = null,
    override var isLoading: Boolean = false,
    override var isButtonEnabled: Boolean = false
): LoginContract.ViewState
this is the update function:
Copy code
fun <T> MutableStateFlow<T>.update(
    block: T.() -> T
)  {
    value = value.run(block)
}
and this is the viewmodel injection/fetching the view state:
Copy code
@Composable
fun LoginInputComponent(props: LoginContract.Props) {
    val viewModel by instance<LoginContract.Props, LoginContract.ViewModel>(props)
    LoginInput(viewModel.viewState.collectAsState(Dispatchers.Main.immediate).value, viewModel)
}

@Composable
fun LoginInput(viewState: LoginContract.ViewState, viewModel: LoginContract.ViewModel) {
//... OutlinedTextField
the viewmodel is that exact same instance btw anytime this code runs, so no mistakes there
🙂 1
a
thanks, I'm tracing through some of the compose text field source right now to check some theories
j
Copy code
override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val stateFlow = MutableStateFlow(LoginViewState())
        val update = { userName: String ->
            stateFlow.value = stateFlow.value.copy(userName = userName)
        }
        setContent {
            val viewState = stateFlow.collectAsState().value
            OutlinedTextField(
                value = viewState.userName ?: "",
                onValueChange = update,
                modifier = Modifier.fillMaxWidth(),
                label = { Text("User name") }
            )
        }
    }
If I rewrite it like this
it is indeed buggy
I think because of the dispatcher, the
update
will run too late (as a handler.post)
if it would be the immediate dispatcher, it runs in time
in this I notice when I for example hold backspace, the cursor position shifts
a
I suspect that what you're seeing is something that would happen in any situation where there's a little bit of latency, and ideally I would like our components to be tolerant to that
j
yeah makes sense
but it is harder to take into account a method call that just comes in late
This gives no issue at all
Copy code
val viewState = stateFlow.collectAsState(Dispatchers.Main.immediate).value
a
yes, and I would suggest using that as the workaround for now
in this case it depends on what the late method call is operating on. From what I'm reading so far, the text field may be giving you updates based on a stale version of the value that was passed in plus the new changes from the IME
j
could it be that it somehow falls out of the frame because of this:
Copy code
@Composable
fun <T : R, R> Flow<T>.collectAsState(
    initial: R,
    context: CoroutineContext = Dispatchers.Main
): State<R> {
    val state = state { initial }
    onPreCommit(this, context) {
        val job = CoroutineScope(context).launch {
            collect {
                FrameManager.framed {
                    state.value = it
                }
            }
        }
        onDispose { job.cancel() }
    }
    return state
}
in combination with the Dispatchers.Main
a
I would prefer it not matter if it happens a frame or three late 🙂
j
I'll anyway use the workaround for now. Btw there are a bunch of ripple effects happening on the outlined text field, is that an issue I should report? Or is that a wanted effect on the textfield>?
a
what kind of ripple effects?
j
When I press on the input
there is a ripple
a
not sure, probably worth filing as a bug just as confirmation
j
it looks like the videos I take from the emulator can give people epilepsy so I am not sure that will help 😄
I'll might file a bug in that case!
thanks for your help!
👍 1
a
For some additional context, the reason I want to double check the IME pathways here is because on Android that process is also very async and we do a lot of work already to make it work within a reactive controlled component framework here
It may be that ensuring less latency in userland is the only way to deal with this but I'd like to know the options and if we can cover for it a bit more if so
Can you do me a quick favor if you have that code still open? If
AndroidUiDispatcher
is in dev14 can you try
AndroidUiDispatcher.Main
as the context for that collect operation?
j
lemme try!
a
I can't remember if that code made the dev14 build or not
j
I see an AndroidUiDispatcher
seems to work well 🙂
a
Cool, thanks! That's the dispatcher that you'll get for
launchInComposition
, which is probably what the collect extensions should be updated to use. (The former didn't exist when the collect extensions were first written)
It does a little more work between Android Handlers and Choreographer to avoid missing a pending frame
Without running synchronously like immediate dispatchers do
j
Would you then still need the frameManager?
a
The FrameManager is kind of a separate concern, and the shape of that is changing over time too. The new Snapshot system replaces Frames and allows for a more streamlined API for dealing with transactions, and also adds a "global transaction" so the "not in a frame" exception is a thing of the past
(also we'll avoid the confusion when talking about a choreographer frame vs. a snapshot frame 🙂)
In short, modifying MutableState off the main thread is a lot easier now
j
That sounds very interesting :) I'd love to read about it when it's done!
a
After some further reading through the code and thinking on it again I'm inclined to think that your initial assessment was correct and there's not much we can do in the text components to mitigate what happens if you compress any of our types down to a string and then apply it with latency without tradeoffs that would be much creepier in practice
Sorry, it's been a while since we set this up, apologies that it took me a while to reload the context around it 🙂
I'm the meantime, the issue should go away when we update the collect extensions to use launchInComposition
j
@Adam Powell would you suggest the androidUiDispatcher or the immediate dispatcher in this case?
a
Flip a coin. 🙂 Selfishly if the AndroidUiDispatcher introduces any other unexpected side effects I'd like to know early, so feedback there would be welcome
j
Haha okay then I'll use that one for you :)
😄 1