Efficient state management for TextField in Compose

Efficient state management for TextField in Compose

compose TextField

To prevent synchronization issues and unexpected behavior:

  • TextFieldAvoid delay/asynchronous behavior between input and update state.
  • StateFlowAvoid using data collected by reactive streams to save TextFieldstate, such as using the default dispatcher.
  • Use the Compose API, for example MutableState<String>, to define TextFieldstate variables. When needed, TextFieldthe state is raised to ViewModel, for example, business validation is applied to TextFieldthe content.

Suppose we have to implement a registration page in a Jetpack Compose application and receive the following design:
Use two text input boxes to implement the registration interface

We have two text input boxes and a button.
Let's start with the text input box at the top, which is the username field.
In order to implement a text input box in Compose, we need to define a state variable:

  1. Stores the currently displayed value and passes it to the TextField's value parameter.
  2. It is updated whenever the user enters new text in the TextField's onValueChange callback.
/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

var myValue = ...

OutlinedTextField(
   value = myValue, // #1
   onValueChange = {
    
     newValue -> myValue = newValue } // #2
   ...
  )
}

When dealing with state, the important thing is deciding where to put the state variables. In our case, we want to do some business logic validation on the username, so we lift the state into the ViewModel instead of keeping it in the composition function. For more information on this and how to organize your application architecture, you can read our Architecture Guide.
By putting the state in the ViewModel, the TextField value will persist for free on configuration changes.

Based on these requirements, we create a composite registration screen that contains an OutlinedTextField component similar to this:

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

// SignUpScreen.kt

@Composable
fun SignUpScreen(...) {
    
    

    OutlinedTextField(
       ...
       value = viewModel.username.collectAsStateWithLifecycle(),
       onValueChange = {
    
     viewModel.updateUsername(it) }
       ...
      )
    }
}

Next, in the ViewModel, we'll define state variables and execute business logic.

Currently, it is not recommended to use Reactive Streams to define TextField's state variables. We'll explore why, and other pitfalls, in the next chapters, but for now let's say we make this mistake. We mistakenly defined a variable MutableStateFlowof _usernameto store the TextField state and exposed it by defining an immutable backed variable username.

The asynchronous method updateUsernamewill call the service every time the user types new characters on the TextField to verify that the username is available (eg, has been used before). If authentication fails, it will display an error message asking to choose a different username.

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

// SignUpViewModel.kt

class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
    
    
   
   // DO NOT DO THIS. ANTI-PATTERN - using a reactive stream for TextField state
   private val _username = MutableStateFlow("")
   val username = _username.asStateFlow()

   fun updateUsername(input: String) {
    
    
       viewModelScope.launch {
    
    
           // async operation
           val isUsernameAvailable = userRepository.isUsernameAvailable(input)
           // ...
           
           if (!isUsernameAvailable) {
    
    
               // modify error state
           }
           // DO NOT DO THIS. ANTI-PATTERN - updating after an async op
           _username.value = input
       }
   }
}

question

We have completed the implementation of the username field. If we run the application now, we should be able to test:
TextField behaves incorrectly when we try to change username jane@mail.com to jane.surname@mail.com
when we type, we quickly discover incorrect behavior: as we type, some letters are skipped, some letters are added to the input in the wrong order, the whole Bits are repeated and the cursor jumps back and forth. All editing operations failed, including deleting and selecting text to replace. Clearly there is a bug.

What happened and how can we fix it?

Internal implementation of TextField

As of this writing (using Compose UI 1.3.0-beta01), TextField's implementation includes holding 3 copies of the state:

  1. Input Method Editors (IME): To be able to perform smart actions, such as suggesting the next word or emoji to replace a word, the keyboard needs to have a copy of the currently displayed text.
  2. A state holder defined and updated by the user, in the above example it is a MutableStateFlow variable.
  3. The internal state acts as a controller, keeping the other two states in sync, so you don't need to manually interact with the IME.

Even though there are 3 copies of the state in play at the full time of each TextField, the developer only manages one of them (the state holder) and the other is internal.

How do these three states interact behind the scenes? To simplify, each character typed or added from the keyboard performs a series of steps, forming a feedback loop, as follows:

Interaction between TextField states

  1. Events are entered from the keyboard (the word "hello" is entered) and are forwarded to the internal controller.
  2. The internal controller receives this update "hello" and forwards it to the state holder.
  3. The state holder is updated with "hello" content, which updates the UI and notifies the internal controller that the update has been received.
  4. The internal controller notifies the keyboard.
  5. The keyboard is notified so it can prepare for the next typing event, such as suggesting the next word.

As long as these copies of the state are kept in sync, the TextField will behave as expected.

However, by introducing asynchronous behavior and race conditions into the typing process, these copies can become out of sync and cannot be recovered. The severity of these errors depends on various factors such as the amount of delay introduced, keyboard language, text content and length, and input method implementation.

Even just using reactive flows to represent state (such as StateFlow) without latency can be problematic because dispatch of update events is not immediate if you use the default scheduler.

Let's try to see what happens in this case, when you start typing. A new event "hello" from the keyboard arrives, then we generate an asynchronous call before we update the state and UI. Then another event "world" comes from the keyboard.

The first asynchronous event resumes and the loop completes. When the TextField internal state receives an asynchronous "hello", it discards the latest "hello world" it received before.
TextField's internal state is overridden with 'hello' instead of 'hello world'
But at some point, the "hello world" asynchronous event will also resume. At this point the TextField remains in an invalid state with 3 of the states mismatching.

