This post will not only focus on manual & auto validation of TextFieldinputs, but also on how to provide a nice UX by handling process death, including highlighting the last focused field upon restoration.
Auto-validation sample
Setup
We’ll use composables, viewModels & Dagger Hilt in these samples.
Part 1: InputWrapper
Let’s create a wrapper for inputs, so it can contain both input value and errorId, if there is a validation error. Class is parcelable, so we can save it inside savedStateHandle later.
@Parcelize | |
data class InputWrapper( | |
val value: String = "", | |
val errorId: Int? = null | |
) : Parcelable |
Part 2: ViewModel (simplified version)
@HiltViewModel | |
class InputValidationAutoViewModel @Inject constructor( | |
private val savedStateHandle: SavedStateHandle | |
) : ViewModel() { | |
val name = handle.getStateFlow(NAME, InputWrapper()) | |
val creditCardNumber = handle.getStateFlow(CREDIT_CARD_NUMBER, InputWrapper()) | |
val areInputsValid = combine(name, creditCardNumber) { name, cardNumber -> | |
name.value.isNotEmpty() && name.errorId == null && cardNumber.value.isNotEmpty() && cardNumber.errorId == null | |
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false) | |
private val _events = Channel<ScreenEvent>() | |
val events = _events.receiveAsFlow() | |
fun onNameEntered(input: String) { | |
val errorId = InputValidator.getNameErrorIdOrNull(input) | |
handle[NAME] = name.value.copy(value = input, errorId = errorId) | |
} | |
fun onCardNumberEntered(input: String) { | |
val errorId = InputValidator.getCardNumberErrorIdOrNull(input) | |
handle[CREDIT_CARD_NUMBER] = creditCardNumber.value.copy(value = input, errorId = errorId) | |
} | |
fun onContinueClick() { | |
val resId = if (areInputsValid.value) R.string.success else R.string.validation_error | |
_events.trySend(ScreenEvent.ShowToast(resId)) | |
} | |
} |
- We’re getting a stateFlow from the savedStateHandle using .getStateFlowextension found here. Using savedStateHandle here allows us to retain the latest data after process death happens. You can also use getLiveData, it’s a matter of personal preferences unless you plan to perform observable.switchMap/flatMapLatest type of operations, since there is a subtle difference between stateFlow & liveData in such scenarios described here & change proposal created.
- areInputsValid stateFlow is combining name & creditCardNumberflows into a single one. The results it returns depend on whether input fields are valid or not. It’s invoked whenever there is new emition to name or creditCardNumber flows. We’ll use it’s value to control buttons enabled/disabled state in our composable.
- _events will be used for one time events emission to the view layer. ScreenEvent is a sealed class representing all of our one time events.
- onNameEntered & onCardNumberEntered will be invoked when user enters symbols inside the TextField. We find out whether the input is valid by using an InputValidator object. It’s returning either a string resource id containing error message, or a null. Latter means that input is valid. We store the new value & errorId using:
handle[NAME] = name.value.copy(value = input, errorId = errorId) |
This does 2 important things for us.
1) Recomposes the composable which is listening to the updates.
2) Triggers areInputsValid flow, making sure that we are operating with the latest information.
- onContinueClick emits an event to display a success message if inputs are valid or error message if not. It’s called in 2 scenarios:
1) On a button click.
2) On ImeAction.Done from the creditCardNumber TextField.
Part 3: Composables (simplified version)
@Composable | |
fun InputValidationScreen(viewModel: InputValidationAutoViewModel = hiltViewModel()) { | |
val events = remember(viewModel.events, lifecycleOwner) {..} | |
val name by viewModel.name.collectAsStateWithLifecycle() | |
val creditCardNumber by viewModel.creditCardNumber.collectAsStateWithLifecycle() | |
val areInputsValid by viewModel.areInputsValid.collectAsStateWithLifecycle() | |
LaunchedEffect(Unit) { | |
events.collect { event -> | |
when (event) { | |
is ScreenEvent.ShowToast -> context.toast(event.messageId) | |
} | |
} | |
} | |
Column { | |
CustomTextField( | |
inputWrapper = name, | |
onValueChange = viewModel::onNameEntered | |
) | |
CustomTextField( | |
inputWrapper = creditCardNumber, | |
onValueChange = viewModel::onCardNumberEntered, | |
onImeKeyAction = viewModel::onContinueClick | |
) | |
Button(onClick = viewModel::onContinueClick, enabled = areInputsValid) { | |
Text(text = "Continue") | |
} | |
} | |
} |
@Composable | |
fun CustomTextField( | |
inputWrapper: InputWrapper, | |
onValueChange: OnValueChange, | |
onImeKeyAction: OnImeKeyAction | |
) { | |
Column { | |
TextField( | |
value = inputWrapper.value, | |
onValueChange = { onValueChange(it) }, | |
isError = inputWrapper.errorId != null, | |
keyboardActions = KeyboardActions(onAny = { onImeKeyAction() }), | |
) | |
if (inputWrapper.errorId != null) { | |
Text() // error message | |
} | |
} | |
} |
Hopefully code above is self-explanatory. Now let’s look on what’s missing :
1) Keyboard isn’t opened upon entering the screen.
2) No TextField is focused upon entering the screen.
3) There is no way to tell which TextField was focused last, after process death occurred.
4) No ImeAction handling for the name TextField.
5) Keyboard isn’t dismissed upon successful button click.
6) Focused TextField is not unfocused upon successful button click.
Enhancements
Let’s add the missing parts to the viewModel & composables.
Part 1: ViewModel
enum class FocusedTextFieldKey { | |
NAME, CREDIT_CARD_NUMBER, NONE | |
} | |
@HiltViewModel | |
class InputValidationViewModel @Inject constructor(..) : ViewModel() { | |
private var focusedTextField = handle.get("focusedTextField") ?: FocusedTextFieldKey.NAME | |
set(value) { | |
field = value | |
handle.set("focusedTextField", value) | |
} | |
init { | |
if (focusedTextField != FocusedTextFieldKey.NONE) focusOnLastSelectedTextField() | |
} | |
fun onTextFieldFocusChanged(key: FocusedTextFieldKey, isFocused: Boolean) { | |
focusedTextField = if (isFocused) key else FocusedTextFieldKey.NONE | |
} | |
fun onNameImeActionClick() { | |
_events.trySend(ScreenEvent.MoveFocus(FocusDirection.Down)) | |
} | |
fun onContinueClick() { | |
viewModelScope.launch(Dispatchers.Default) { | |
if (areInputsValid.value) clearFocusAndHideKeyboard() | |
.. | |
} | |
} | |
private suspend fun clearFocusAndHideKeyboard() { | |
_events.send(ScreenEvent.ClearFocus) | |
_events.send(ScreenEvent.UpdateKeyboard(false)) | |
focusedTextField = FocusedTextFieldKey.NONE | |
} | |
private fun focusOnLastSelectedTextField() { | |
viewModelScope.launch(Dispatchers.Default) { | |
_events.send(ScreenEvent.RequestFocus(focusedTextField)) | |
delay(250) | |
_events.send(ScreenEvent.UpdateKeyboard(true)) | |
} | |
} | |
} |
Job Offers
- focusedTextField is responsible for storing the name of last known focused TextField. By default it’s NAME, since it’s the first input from top to bottom.
- init{} block checks if there is last known focused TextField, and if so — invokes focusOnLastSelectedTextField(). It emits 2 events:
1) Focus on the last known TextField.
2) Show keyboard. - onTextFieldFocusChanged() is called when we get TextFieldfocused/unfocused events from the composable. It stores the last known focused TextField name on focused event, and clears it on unfocusedevent.
- onNameImeActionClick() is called when the name TextField is focused, and user presses on ImeAction.Next. It emits event to move the focus with a FocusDirection.Down command.
- onContinueClick() – if inputs are valid, we’re setting focusedTextField to NONE and emit 2 events:
1) Unfocus currently focused TextField.
2) Hide keyboard.
Part 2: Composables
@Composable | |
fun InputValidationScreen(..) { | |
val focusManager = LocalFocusManager.current | |
val keyboardController = LocalSoftwareKeyboardController.current | |
val creditCardNumberFocusRequester = remember { FocusRequester() } | |
val nameFocusRequester = remember { FocusRequester() } | |
LaunchedEffect(Unit) { | |
events.collect { event -> | |
when (event) { | |
is ScreenEvent.UpdateKeyboard -> { | |
if (event.show) keyboardController?.show() else keyboardController?.hide() | |
} | |
is ScreenEvent.ClearFocus -> focusManager.clearFocus() | |
is ScreenEvent.RequestFocus -> { | |
when (event.textFieldKey) { | |
FocusedTextFieldKey.NAME -> nameFocusRequester.requestFocus() | |
FocusedTextFieldKey.CREDIT_CARD_NUMBER -> creditCardNumberFocusRequester.requestFocus() | |
} | |
} | |
is ScreenEvent.MoveFocus -> focusManager.moveFocus(event.direction) | |
} | |
} | |
} | |
Column { | |
CustomTextField( | |
modifier = Modifier | |
.focusRequester(nameFocusRequester) | |
.onFocusChanged { focusState -> | |
viewModel.onTextFieldFocusChanged( | |
key = FocusedTextFieldKey.NAME, | |
isFocused = focusState.isFocused | |
) | |
}, | |
onImeKeyAction = viewModel::onNameImeActionClick | |
) | |
CustomTextField( | |
modifier = Modifier | |
.focusRequester(creditCardNumberFocusRequester) | |
.onFocusChanged { focusState -> | |
viewModel.onTextFieldFocusChanged( | |
key = FocusedTextFieldKey.CREDIT_CARD_NUMBER, | |
isFocused = focusState.isFocused | |
) | |
} | |
) | |
} | |
} |
- focusManager is used to clear current focus and to move it in certain direction. In our case it’s down.
- keyboardController is used to hide/show keyboard.
- creditCardNumberFocusRequester & nameFocusRequested are FocusRequesters. They allow us to request focus for composables on demand(eg. from events ).
- Modifier.focusRequester.onFocusChanged — We’ve added focus requesters and onFocusChanged listener to our composable modifiers.
@Composable | |
fun CustomTextField( | |
modifier: Modifier, | |
inputWrapper: InputWrapper, | |
onValueChange: OnValueChange | |
) { | |
val fieldValue = remember { | |
mutableStateOf(TextFieldValue(inputWrapper.value, TextRange(inputWrapper.value.length))) | |
} | |
Column { | |
TextField( | |
modifier = modifier, | |
value = fieldValue.value, | |
onValueChange = { | |
fieldValue.value = it | |
onValueChange(it.text) | |
} | |
) | |
.. | |
} | |
} |
- fieldValue — is a class holding information about the editing state.The input service updates text selection, cursor, text and text composition. This class represents those values and allows to observe changes to those values in the text editing composables. We need it to place the input indicator to the end of the entered text upon requesting focus after process death/if input is not empty.
Manual Validation
In case you want to validate inputs only upon the button click, a few adjustments inside the viewModel are needed.
private class InputErrors( | |
val nameErrorId: Int?, | |
val cardErrorId: Int? | |
) | |
@HiltViewModel | |
class FormValidationViewModel @Inject constructor(..) : ViewModel() { | |
fun onNameEntered(input: String) { | |
handle[NAME] = name.value.copy(value = input, errorId = null) | |
} | |
fun onContinueClick() { | |
viewModelScope.launch(Dispatchers.Default) { | |
when (val inputErrors = getInputErrorsOrNull()) { | |
null -> { | |
clearFocusAndHideKeyboard() | |
_events.send(ScreenEvent.ShowToast(R.string.success)) | |
} | |
else -> displayInputErrors(inputErrors) | |
} | |
} | |
} | |
private fun getInputErrorsOrNull(): InputErrors? { | |
val nameWrapper = name.value | |
val cardWrapper = creditCardNumber.value | |
val nameErrorId = InputValidator.getNameErrorIdOrNull(nameWrapper.value) | |
val cardErrorId = InputValidator.getCardNumberErrorIdOrNull(cardWrapper.value) | |
return if (nameErrorId == null && cardErrorId == null) { | |
null | |
} else { | |
InputErrors(nameErrorId, cardErrorId) | |
} | |
} | |
private suspend fun displayInputErrors(inputErrors: InputErrors) { | |
handle[NAME] = name.value.copy(errorId = inputErrors.nameErrorId) | |
handle[CREDIT_CARD_NUMBER] = creditCardNumber.value.copy(errorId = inputErrors.cardErrorId) | |
} | |
} |
- Inside onNameEntered we’re not validating anymore, and always set errorId to null. This results in TextField error being removed upon first symbol entered/deleted.
- Validation happens inside the getInputErrorsOrNull() now. It returns null if inputs are valid, or InputErrors class containing the errorIds.
- To show errors we’re passing the error ids from the InputErrors to our name & creditCardNumber flows.
Auto-validation UX can be improved by adding debouncing to the validation events. Debounce sample is inside the repository.
Compose version used : 1.0.3
Tool used for triggering process death: Venom
Complete examples can be found here