Composable does not remember input when changing configuration in test - android

I'm writing instrumented tests for a Jetpack Compose component. My composable uses rememberSaveable to remember between configuration changes (activity restarts):
#Composable
fun AddUserScreen() {
Input(
shouldRequestFocus = true,
stringResource(R.string.user_first_name),
stringResource(R.string.user_first_name_label),
tag = "input-first-name"
)
}
#Composable
fun Input(
shouldRequestFocus: Boolean,
text: String,
label: String,
tag: String
) {
var value by rememberSaveable { mutableStateOf("") } // <-- Important part
val focusRequester = FocusRequester()
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(10.dp))
TextField(
value = value,
onValueChange = { value = it },
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier
.focusRequester(focusRequester)
.testTag(tag)
)
}
if (shouldRequestFocus) {
DisposableEffect(Unit) {
focusRequester.requestFocus()
onDispose { }
}
}
}
The input value is retained when I open the app myself and rotate the device. But in the following test the input is not retained on configuration change and the test fails:
#get:Rule val composeTestRule = createAndroidComposeRule<AddUserActivity>()
#Test fun whenAConfigChangeHappensTheFirstNameInputShouldRetainItsValue() {
composeTestRule.setContent {
WorkoutLoggerTheme {
AddUserScreen()
}
}
composeTestRule.onNodeWithTag("input-first-name").performTextInput("John")
composeTestRule.activity.requestedOrientation = SCREEN_ORIENTATION_LANDSCAPE
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("input-first-name").assertTextEquals("John")
}

Related

What is the proper way to update TextField string value after successfully loading data to pre-fill the TextField from database?

