https://kotlinlang.org logo
#compose-android
Title
# compose-android
s

sindrenm

02/27/2024, 7:12 PM
Using the new
BasicTextField2
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:
Copy code
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.
s

Stylianos Gakis

02/27/2024, 7:18 PM
May I ask why you want to know this? 👀
s

sindrenm

02/27/2024, 7:25 PM
Yeah, we want to track user input in the text field, but we're also loading some default value for the text field in the background, replacing the text field's content if the user hasn't already written anything into it. Basically this:
Copy code
launch {
  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)
    }
  }
}
And yeah, we don't really want to track us inserting the default value. 😅
s

Stylianos Gakis

02/27/2024, 7:32 PM
What is trackInputFieldUsage? If by the time the backend response comes back, I'd the user is already typing something, do you still want to hijack it and insert the backend response in the middle of it?
s

sindrenm

02/27/2024, 7:33 PM
It's just a call to send a tracking event over to our tracking service.
And no, that's what the (perhaps not super sophisticated, but fine in our case)
isNotBlank()
check is for
s

Stylianos Gakis

02/27/2024, 7:33 PM
Oh you got an if empty check there okay
s

sindrenm

02/27/2024, 7:36 PM
If we use
dropWhile {}
, then we would get a
TextFieldCharSequence
passed in to it, but that doesn't have any information about its source.
Was thinking maybe something like this could be possible if we did:
Copy code
textFieldState.textAsFlow()
  .drop(1) // exclude initial value
  .dropWhile { it.isCreatedProgrammatically }
  .debounce(1000) // wait for user to finish typing
  .collect { trackInputFieldUsage() }
s

Stylianos Gakis

02/27/2024, 7:56 PM
I’m trying to think of something smarter, but I can’t quite get it. I think no matter what, if you do
Copy code
isSettingProgrammatically = 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?
s

sindrenm

02/27/2024, 8:05 PM
Yeah, the
isSettingProgrammatically
route doesn't feel ideal, regardless.
s

Stylianos Gakis

02/27/2024, 8:06 PM
Copy code
val 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)
This is the best I can imagine too. This also gives you the bonus of just stopping the network request completely if you would no longer be interested in it anymore in the first place. Nah doesn’t work
Hmmm although I am now not sure if this works 😄 Since the onEach will cancel the job, and then there will be a debounce, but the cancel will make the
finally
to run, so you will in fact track that too. Scratch that then
s

sindrenm

02/27/2024, 8:25 PM
Hmm, yeah, it's an interesting idea, though. Thanks for testing this out with me! Will probably need to think a little more on this. Since the APIs are still experimental, I wonder if there would be a possibility to add the source of the change into whatever is being sent down the text flow, be that a
TextFieldCharSequence
or some wrapper around it.
s

Stylianos Gakis

02/27/2024, 8:30 PM
Copy code
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() }
    .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.
Oooor, make your own
dropFirst
flow operator, which just drops the first time it encounters a match
Copy code
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
Copy code
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)
? 😅
z

Zach Klippenstein (he/him) [MOD]

02/28/2024, 4:01 AM
You should be able to use a noop InputTransformation to handle input from the user. It won’t fire for programmatic state changes.
s

Stylianos Gakis

02/28/2024, 7:48 AM
Oh, interesting! If I just saw someone do that on a PR I would definitely start questioning if they're just misusing an API that exists for another reason in a way that who knows if it will be kept functioning throughout future versions. Because it's not the main purpose of that API. Do you feel the same here? Do you imagine a future where assuming that you can use InputTransformation for this won't still hold? I am sure you can answer this question with a lot more confidence than most others seeing that you're literally building this thing 😅
s

sindrenm

02/28/2024, 8:25 AM
Interesting idea with
dropFirst
, 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()
.
s

Stylianos Gakis

02/28/2024, 8:27 AM
NoOp I assumed as in it makes no transformations. It just forwards the text as-is, so it does not matter if it’s there or not, it should function the same
s

sindrenm

02/28/2024, 8:28 AM
Oh, right, I see now! And then use the transformation lambda to perform the tracking! I misunderstood the use case for it above. 😅
Yeah, I agree that it feels like using the wrong tool for the job, but it's not a terrible option. 🤔
z

