sindrenm
02/27/2024, 7:12 PMBasicTextField2
API, when listening to its changes through its textAsFlow()
, is there a way to know if a text was added programmatically through edit {}
vs. manually from a user? I'm thinking something like this, but due to the asynchronous nature of it all, that doesn't quite cut it:
val textFieldState = TextFieldState("")
var isSettingProgrammatically = false
launch {
textFieldState.textAsFlow()
.dropWhile { isSettingProgrammatically }
.collect { println("change: $it") }
}
launch {
delay(2.seconds)
isSettingProgrammatically = true
textFieldState.edit { replace(0, length, "value" }
isSettingProgrammatically = false
}
where the "value"
from the second launch
should not be printed from the text flow collection.Stylianos Gakis
02/27/2024, 7:18 PMsindrenm
02/27/2024, 7:25 PMlaunch {
textFieldState.textAsFlow()
.drop(1) // exclude initial value
.debounce(1000) // wait for user to finish typing
.collect { trackInputFieldUsage() }
}
launch {
val default = loadDefaultValueFromApi()
textFieldState.edit {
if (textFieldState.text.isNotBlank()) {
replace(0, length, default)
}
}
}
sindrenm
02/27/2024, 7:31 PMStylianos Gakis
02/27/2024, 7:32 PMsindrenm
02/27/2024, 7:33 PMsindrenm
02/27/2024, 7:33 PMisNotBlank()
check is forStylianos Gakis
02/27/2024, 7:33 PMsindrenm
02/27/2024, 7:36 PMdropWhile {}
, then we would get a TextFieldCharSequence
passed in to it, but that doesn't have any information about its source.sindrenm
02/27/2024, 7:37 PMtextFieldState.textAsFlow()
.drop(1) // exclude initial value
.dropWhile { it.isCreatedProgrammatically }
.debounce(1000) // wait for user to finish typing
.collect { trackInputFieldUsage() }
Stylianos Gakis
02/27/2024, 7:56 PMisSettingProgrammatically = true
textFieldState.edit { replace(0, length, "value" }
isSettingProgrammatically = false
in-between the two there may always be some input from the user in-between that you’d miss from tracking, right?sindrenm
02/27/2024, 8:05 PMisSettingProgrammatically
route doesn't feel ideal, regardless.Stylianos Gakis
02/27/2024, 8:06 PMval textFieldState = rememberTextFieldState()
LaunchedEffect(textFieldState) {
var settingFromBackend = false
val backendTextJob = launch {
val backendText = fetchBackendText()
try {
settingFromBackend = true
textFieldState.edit { replace(0, length, backendText) }
} finally {
settingFromBackend = false
}
}
textFieldState.textAsFlow()
.drop(1)
.onEach { backendTextJob.cancel() }
.debounce(1_000)
.collect {
if (!settingFromBackend) {
trackInputFieldUsage(it.toString())
}
}
}
BasicTextField2(textFieldState)
Stylianos Gakis
02/27/2024, 8:12 PMfinally
to run, so you will in fact track that too. Scratch that thensindrenm
02/27/2024, 8:25 PMTextFieldCharSequence
or some wrapper around it.Stylianos Gakis
02/27/2024, 8:30 PMval textFieldState = rememberTextFieldState()
LaunchedEffect(textFieldState) {
var textFromBackend: String? = null
val backendTextJob = launch {
val backendText = fetchBackendText()
textFromBackend = backendText
textFieldState.edit { replace(0, length, backendText) }
}
textFieldState.textAsFlow()
.drop(1)
.onEach { backendTextJob.cancel() }
.dropWhile {
(it.toString() == textFromBackend).also { textFromBackend = null }
}
.debounce(1_000)
.collect {
trackInputFieldUsage(it.toString())
}
}
BasicTextField2(textFieldState)
This perhaps? 🤔
Instead of relying on a flag, just store the text from backend, and make sure to drop the first string that matches it. But no more, since the user may just happen to type the same text themselves.Stylianos Gakis
02/27/2024, 8:38 PMdropFirst
flow operator, which just drops the first time it encounters a match
fun <T> Flow<T>.dropFirst(predicate: suspend (T) -> Boolean): Flow<T> = flow {
var hasAlreadyDroppedFirstMatch = false
collect { value ->
if (hasAlreadyDroppedFirstMatch) {
emit(value)
} else if (predicate(value)) {
hasAlreadyDroppedFirstMatch = true
} else {
emit(value)
}
}
}
And then do
val textFieldState = rememberTextFieldState()
LaunchedEffect(textFieldState) {
var textFromBackend: String? = null
val backendTextJob = launch {
val backendText = fetchBackendText()
textFromBackend = backendText
textFieldState.edit { replace(0, length, backendText) }
}
textFieldState.textAsFlow()
.drop(1)
.onEach { backendTextJob.cancel() }
.dropFirst {
(it.toString() == textFromBackend)
}
.debounce(1_000)
.collect {
trackInputFieldUsage(it.toString())
}
}
BasicTextField2(textFieldState)
? 😅Zach Klippenstein (he/him) [MOD]
02/28/2024, 4:01 AMStylianos Gakis
02/28/2024, 7:48 AMsindrenm
02/28/2024, 8:25 AMdropFirst
, although it does clutter the code up quite a bit. And the no-op input transformation would then need to be applied only while we're inserting the text through edit {}
, since we actually want the user to be able to input stuff so we can track usages. Given that, by a no-op input transformation, you mean one that just directly calls valueWithChanges.revertAllChanges()
.Stylianos Gakis
02/28/2024, 8:27 AMsindrenm
02/28/2024, 8:28 AMsindrenm
02/28/2024, 8:29 AMZach Klippenstein (he/him) [MOD]
02/28/2024, 1:06 PMStylianos Gakis
02/28/2024, 1:31 PMsindrenm
02/28/2024, 1:33 PMTextFieldState
, which exists in a UI State that the view model owns.sindrenm
02/28/2024, 1:59 PMtextAsFlow()
emits on interactions that aren't just text changes, as well. For instance, making a selection, entering the text field (but only if it's non-empty, for some reason), and when moving the cursor between text in the text field. That changes things for us, as well, I think. 🤔Stylianos Gakis
02/28/2024, 2:00 PMmap { it.toString() }.distinctUntilChanged()
on the Flow
should eliminate this issue for you heresindrenm
02/28/2024, 2:00 PMsindrenm
02/28/2024, 2:00 PMStylianos Gakis
02/28/2024, 2:00 PMsindrenm
02/28/2024, 2:01 PMZach Klippenstein (he/him) [MOD]
02/28/2024, 7:38 PMStylianos Gakis
02/28/2024, 8:01 PM@param inputTransformation Optional [InputTransformation] that will be used to transform changes to the [TextFieldState] made by the user. The transformation will be applied to changes made by hardware and software keyboard events, pasting or dropping text, accessibility services, and tests. The transformation will _not_ be applied when changing the [state] programmatically, or when the transformation is changed. If the transformation is changed on an existing text field, it will be applied to the next user edit. the transformation will not immediately affect the current [state].
Tbh with this text in mind, I would definitely reach for this with much more confidence. I just haven’t used the new btf2 enough so it’s true that on first thought it didn’t feel like the right one.sindrenm
02/28/2024, 8:07 PMHalil Ozercan
02/28/2024, 8:07 PMOutputTransformation
. AFAIR we couldn't find a name that could reflect its both filtering and observation features, they are very different use cases.
I think there's a fundamental problem here about the relation between "editing" and "the state". TextFieldState
is the latter, only responsible for holding the state and applying whatever is coming from the editing process. BasicTextField2
is the editor responsible for editing. It manages all the input systems like software keyboard(IME), hardware keyboard, or a11y. These systems just send intentions/actions of how they want the state to change.
InputTransformation
is mostly about editing. It gives developers the delta that's proposed by the input system and asks what should be done about it before it's committed to the state. That's also why it would be kind of be useless to apply InputTransformation
to edit
calls since both the source and interceptor would be the same (developer's code).
If you want to observe any kind of change that the state goes through, it's perfectly normal to reach to a tool like textAsFlow
. However, it will only give you a flow of the state itself between snapshots, before and after applying the atomic changes. I think what you really want is a combination of both editing and the state. IMO there's no perfect way to achieve that.
To answer your question, InputTransformation
is definitely a safe API to use for this purpose although its name may suggest otherwise.Halil Ozercan
02/28/2024, 8:09 PMStylianos Gakis
02/28/2024, 8:13 PMInputTransformation
. But since I now understand that just doing programmatic changes to the state directly would not go through this transformation, right? If that’s the case, it would be possible to still put a number there if a developer misuses a textFieldState.edit { replace(…) }
call, right?sindrenm
02/28/2024, 8:19 PMInputTransformation
for this, it's also somewhat limited.
For one, there can only be a single InputTransformation
per BTF2, so if I'm already using a more generic one like an IntegerInputTransformation
or whatever, then I would need to do some funky business. On the other hand, there can be multiple places collecting from a textAsFlow(): Flow
.
There's also the ability of having access to Flow
operators like debounce()
, which is not as easily handled inside of an InputTransformation
. I could have a separate Job
and a CoroutineScope
, then delaying in there and cancelling and recreating jobs (or cancelling children), but that's already a lot more cumbersome.Halil Ozercan
02/28/2024, 8:22 PMsindrenm
02/28/2024, 8:22 PMTextFieldState
, which I suppose doesn't make much sense as per your explanation.sindrenm
02/28/2024, 8:22 PMStylianos Gakis
02/28/2024, 8:26 PMI really want is to observe the editing through aHow come you have this limitation?TextFieldStateH
Halil Ozercan
02/29/2024, 1:40 AMTextFieldState
with the granularity that you get from InputTransformation r.android.com/2982295 It's not very different than notifying the IME about the changes happening in TextFieldState
.Halil Ozercan
02/29/2024, 1:43 AMInputTransformation
for observation which I forgot to mention; undo/redo calls. InputTransformation
is not triggered when undo/redo is requested.Stylianos Gakis
02/29/2024, 7:51 AM