I am using Jetpack Compose, and it has occurred to me that I may be doing this incorrectly.
Suppose we have a screen that allows us to edit a form's data, which has been saved locally using Room. Currently, I follow this rough outline:
In my ViewModel's init block, call repository methods to query local Room Db and collect the results as a flow. Upon the flow change, update the ui state (which is a mutableStateOf inside of the viewModel and observed in the UI).
Now, I am following MVVM and my compose ui pattern is as follows: NavHost -> MyComposableScreen -> MyComposablePage. So we have:
#Composable
fun EditFormScreen(
viewModel: EditFormScreenViewModel,
onBackClick: () -> Unit,
onDoneClick: () -> Unit,
) {
val uiState = viewModel.uiState
LaunchedEffect(key1 = uiState) {
when (uiState.validationEvent) {
is FormValidationEvent.Initial -> {
// do nothing
}
is FormValidationEvent.Success -> {
onDoneClick()
}
}
}
Scaffold(
topBar = {
AppBar(
title = {
Text(
text = if (viewModel.id == null) {
stringResource(id = R.string.add_new_title)
} else {
stringResource(id = R.string.edit_existing_title)
},
)
},
onBackPressed = onBackClick,
)
}
) {
EditFormPage(
uiState = uiState,
onEvent = viewModel::onEvent,
)
}
}
fun EditFormPage(
uiState: EditFormPageUiState,
onEvent: (EditFormUiEvent) -> Unit = {},
) {
Column(
modifier = Modifier
...
) {
Column(
modifier = Modifier
...
) {
when(uiState.formLoadedState) {
FormLoadedState.Initial -> {
OutlinedInput(
label = stringResource(id = R.string.first_name),
onTextChanged = {
onEvent(
EditFormUiEvent.OnFirstNameChanged(it)
)
},
isError = uiState.isFirstNameError,
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = {},
)
OutlinedInput(
label = stringResource(id = R.string.last_name),
onTextChanged = {
onEvent(
EditFormUiEvent.OnLastNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.password),
onTextChanged = {
onEvent(
EditFormUiEvent.OnPasswordChanged(it)
)
},
...
)
}
FormLoadedState.Loading -> {
LoadingScreen()
}
is FormLoadedState.Success -> {
OutlinedInput(
label = stringResource(id = R.string.first_name),
initialValue = uiState.formLoadedState.user.firstName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnFirstNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.last_name),
initialValue = uiState.formLoadedState.user.lastName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnLastNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.password),
initialValue = uiState.formLoadedState.user.password,
onTextChanged = {
onEvent(
EditFormUiEvent.OnPasswordChanged(it)
)
},
...
)
}
}
}
MainButton(
label = stringResource(id = R.string.main_button_done),
onClick = {
focusManager.clearFocus()
onEvent(EditFormUiEvent.OnDoneClick)
}
)
}
}
My OutlinedInput composable is just a wrapper around OutlinedTextField, and is as follows:
#Composable
fun OutlinedInput(
modifier: ...,
label: String,
initialValue: String? = null,
textStyle: ...,
onTextChanged: (String) -> Unit,
isError: Boolean = false,
...
) {
var text by rememberSaveable { mutableStateOf(initialValue ?: "") }
OutlinedTextField(
modifier = modifier,
value = text,
onValueChange = {
text = it
onTextChanged(it)
},
isError = isError,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onNext = onNext,
onDone = onDone,
),
textStyle = textStyle,
label = {
Text(
text = label
)
},
)
}
And finally my viewmodel class:
class EditFormScreenViewModel(
application: Application,
val id: Int? = null,
private val userRepository: UserRepository,
private val coroutineContextProvider: CoroutineContextProvider,
) : AndroidViewModel(application) {
var uiState: EditFormPageUiState by mutableStateOf(
EditFormPageUiState()
)
init {
if (id == null) {
// we are creating a new user
uiState = uiState.copy(
user = User(
...
)
)
} else {
// collect user flow to pre-populate UI fields
viewModelScope.launch {
uiState = uiState
.copy(
formLoadedState = FormLoadedState.Loading
)
withContext(coroutineContextProvider.IO) {
collectGetUserByIdFlow(id)
}
}
}
}
private suspend fun collectGetUserByIdFlow(id: Int) {
userRepository.getUserById(id = id)
.stateIn(viewModelScope)
.collectLatest(::onGetUserByIdUpdate)
}
private suspend fun onGetUserByIdUpdate(user: User) {
withContext(coroutineContextProvider.Main) {
uiState = uiState.copy(
formLoadedState = FormLoadedState.Success(
user = user
)
)
}
}
/**
* Manages user form input event & validation
*/
fun onEvent(uiEvent: EditFormUiEvent) {
when (uiEvent) {
is EditFormUiEvent.Initial -> {
// do nothing
}
is EditFormUiEvent.OnFirstNameChanged -> {
...
}
...
is EditFormUiEvent.OnDoneClick -> {
validateInputs()
}
}
}
private fun validateInputs() {
...
val hasError = listOf(
firstNameResult,
lastNameResult,
passwordResult,
).any { !it.status }
if(!hasError) {
viewModelScope.launch {
upsertUser(user)
}
}
}
}
private suspend fun upsertUser(user: User) {
userRepository.upsertUser(user = user)
withContext(coroutineContextProvider.Main) {
uiState = uiState.copy(
validationEvent = EditFormUiEvent.Success
)
}
}
}
The above works completely as expected: Arrive at screen -> init view model loads data -> while data is loading shows a progress bar -> when data is done loading, ui state is updated to success and the data is preloaded into the form.
However, I can't help but feel like I am missing a simpler way to achieve this and avoid the repetition in the EditFormPage composable, specifically, referring to this part:
when(uiState.formLoadedState) {
FormLoadedState.Initial -> {
OutlinedInput(
label = stringResource(id = R.string.first_name),
onTextChanged = {
onEvent(
EditFormUiEvent.OnFirstNameChanged(it)
)
},
isError = uiState.isFirstNameError,
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = {},
)
OutlinedInput(
label = stringResource(id = R.string.last_name),
onTextChanged = {
onEvent(
EditFormUiEvent.OnLastNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.password),
onTextChanged = {
onEvent(
EditFormUiEvent.OnPasswordChanged(it)
)
},
...
)
}
FormLoadedState.Loading -> {
LoadingScreen()
}
is FormLoadedState.Success -> {
OutlinedInput(
label = stringResource(id = R.string.first_name),
initialValue = uiState.formLoadedState.user.firstName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnFirstNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.last_name),
initialValue = uiState.formLoadedState.user.lastName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnLastNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.password),
initialValue = uiState.formLoadedState.user.password,
onTextChanged = {
onEvent(
EditFormUiEvent.OnPasswordChanged(it)
)
},
...
)
}
}
}
...
How can I, taking my current structure into account, achieve something where my edit form page instead looks like this? (i.e.: no initial/loading/success states):
OutlinedInput(
label = stringResource(id = R.string.first_name),
initialValue = uiState.user.firstName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnFirstNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.last_name),
initialValue = uiState.user.lastName,
onTextChanged = {
onEvent(
EditFormUiEvent.OnLastNameChanged(it)
)
},
...
)
OutlinedInput(
label = stringResource(id = R.string.password),
initialValue = uiState.user.password,
onTextChanged = {
onEvent(
EditFormUiEvent.OnPasswordChanged(it)
)
},
...
)
I would expect the above to work, since initial value in the OutlinedInput can use something uiState.user.firstName, and I would think that once I do this in the viewmodel:
private suspend fun onGetUserByIdUpdate(user: User) {
withContext(coroutineContextProvider.Main) {
uiState = uiState.copy(
user = user
)
}
}
The OutlinedInput would recompose, and display the updated uiState's user's data. However, this doesn't happen.
Please check below code and I think this will help you. A similar implementation
val fullNameText = remember { mutableStateOf("") }
fullNameText.value = state.user.fullName
TextField(
label = stringResource(id = R.string.fullname),
textValue = fullNameText.value,
onValueChange = { newText ->
fullNameText.value = newText.trim()
onEvent(ProfileEditEvent.SetFullName( newText.trim()))
}
)
Here we are assaigning this into a mutableState rememberable then set state to it and then reassign. I think somebody will suggest you a better option.