Zach Klippenstein (he/him) [MOD]

02/28/2024, 1:06 PM
I’m curious why you both feel like it’s api misuse? I have a guess but don’t want to bias your answer 😜
s

Stylianos Gakis

02/28/2024, 1:31 PM
In my mind, the name itself makes me feel like this has nothing to do with observing state changes, but everything to do with transforming its Input into a new output in the UI. And since that is the case, I do not expect any guarantees that this will do exactly what I am looking for. And I also do not expect my colleagues reviewing that code not to be confused themselves too.
s

sindrenm

02/28/2024, 1:33 PM
1. Input transformations, to me, seem like they exist to interrupt user all user input and potentially transform it or alter the visuals in other ways (like selection and moving the cursor) before it becomes part of the state. 2. Input transformations in our case live entirely on the UI layer (read: in the composable), and I don't want to couple that logic in any way to tracking, which is currently isolated in a view model. The only thing that ties them together now is the
TextFieldState
, which exists in a UI State that the view model owns.
Hmm, I'm also noticing now that
textAsFlow()
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. 🤔
s

Stylianos Gakis

02/28/2024, 2:00 PM
map { it.toString() }.distinctUntilChanged()
on the
Flow
should eliminate this issue for you here
s

sindrenm

02/28/2024, 2:00 PM
Oh, of course! 🤦
🌟 1
Thanks. Brainfarted a bit there.
s

Stylianos Gakis

02/28/2024, 2:00 PM
Flows are amazing, I tell ya 😄
s

sindrenm

02/28/2024, 2:01 PM
Indeed!
z

Zach Klippenstein (he/him) [MOD]

02/28/2024, 7:38 PM
Cc @Halil Ozercan for this API feedback about InputTransformation. Maybe we named it wrong
👍 1
👀 1
s

Stylianos Gakis

02/28/2024, 8:01 PM
Okay, I read the docs though and it says
Copy code
@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.
s

sindrenm

02/28/2024, 8:07 PM
That certainly makes it feel safer to use it, but it still doesn't feel completely right. The documentation also says it “will be used to transform changes to the TextFieldState made by the user”, and while you could argue that it's “transforming” a change made by the user into a tracking event happening, that's a bit of a stretch, if you ask me.
h

Halil Ozercan

02/28/2024, 8:07 PM
I totally understand this confusion. `InputTransformation`'s name was chosen to reflect its filtering capabilities as well as its role in transformation pipeline that includes
OutputTransformation
. 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.
🌟 1
Btw, thanks for this valuable feedback.
🤗 1
jetpack compose 1
s

Stylianos Gakis

02/28/2024, 8:13 PM
Can I derail the topic a bit here to ask one thing that I am realizing by this. If we would want to somehow completely disallow some input on btf2, like let’s say I simply do not want numbers to be possible to be added to the state. I thought I would be able to disallow this inside
InputTransformation
. 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?
👌 1
s

sindrenm

02/28/2024, 8:19 PM
Thanks for a great explanation! What you say makes sense, and while it would be safe to use an
InputTransformation
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.
h

Halil Ozercan

02/28/2024, 8:22 PM
InputTransformation chaining allows you to combine multiple transformations in whatever order you choose. Just call firstTransformation.then(secondTransformation)
s

sindrenm

02/28/2024, 8:22 PM
But yeah, what I really want is to observe the editing through a
TextFieldState
, which I suppose doesn't make much sense as per your explanation.
Oh, right, I remember chaining being a thing now from the docs. My bad.
s

Stylianos Gakis

02/28/2024, 8:26 PM
I really want is to observe the editing through a
TextFieldStateH
How come you have this limitation?
h

Halil Ozercan

02/29/2024, 1:40 AM
it may be possible to follow these changes on
TextFieldState
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
.
🌟 1
this also overcomes a certain shortcoming of
InputTransformation
for observation which I forgot to mention; undo/redo calls.
InputTransformation
is not triggered when undo/redo is requested.
s

Stylianos Gakis

02/29/2024, 7:51 AM
Wow yes, this looks exactly like what I would reach for intuitively if we never had this conversation and I had the same use case as Sindre did! Can we keep track of this and when it will be merged somehow without going on r.android and filtering for all commits on your name? I don't know how Gerrit works really 😅
53 Views