Search code examples
androidandroid-jetpack-composeandroid-livedatahoisting

What is the best way to pass a default/initial value to a Jetpack Compose TextField?


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 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.


Solution

  • 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 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 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'