State hosting in jetpack compose

I am learning State hosting in jetpack compose. I am trying to separate my variable in single function and view logic to separate function. But I am getting weird issue in my code. Can someone guide me on this?
PulsePressure
#Composable
fun PulsePressure() {
var systolicTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
var isSystolicTextFieldValueError by rememberSaveable { mutableStateOf(false) }
var diastolicTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
var isDiastolicTextFieldValueError by rememberSaveable { mutableStateOf(false) }
InputWithUnitContainer(
systolicTextFieldValue,
isError = {
isSystolicTextFieldValueError = it
},
incrementTextFieldValue = {
systolicTextFieldValue = it
})
InputWithUnitContainer(
diastolicTextFieldValue,
isError = {
isDiastolicTextFieldValueError = it
},
incrementTextFieldValue = {
diastolicTextFieldValue = it
}
)
}
InputWithUnitContainer
#Composable
fun InputWithUnitContainer(
textFieldValue: TextFieldValue,
isError: (Boolean) -> Unit,
incrementTextFieldValue: (TextFieldValue) -> Unit,
) {
val maxLength = 4
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = textFieldValue,
singleLine = true,
onValueChange = {
if (it.text.length <= maxLength) {
incrementTextFieldValue(it)
}
isError(false)
},
isError = isError,
textStyle = RegularSlate20
)
}
}
Error on Textfield
None of the following functions can be called with the arguments supplied.
isError parameter in TextField is a Boolean.
Change 1
You have to change
var isDiastolicTextFieldValueError by rememberSaveable { mutableStateOf(false) }
to
var (isDiastolicTextFieldValueError, updateIsDiastolicTextFieldValueError) = rememberSaveable {
mutableStateOf(
false
)
}
This gives the value and a function to update the value.
Change 2
Then pass both the value and the method to the composable.
Usage
InputWithUnitContainer(
textFieldValue = diastolicTextFieldValue,
isError = isDiastolicTextFieldValueError,
updateIsError = updateIsDiastolicTextFieldValueError,
incrementTextFieldValue = {
diastolicTextFieldValue = it
}
)
Method signature change
#Composable
fun InputWithUnitContainer(
textFieldValue: TextFieldValue,
isError: Boolean,
updateIsError: (Boolean) -> Unit,
incrementTextFieldValue: (TextFieldValue) -> Unit,
) {
...
}
Change 3
Update the usage
TextField(
value = textFieldValue,
singleLine = true,
onValueChange = {
if (it.text.length <= maxLength) {
incrementTextFieldValue(it)
}
updateIsError(false)
},
isError = isError,
textStyle = TextStyle(),
)

How to show error message using compose and a viewModel