The internal state of the TextField is overridden to 'hello' instead of 'hello world'.
But at some point, the "hello world" asynchronous event will also resume. At this point the TextField remains in an invalid state with 3 of the states mismatching.
TextField has inconsistencies after each async processing resume
There is an inconsistency with TextField. Combine these unexpected asynchronous calls with the IME's handling, fast typing, timing conditions, and deletions that replace entire blocks of text, and the flaw becomes even more apparent.

Now that we have some understanding of the dynamics involved, let's look at how to fix and avoid these issues.

Best Practices for Handling TextField State

Avoid delayed state updates
Update your TextField immediately and synchronously when onValueChange is called.

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

// SignUpViewModel.kt

class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
    
    
   
   fun updateUsername(input: String) {
    
    
-       viewModelScope.launch {
    
    
-           // async operation
-           val isUsernameAvailable = userRepository.isUsernameAvailable(input)
-           // ...
-           
-           if (!isUsernameAvailable) {
    
    
-               // modify error state
-           }
           username.value = input
       }
   }
}


// SignUpScreen.kt

@Composable
fun SignUpScreen(...) {
    
    

   OutlinedTextField(
      value = viewModel.username,
      onValueChange = {
    
     username -> viewModel.updateUsername(username) })
}

You may still need to filter or trim the text. Synchronous operations can be performed. Consider using , for example, if your synchronous operations convert input to a different character set visualTransformation. You should avoid using asynchronous operations as this can lead to the problems described above.

Use MutableState to represent TextField state
Avoid using reactive flows such as StateFlow to represent TextField state because these constructs introduce asynchronous latency. Instead MutableState should be used:

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

class SignUpViewModel : ViewModel() {
    
    
   var username by mutableStateOf("")
       private set

   // ...
}

If you still prefer to use StateFlow to store state, make sure to use the immediate scheduler instead of the default scheduler to collect from the flow.

This solution requires deeper knowledge of coroutines and can lead to the following problems:

  • Since the collection is synchronous, the UI may be in an inoperable state while it is happening.
  • Interfere with Compose's threading and rendering phases, since it assumes recomposition happens on the main thread.

Where to define the state
If your TextField state needs business logic validation while typing, it is correct to promote the state into the ViewModel. If you don't need that, you can use Composables or state holding classes as real data sources.

The general rule is that you should place state as low as possible while still being properly owned, which usually means closer to where it is used. For more information on state in Compose, check out our guide.

When solving this problem, the important thing is not where to promote the TextField state, but how to store it.

Apply best practices to your application

With these best practices in mind, let's implement both asynchronous and synchronous validation into our TextField state.

Starting with asynchronous validation, if the username to be used is invalid, we want to display an error message below the TextField and perform this validation on the server side. In our UI it will look like this:

An error is displayed because "username1" is already in use

When called onValueChange, we will immediately call the update method to update TextField, and the ViewModel will then schedule an asynchronous check based on the value just changed.

In the ViewModel, we define two state variables: one for the state of the TextField username, MutableStateand one userNameHasErrorfor StateFlowreactive calculations when the username is updated.

snapshotFlowThe API transforms the Compose State into flowso that we can perform asynchronous (pending) operations on each value.

Because typing can be faster than getting asynchronous call results, we process events sequentially and use mapLatest(experimental) cancellation of outstanding calls when new events come in to avoid wasting resources or displaying incorrect status. We can also add a debounce method (delay between asynchronous calls) for the same reason.

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

// SignUpViewModel.kt

@OptIn(ExperimentalCoroutinesApi::class)
class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
    
    
    var username by mutableStateOf("")
        private set

    val userNameHasError: StateFlow<Boolean> =
        snapshotFlow {
    
     username }
            .mapLatest {
    
     signUpRepository.isUsernameAvailable(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = false
            )

    fun updateUsername(input: String) {
    
    
        username = input
    }
}


// SignUpScreen.kt

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun SignUpScreen(...)

    OutlinedTextField(
        value = viewModel.username,
        onValueChange = {
    
     newValue ->
            viewModel.updateUsername(newValue)
        }
    )

    val userNameHasError by viewModel.userNameHasError.collectAsStateWithLifecycle()

    if (userNameHasError) {
    
    
        Text(
            text = "Username not available. Please choose a different one.",
            color = Color(ColorError)
        )
    }
    ...
}

Note that we are collecting error validation streams using the experimental collectAsStateWithLifecycle API, which is the recommended way to collect streams in Android. To learn more about this API, you can check out the "Consuming Streams Safely" section in the Jetpack Compose blog post.

https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3

Now, we want to add synchronous validation to check if the input contains invalid characters. We can use the synchronous derivedStateOf() API which will trigger the lambda validation whenever the username changes.

/* Copyright 2022 Google LLC.   
   SPDX-License-Identifier: Apache-2.0 */

// SignUpViewModel.kt

class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
    
    
    
    var username by mutableStateOf("")
        private set    

    val userNameHasLocalError by derivedStateOf {
    
     
        // synchronous call
        signUpRepository.isUsernameCorrect(username) 
    }

    ...
}

derivedStateOf()Creates a new State that read userNameHasLocalErrorcomponents will reassemble when the value changes between true and false.
Our full authenticated username implementation looks like this:
Implement username field with synchronous and asynchronous errors

Consider the implementation of TextField

We are currently improving the TextField API and consider it one of our priorities.

The Compose roadmap reflects the team's work on several fronts, in this case text editing and keyboard input improvements that relate to these APIs. So keep an eye out for future Compose releases and release notes.

Guess you like

Origin blog.csdn.net/u011897062/article/details/131006927