Baterka
Baterka

Reputation: 3714

Common way to handle form state in StateFlow in Android

I am trying to implement an form screen with many EditTexts, Switches, etc. I have a fragment with viewBinding and viewModel that holds a StateFlow "formData". Every field in UI have its own field in those formData.

I created listeners for the fields, that are updating the StateFlow and collector that updates those fields when data changed.

Minimal example (pseudo Kotlin):

// ViewModel
data class FormData(
 val editTextA: String = "default 1"
 val editTextB: String = "default 2"
)

private val _formData = MutableStateFlow(FormData())
val formData = _formData.asStateFlow()


// Fragment
setCollectors(){
 createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
   binding.edtA.text = it.editTextA
   binding.edtB.text = it.editTextB
 }
}

setListeners(){
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextA = s)
  })
  binding.edtA.addTextChangedListener(AfterTextChangedWatcher { s ->
    viewModel.updateFormData(editTextB = s)
  })
}

My problem is that I am getting cyclic renders, because if I enter some text into e.g. editText, it gets updated in stateflow and collector tries to update the EditText again and so on.

Is there some general (common) way to approach this problem (without using databinding)?

I need to keep the formData in StateFlow because I want to be able to load initial data into StateFlow at init. I also want all logic in viewModel rather than in fragment. And last but not least, I do not want to handle state saving of the fields while changing orientation, adding fragment to backstack, etc. I want to get the latest data from viewModel when fragment is loaded again.

Upvotes: 0

Views: 1973

Answers (3)

cactustictacs
cactustictacs

Reputation: 19592

The problem is you've set up that cycle, where pushing state to the UI (e.g. observing changes in the LiveData) causes an event to be pushed to the data layer (the TextWatcher triggering when the EditText is changed).

Really those events should be in response to something the user does - displaying an updated UI state shouldn't trigger any non-UI actions, it should just be about reacting to new state and displaying it, job done.

So fundamentally, your TextWatcher is a problem, because it doesn't know the difference between a change caused by user interaction (which you want to push to the data layer) and a simple display update (which shouldn't trigger any reactive behaviour). And because of the way you have it set up, even a user interaction will cause this eventually:

user edit -> watcher -> VM update -> observer updates EditText -> watcher -> VM update...

This makes it tricky to implement the usual approaches to avoiding cyclic TextWatcher behaviour, where you make afterTextChanged set a flag or something before it makes a second change - that way, when it gets called again in response to its own change, it can avoid doing it again by checking the flag (and then resetting it for next time). But that's not really possible here, because it's not causing the extra updates itself - an external change is happening, and it doesn't know what's causing it.


So you'll probably want a different way of breaking that cycle. One way is to just not update the EditText if its contents haven't changed:

createMedicineViewModel.formData.launchAndCollectIn(viewLifecycleOwner) {
    if (binding.edtA.text != it.editTextA) {
        binding.edtA.text = it.editTextA
    }
}

You could always write a function for that, to make it a little less unwieldy.

fun TextView.setTextIfChanged(newText: String) {
    if (text != newText) text = newText
}

binding.edtA.setTextIfChanged(it.editTextA)

And now, if the contents are changed, it'll push an update to the VM, which pushes an update to observers containing those same contents. Because there's no actual change to make now, you skip it and the loop is broken.


That might seem a little clumsy though, having to wire things up to take care of that specific behaviour. A more general approach is calling distinctUntilChanged() on the StateFlow:

private val _formData = MutableStateFlow(FormData()).distinctUntilChanged()

Now, as soon as you set a FormData on that LiveData which is equal to the current value, it'll skip emitting an update to observers, breaking the cycle. Because in your example you have a data class using Strings, you get that equality check for free, and eventually things will settle to the point where your TextWatcher pushes an "update" that doesn't change anything.

But I said "eventually things will settle" because, depending on how you have things set up, it's possible you push a state that updates multiple EditTexts, and each one of those will trigger their own cycle of pushing updates. So you have to be aware of that, and decide whether a single update going round and round the loop a few times is ok, or if you want to avoid that.


The other approach you can take (and this is probably easiest for your situation) is some kind of general "I'm updating" flag. Anything that's updating the UI (e.g. observers, initial setup code) can set some updating flag before making the changes, and unset it when finished.

Your TextWatchers can check that flag, and ignore any changes that happen while it's set. That way, your "displaying state" changes don't push anything to the VM, and user changes will only happen while the flag is unset, causing update events to be pushed.

The tricky part there is ensuring you set that flag in all the situations you need to, and unset it at the end - might be worth creating a displayState(formData: FormData) function that everything (including setup code) uses to display a particular state in the UI, then it's all handled in one place.

Upvotes: 0

Tenfour04
Tenfour04

Reputation: 93834

You mention not wanting to handle state saving and restoring of fields, but Android already does this automatically for UI form fields like EditText and CheckBox, provided the views use the same IDs as before.

I suppose the logic you want to move to the ViewModel is filtering of text typed? Most of the UI components will not rerender if you set them to the same value they are already showing. EditText is the exception.

I think the reason EditText doesn't avoid the re-render is that it is simply too complex and would require too many assumptions to be able to determine no-op changes, since it supports different types of CharSequences.

If you aren't using Spannables to use various formatting within the text of the EditText, you could create an extension function or property for updating TextViews (the parent of EditText) without rerendering if the content of the CharSequence matches.

/** When set, only sets new text if the Chars in the sequence have changed. */
var TextView.chars: CharSequence
    get() = text
    set(value) {
        val toSet = (value as? String) ?: value.toString()
        if (text.toString() != toSet) { // toString() is fast, no string copy, if you check source
            setText(value)
        }
    }

Upvotes: 1

MrAdkhambek
MrAdkhambek

Reputation: 1

Sorry my English.

If you want MVI style with View You can use this example

Solution My usage

   val renderer = diff<State> {
        diff(
            get = State::textValue,
            compare = { old, new ->
                old == new && new == binding.editText.text.toString()
            }
        ) { newValue ->
            binding.editText.text = newValue
        }
    }

Upvotes: 0

Related Questions