Using the new `BasicTextField2` API, when listenin...
# compose-android
s
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
May I ask why you want to know this? 👀
s
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
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
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
Oh you got an if empty check there okay
s
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
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
Yeah, the
isSettingProgrammatically
route doesn't feel ideal, regardless.
s
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
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
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
You should be able to use a noop InputTransformation to handle input from the user. It won’t fire for programmatic state changes.
s
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
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
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
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
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
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
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
map { it.toString() }.distinctUntilChanged()
on the
Flow
should eliminate this issue for you here
s
Oh, of course! 🤦
🌟 1
Thanks. Brainfarted a bit there.
s
Flows are amazing, I tell ya 😄
s
Indeed!
z
Cc @Halil Ozercan for this API feedback about InputTransformation. Maybe we named it wrong
👍 1
👀 1
s
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
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
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
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
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
InputTransformation chaining allows you to combine multiple transformations in whatever order you choose. Just call firstTransformation.then(secondTransformation)
s
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
I really want is to observe the editing through a
TextFieldStateH
How come you have this limitation?
h
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
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 😅
325 Views