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
LiveData
because that's what the project is using right now. Switching over to State
or Kotlin Flow
isn'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,
class UserProfileViewModel : ViewModel() {
sealed class State {
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 with setValue()
or it can also be MutableState<String>
(the Google recommendation) but it also works with MutableStateFlow<>
+ collectLatest {}
as long as there is no flowOn()
or delay()
etc. and everything happens on Dispatchers.Main.immediate (NOT Dispatchers.Main
).
Once you consider the separation of "State" so that your TextField will behave as intended, you will have
class UserProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val firstName by savedStateHandle.saveable { mutableStateOf<String>("")
val didFetchUserData by savedStateHandle.saveable { mutableStateOf(false) }
val hadErrorSavingData by mutableStateOf(false) // unlikely that this needs state restoration
val hadErrorUpdatingData by mutableStateOf(false) // unlikely that this needs state restoration
}
// "LOADING" state is just `didFetchData == false`.
// You might say "but I want a `State` class that presents this"
// but then you won't be able to guarantee that the TextField values update
// immediately, and you will break your code.
I guess you can do viewModel.isLoading
and val isLoading get() = !didFetchUserData
if you really need that property explicit.
Once you've destructured this State
class 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 @Stable
state 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 from ViewModel()
(Android-bound class that cannot be preview'd).
So you end up with
class UserProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
private var firstName by savedStateHandle.saveable { mutableStateOf("") }
// ...
private val didFetchUserData by savedStateHandle.saveable { mutableStateOf(false) }
private val hadErrorSavingData by mutableStateOf(false) // unlikely that this needs state restoration
private val hadErrorUpdatingData by mutableStateOf(false) // unlikely that this needs state restoration
val state = UserProfileState(
firstNameProvider = { firstName },
// ...
didFetchUserDataProvider = { didFetchUserData },
hadErrorSavingDataProvider = { hadErrorSavingData },
hadErrorUpdatingDataProvider = { hadErrorUpdatingData },
onUpdateFirstName = { firstName = it },
)
}
@Stable
class UserProfileState(
private val firstNameProvider: () -> String,
private val didFetchUserDataProvider: () -> Boolean,
private val hadErrorSavingDataProvider: () -> Boolean,
private val hadErrorUpdatingDataProvider: () -> Boolean,
private val onUpdateFirstName: (String) -> Unit,
) {
val firstName: String get() = firstNameProvider()
private val didFetchUserData: Boolean get() = didFetchUserDataProvider()
val hadErrorSavingData: Boolean get() = hadErrorSavingDataProvider()
val hadErrorUpdatingData: Boolean get() = hadErrorUpdatingDataProvider()
fun updateFirstName(firstName: String) {
onUpdateFirstName(firstName)
}
val isLoading: Boolean get() = !didFetchUserData
}
Then you should be able to do
@Composable
fun NavigationGraph() {
...
val userProfileViewModel = viewModel<UserProfileViewModel>()
UserProfileScreen(
state = userProfileViewModel.state
)
}
@Composable
fun UserProfileScreen(
state: UserProfileState,
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(modifier)
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier
.size(48.dp)
.align(Alignment.Center))
} else {
TextField(
value = state.firstName,
onValueChange = state::updateFirstName,
)
}
}
}
But if you use LiveData, then the solution should be similar, but not entirely the same. All calls to savedStateHandle.saveable { mutableStateOf("")
is savedStateHandle.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 the State
class from there.
class UserProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val firstName = savedStateHandle.getLiveData<String>("firstName", "")
// ...
private val currentDidFetchUserData = savedStateHandle.getLiveData<Boolean>("didFetchUserData", false)
val didFetchUserData: LiveData<Boolean> = currentDidFetchUserData
private val currentHadErrorSavingData = MutableLiveData(false) // unlikely that this needs state restoration
val hadErrorSavingData: LiveData<Boolean> = currentHadErrorSavingData
private val currentHadErrorUpdatingData = MutableLiveData(false) // unlikely that this needs state restoration
val hadErrorUpdatingData: LiveData<Boolean> = currentHadErrorUpdatingData // unlikely that this needs state restoration
}
@Stable
class UserProfileState(
private val firstNameProvider: () -> String,
private val didFetchUserDataProvider: () -> Boolean,
private val hadErrorSavingDataProvider: () -> Boolean,
private val hadErrorUpdatingDataProvider: () -> Boolean,
private val onUpdateFirstName: (String) -> Unit,
) {
val firstName: String get() = firstNameProvider()
private val didFetchUserData: Boolean get() = didFetchUserDataProvider()
val hadErrorSavingData: Boolean get() = hadErrorSavingDataProvider()
val hadErrorUpdatingData: Boolean get() = hadErrorUpdatingDataProvider()
fun updateFirstName(firstName: String) {
onUpdateFirstName(firstName)
}
val isLoading: Boolean get() = !didFetchUserData
}
@Composable
fun NavigationGraph() {
...
val userProfileViewModel = viewModel<UserProfileViewModel>()
val firstName = userProfileViewModel.firstName.observeAsState("")
val didFetchUserData = userProfileViewModel.didFetchUserData.observeAsState(false)
val hadErrorSavingData = userProfileViewModel.hadErrorSavingData.observeAsState(false)
val hadErrorUpdatingData = userProfileViewModel.hadErrorUpdatingData.observeAsState(false)
val state = remember {
UserProfileState(
firstNameProvider = firstName::value::get,
didFetchUserDataProvider = didFetchUserData::value::get,
hadErrorSavingDataProvider = hadErrorSavingData::value::get,
hadErrorUpdatingDataProvider = hadErrorUpdatingData::value::get,
onUpdateFirstName = userProfileViewModel.firstName::setValue, // trick. viewModel::updateFirstName would be also ok
)
}
UserProfileScreen(
state = state,
)
}
@Composable
fun UserProfileScreen(
state: UserProfileState,
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(modifier)
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier
.size(48.dp)
.align(Alignment.Center))
} else {
TextField(
value = state.firstName,
onValueChange = state::updateFirstName,
)
}
}
}
You definitely don't need to create your own TextFieldValue
s 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 use String
instead of TextFieldValue
.
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 () -> String
and 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:
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
val Saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1],
)
}
)
}
}
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.
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
remember(hint) {
EditableUserInputState(hint, hint)
}
Overall, the answer is somewhere between #1 and #4, definitely not #2 and #3.
Dependencies require so:
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
implementation "androidx.compose.runtime:runtime:1.4.3"
implementation 'androidx.compose.runtime:runtime-livedata:1.4.3'