I want to display an error message with compose, this works the problem is that the viewModel state call always the state function
I have a Textfield like this
class Test {
#Composable
fun Test() {
val viewModel:TestViewModel = viewModel()
var text by rememberSaveable { mutableStateOf("") }
var isError by rememberSaveable { mutableStateOf(false) }
// liveData
val state by viewModel.viewState.observeAsState(EmailViewState.Nothing)
when (state) {
EmailViewState.OnInvalidPassword -> {
shouldDisplayPasswordError = true
}
/*....*/
}
Column {
TextField(
value = text,
singleLine = true,
isError = isError,
onValueChange = {
text = it
isError = false
},
)
if (isError) {
Text(
text = "Error message",
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
class TestViewModel: ViewModel(){
val viewState = MutableLiveData<EmailViewState>()
fun validate(email:String){
//some validations
viewState.postValue(OnInvalidPassword)
}
}
}
The problem is everytime the recomposition happens, the
val state by viewModel.viewState.observeAsState(EmailViewState.Nothing) is called with the latest value (Invalid) and override the behavior of onValueChange where i set isError = false is any way to combine or avoid the viewState being called in every re composition?
Thanks

login in jetpack compose(single event)

I want to call "onLogin" function and pass user but I can't access "onLogin" in ViewModel , I tried to use mutableLiveData but I couldn't,I don't know should I pass onLogin to viewmodel or this is a bad practice
there is button whose title is "Sign In" , it calls method in ViewModel called "Submit" use apollo (graphql) to get the user
SignInScreen
#Composable
fun SignInScreen(
onNavigateToSignUp:() -> Unit,
onLogin:(User) -> Unit
){
val viewModel:SignInViewModel = viewModel()
Scaffold(
bottomBar = {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.padding(bottom = 10.dp)
.fillMaxWidth()
) {
Text(text = "Don't have an account?")
Text(
text = "Sign Up.",
modifier = Modifier
.padding(start = 5.dp)
.clickable { onNavigateToSignUp() },
fontWeight = FontWeight.Bold
)
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Instagram")
Spacer(modifier = Modifier.size(30.dp))
Input(viewModel.username,placeholder = "username"){
viewModel.username = it
}
Spacer(modifier = Modifier.size(20.dp))
Input(viewModel.password,placeholder = "Password"){
viewModel.password = it
}
Spacer(modifier = Modifier.size(30.dp))
Button(onClick = {viewModel.submit()},modifier = Modifier.fillMaxWidth()) {
Text(text = "Sign In")
}
}
}
}
ViewModel
class SignInViewModel(application:Application):AndroidViewModel(application) {
var username by mutableStateOf("")
var password by mutableStateOf("")
private val context = application.applicationContext
private val _user = MutableLiveData<User>(null)
val user:LiveData<User> get() = _user
fun submit(){
viewModelScope.launch {
val response = apolloClient.mutate(LoginMutation(username = Input.fromNullable(username),password = Input.fromNullable(password))).await()
_user.value = response.data?.login?.user as User
}
}
}
This is how I did it.
1. First I created this class to communicate from ViewModel to view(s) and to have stateful communication where the UI knows what to show with every update and through one live data.
sealed class UIState<out T>() {
class Idle() : UIState<Nothing>()
class Loading(val progress: Int = 0) : UIState<Nothing>()
class Success<out T>(val data: T?) : UIState<T>()
class Error(
val error: Throwable? = null,
val message: String? = null,
val title: String? = null
) : UIState<Nothing>()
}
2. Then Of course create the live data in ViewModel and also an immutable copy for the view:
private val _loginState by lazy { MutableLiveData<UIState<ResponseUser>>() }
val loginState: LiveData<UIState<ResponseUser>> = _loginState
fun performLogin(username: String, password: String) {
viewModelScope.launch {
_loginState.postValue(loading)
// your login logic here
if ("login was successful") {
_loginState.postValue(UIState.Success("your login response if needed in UI"))
} else {
_loginState.postValue(UIState.Error("some error here"))
}
}
}
3. Now in the UI I need to observe this live data as a state, which is pretty easy we have delegate literally called observeAsState. But here is the catch and that's if you are doing something like navigation, which you only want to happen only once:
#Composable
fun LoginScreen(viewModel: LoginViewModel) {
val loginState by viewModel.loginState.observeAsState(UIState.Idle())
val hasHandledNavigation = remember { mutableStateOf(false)}
if (loginState is UIState.Success && !hasHandledNavigation.value ) {
navigateToWelcomeScreen()
else {
LoginScreenUI(loginState) { username, password ->
viewModel.performLogin(username, password)
}
}
}
4. in the UI you want, among other things, two text fields and a button, and you want to remember the username and password that entered:
#Composable
fun LoginScreenUI(
state: UIState<ResponseUser>, onLoginButtonClicked: (username: String, password: String) -> Unit
) {
Column() {
var username by rememberSaveable { mutableStateOf("") }
OutlinedTextField(
value = username,
onValueChange = { username = it },
)
var password by rememberSaveable { mutableStateOf("") }
OutlinedTextField(
value = password,
onValueChange = { password = it },
)
Button(
onClick = {
onLoginButtonClicked(
username, password
)
}
) {
Text(text = "Login")
}
if (state is UIState.Error) {
AlertDialogComponent(state.title, state.message)
}
}
}
I hope I've covered everything :D
My solution is to use the LaunchedEffect because the Android developer documentation is mentioning showing SnackBar as an example which is a single time event, code example following the same as Amin Keshavarzian Answer
just change the part 3 to use LaunchedEffect instead of the flag state hasHandledNavigation
#Composable
fun LoginScreen(viewModel: LoginViewModel) {
val loginState by viewModel.loginState.observeAsState(UIState.Idle())
LaunchedEffect(key1 = loginState) {
if (loginState is UIState.Success)
navigateToWelcomeScreen()
}
LoginScreenUI(loginState) { username, password ->
viewModel.performLogin(username, password)
}
}

How to use by delegate for mutableState yet make it passable to another function?

I have this composable function that a button will toggle show the text and hide it
#Composable
fun Greeting() {
Column {
val toggleState = remember {
mutableStateOf(false)
}
AnimatedVisibility(visible = toggleState.value) {
Text(text = "Edit", fontSize = 64.sp)
}
ToggleButton(toggleState = toggleState) {}
}
}
#Composable
fun ToggleButton(modifier: Modifier = Modifier,
toggleState: MutableState<Boolean>,
onToggle: (Boolean) -> Unit) {
TextButton(
modifier = modifier,
onClick = {
toggleState.value = !toggleState.value
onToggle(toggleState.value)
})
{ Text(text = if (toggleState.value) "Stop" else "Start") }
}
One thing I didn't like the code is val toggleState = remember { ... }.
I prefer val toggleState by remember {...}
However, if I do that, as shown below, I cannot pass the toggleState over to ToggleButton, as ToggleButton wanted mutableState<Boolean> and not Boolean. Hence it will error out.
#Composable
fun Greeting() {
Column {
val toggleState by remember {
mutableStateOf(false)
}
AnimatedVisibility(visible = toggleState) {
Text(text = "Edit", fontSize = 64.sp)
}
ToggleButton(toggleState = toggleState) {} // Here will have error
}
}
#Composable
fun ToggleButton(modifier: Modifier = Modifier,
toggleState: MutableState<Boolean>,
onToggle: (Boolean) -> Unit) {
TextButton(
modifier = modifier,
onClick = {
toggleState.value = !toggleState.value
onToggle(toggleState.value)
})
{ Text(text = if (toggleState.value) "Stop" else "Start") }
}
How can I fix the above error while still using val toggleState by remember {...}?
State hoisting in Compose is a pattern of moving state to a composable's caller to make a composable stateless. The general pattern for state hoisting in Jetpack Compose is to replace the state variable with two parameters:
value: T: the current value to display
onValueChange: (T) -> Unit: an event that requests the value to change, where T is the proposed new value
You can do something like
// stateless composable is responsible
#Composable
fun ToggleButton(modifier: Modifier = Modifier,
toggle: Boolean,
onToggleChange: () -> Unit) {
TextButton(
onClick = onToggleChange,
modifier = modifier
)
{ Text(text = if (toggle) "Stop" else "Start") }
}
and
#Composable
fun Greeting() {
var toggleState by remember { mutableStateOf(false) }
AnimatedVisibility(visible = toggleState) {
Text(text = "Edit", fontSize = 64.sp)
}
ToggleButton(toggle = toggleState,
onToggleChange = { toggleState = !toggleState }
)
}
You can also add the same stateful composable which is only responsible for holding internal state:
#Composable
fun ToggleButton(modifier: Modifier = Modifier) {
var toggleState by remember { mutableStateOf(false) }
ToggleButton(modifier,
toggleState,
onToggleChange = {
toggleState = !toggleState
},
)
}

Categories

Resources