Context
I'm trying to figure out how to pass default values to TextField Composables. I have seen a few solutions out there, but I'm looking at some feedback on what is the most widely accepted solution.
Example
Let's say you have a ViewModel like this:
class UserProfileViewModel : ViewModel() {
sealed class State {
data class UserDataFetched(
val firstName: String
// ...
) : State()
object ErrorFetchingData: State()
object ErrorUpdatingData: State()
object Loading: State()
// ...
}
val state = MutableLiveData<State>()
// ...
}
This ViewModel is for a piece of UI that – let's say, lets you update the user name through an TextField – looks like this:
val state by viewModel.state.observeAsState(initial = UserProfileViewModel.State.Loading)
MaterialTheme() {
UserProfileScreen(
state = state
)
}
@Composable
fun UserProfileScreen(
state: UserProfileViewModel.State,
) {
val userNameValue = remember { mutableStateOf(TextFieldValue()) }
Column {
TextField(
value = userNameValue.value,
onValueChange = {
userNameValue.value = it
}
)
//...
}
}
Now, when I get a State.UserDataFetched event the first time this screen is prompted to the user, I want to pre-fill the TextField with the firstName I got in there.
I have seen a few solutions out there, but I'm not sure which one is most widely-accepted or why.
#1 Use a flag variable
@Composable
fun UserProfileScreen(
state: UserProfileViewModel.State,
) {
val userHasModifiedText = remember { mutableStateOf(false) }
val userNameValue = remember { mutableStateOf(TextFieldValue()) }
if (state is UserProfileViewModel.State.UserDataFetched) {
if (!userHasModifiedText.value) {
userNameValue.value = TextFieldValue(state.firstName)
}
}
Column {
TextField(
value = userNameValue.value,
onValueChange = {
userNameValue.value = it
userHasModifiedText.value = true
}
)
//...
}
}
The idea would be to use userHasModifiedText to keep track of wether the user has typed anything in the TextField or not – that way we avoid changing the value upon recomposition.
#2 Use derivedStateOf
@Composable
fun UserProfileScreen(
state: UserProfileViewModel.State,
defaultFirstName: String? = null,
) {
val userNameString = remember { mutableStateOf<String?>(null) }
val userNameValue = remember {
derivedStateOf {
if (userNameString.value != null)
TextFieldValue(userNameString.value ?: "")
else
TextFieldValue(defaultFirstName ?: "")
}
}
Column {
TextField(
value = userNameValue,
onValueChange = {
userNameString.value = it
}
)
//...
}
}
Taken from this answer here.
#3 use LaunchedEffect
@Composable
fun UserProfileScreen(
state: UserProfileViewModel.State
) {
val userNameValue = remember { mutableStateOf(TextFieldValue) }
LaunchedEffect(true) {
if (state is UserProfileViewModel.State.UserDataFetched) {
userNameValue.value = TextFieldValue(state.firstName)
}
}
Column {
TextField(
value = userNameValue.value,
onValueChange = {
userNameValue.value = it
}
)
//...
}
}
#4 have a specific LiveData property
Let's say that instead of using that sealed class we keep track of what that field through a LiveData property
class UserProfileViewModel : ViewModel() {
// ...
val firstName = MutableLiveData<String>()
// ...
}
then we would do something like
val firstName by viewModel.firstName.observeAsState("")
MaterialTheme() {
UserProfileScreen(
firstName = firstName,
onFirstNameEditTextChanged = {
viewModel.firstName.value = it
}
)
}
@Composable
fun UserProfileScreen(
firstName: String,
onFirstNameEditTextChanged: ((String) -> Unit) = {}
) {
val userNameValue = remember { mutableStateOf(TextFieldValue) }
Column {
TextField(
value = userNameValue.value,
onValueChange = {
userNameValue.value = it
onFirstNameEditTextChanged(it.text)
}
)
//...
}
}
Notes
- I'm using
LiveDatabecause that's what the project is using right now. Switching over toStateor KotlinFlowisn't something we have in the roadmap.
Edit #1
Since this question has been flagged as opinionated, let me be completely clear about what I am looking to get out of it.
I have listed all the solutions I have been able to find as to how to set an initial/default value on a TextField. If there is a better solution or a solution that I haven't listed here that addresses this issue better, please feel free to share it and explain why it is better.
I don't think an answer like "just pass the ViewModel" addresses the issue wholly. What if I'm using this composable on a Fragment and my ViewModel is scoped to the entire Activity? If I dismiss the Fragment I don't want the TextField value to be remembered through the ViewModel. I'm also looking for something simple, as far as I understand if the Composables are self-contained, that's better, so I'm not sure that tying a Composable to a ViewModel is the best solution.
I'm looking for a clear, concise solution that is widely accepted in the Android community so my team and I can adopt it.
My expectation is that you open the screen, we fetch a value for the default name from somewhere, we pre-fill that on the TextField and that's it. If the user deletes the whole text, that's it. If the user dismisses the screen and comes back, then we fetch again the value and pre-fill it again. If the goal of this question is still unclear or you think it might be up to discussion, let me know, and I'll try to clarify further.
First,
You don't really want to have a ViewModel like this. Because it doesn't get a reference to SavedStateHandle. If your ViewModel doesn't get a SavedStateHandle, you should already be suspicious for the rest of your code -- if there is user input on the screen but no SavedStateHandle (and you don't deliberately invalidate the session after process death manually) then you will just lose user input.
Next, TextField requires you to make the current text *synchronously available, so the evaluated text that is inputted into the TextField should be either
MutableLiveData<String>managed withsetValue()or it can also beMutableState<String>(the Google recommendation) but it also works withMutableStateFlow<>+collectLatest {}as long as there is noflowOn()ordelay()etc. and everything happens on Dispatchers.Main.immediate (NOTDispatchers.Main).Once you consider the separation of "State" so that your TextField will behave as intended, you will have
I guess you can do
viewModel.isLoadingandval isLoading get() = !didFetchUserDataif you really need that property explicit.Once you've destructured this
Stateclass you originally had, then you realize this is actually either terrible for recomposition (all reads should be deferred if possible); so then you create a@Stablestate holder that serves as a lazy accessor for state properties that wraps the state access with deferred reads/writes, but any updates should still reflect on it. This way, you do end up "duplicating your state holder" (ViewModel <-> State), but at least you also separate it fromViewModel()(Android-bound class that cannot be preview'd).So you end up with
Then you should be able to do
But if you use LiveData, then the solution should be similar, but not entirely the same. All calls to
savedStateHandle.saveable { mutableStateOf("")issavedStateHandle.getLiveData("key", "")(default value).The real problem is that LiveData by default doesn't notify Compose, so you actually need to use
observeAsState()in your Composable, and THEN create theStateclass from there.You definitely don't need to create your own
TextFieldValues especially in your composable, unless you need to also manipulate selection... but that is somewhat difficult to do and out of scope for this question, so I generally useStringinstead ofTextFieldValue.This is a bit of a tangent here, but it's worth noting what happens if the state is not hoisted in the ViewModel, as that's the example they use in the Codelabs.
If you ditch ViewModel entirely and move the state back into Composables, then you can ditch the
() -> Stringand so on lazy accessors and follow https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects#6 instead. Their solution provides lazy access as well:Except in their case, state is NOT hoisted to a ViewModel, so they track invalidation by specifying *all arguments of the state as remember keys.
Overall, the answer is somewhere between #1 and #4, definitely not #2 and #3.
Dependencies require so: