My view recompose itself muitple times after changing state - android

i am working on compose project. I have simple login page. After i click login button, loginState is set in viewmodel. The problem is when i set loginState after service call, my composable recomposed itself multiple times. Thus, navcontroller navigates multiple times. I don't understand the issue. Thanks for helping.
My composable :
#Composable
fun LoginScreen(
navController: NavController,
viewModel: LoginViewModel = hiltViewModel()
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceEvenly
) {
val email by viewModel.email
val password by viewModel.password
val enabled by viewModel.enabled
if (viewModel.loginState.value) {
navController.navigate(Screen.HomeScreen.route) {
popUpTo(Screen.LoginScreen.route) {
inclusive = true
}
}
}
LoginHeader()
LoginForm(
email = email,
password = password,
onEmailChange = { viewModel.onEmailChange(it) },
onPasswordChange = { viewModel.onPasswordChange(it) }
)
LoginFooter(
enabled,
onLoginClick = {
viewModel.login()
},
onRegisterClick = {
navController.navigate(Screen.RegisterScreen.route)
}
)
}
ViewModel Class:
#HiltViewModel
class LoginViewModel #Inject constructor(
private val loginRepository: LoginRepository,
) : BaseViewModel() {
val email = mutableStateOf(EMPTY)
val password = mutableStateOf(EMPTY)
val enabled = mutableStateOf(false)
val loginState = mutableStateOf(false)
fun onEmailChange(email: String) {
this.email.value = email
checkIfInputsValid()
}
fun onPasswordChange(password: String) {
this.password.value = password
checkIfInputsValid()
}
private fun checkIfInputsValid() {
enabled.value =
Validator.isEmailValid(email.value) && Validator.isPasswordValid(password.value)
}
fun login() = viewModelScope.launch {
val response = loginRepository.login(LoginRequest(email.value, password.value))
loginRepository.saveSession(response)
loginState.value = response.success ?: false
}
}

You should not cause side effects or change the state directly from the composable builder, because this will be performed on each recomposition.
Instead you can use side effects. In your case, LaunchedEffect can be used.
if (viewModel.loginState.value) {
LaunchedEffect(Unit) {
navController.navigate(Screen.HomeScreen.route) {
popUpTo(Screen.LoginScreen.route) {
inclusive = true
}
}
}
}
But I think that much better solution is not to listen for change of loginState, but to make login a suspend function, wait it to finish and then perform navigation. You can get a coroutine scope which will be bind to your composable with rememberCoroutineScope. It can look like this:
suspend fun login() : Boolean {
val response = loginRepository.login(LoginRequest(email.value, password.value))
loginRepository.saveSession(response)
return response.success ?: false
}
Also check out Google engineer thoughts about why you shouldn't pass NavController as a parameter in this answer (As per the Testing guide for Navigation Compose ...)
So your view after updates will look like:
#Composable
fun LoginScreen(
viewModel: LoginViewModel = hiltViewModel(),
onLoggedIn: () -> Unit,
onRegister: () -> Unit,
) {
// ...
val scope = rememberCoroutineScope()
LoginFooter(
enabled,
onLoginClick = {
scope.launch {
if (viewModel.login()) {
onLoggedIn()
}
}
},
onRegisterClick = onRegister
)
// ...
}
And your navigation route:
composable(route = "login") {
LoginScreen(
onLoggedIn = {
navController.navigate(Screen.HomeScreen.route) {
popUpTo(Screen.LoginScreen.route) {
inclusive = true
}
}
},
onRegister = {
navController.navigate(Screen.RegisterScreen.route)
}
)
}

Related

Navigate in compose based on result

I've this screen:
#Composable
fun SetNewPasswordScreen(
viewModel: SetNewPasswordViewModel,
navigateToSetDetails: () -> Unit,
) {
val state by viewModel.state.collectAsState()
if (state.passwordConfirmed) {
LaunchedEffect(Unit) {
navigateToSetDetails()
}
} else {
SetNewPasswordScreen(
confirmNewPassword = viewModel::confirmNewPassword
)
}
}
and this is the view model:
#HiltViewModel
class SetNewPasswordViewModel #Inject constructor(
private val setNewPassword: SetNewPassword,
) : ViewModel() {
val state: StateFlow<SetNewPasswordViewState> = setNewPassword.flow
.map { hasSetNewPassword ->
SetNewPasswordViewState(passwordConfirmed = hasSetNewPassword)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = SetNewPasswordViewState.Empty
)
fun confirmNewPassword(newPassword: String, repeatedNewPassword: String) {
viewModelScope.launch {
setNewPassword(SetNewPassword.Params(newPassword, repeatedNewPassword))
}
}
}
My problem is when I try to navigate back from
SetDetailsScreen
to
SetNewPasswordScreen
I'm getting true for state.passwordConfirmed again, and I'm stuck in a loop.
I've thought about resetting passwordConfirmed right after the if statement, or using previousBackStackEntry in SetDetailsScreen to store that password should be confirmed again, but it doesn't seem right to do so every time I want to navigate back to a screen that got a result. What's the best practice for this kind of situation?

Compose app doesn't load api data after launch

I'm creating a Pokémon app with Jetpack Compose. I'm testing it with two smartphones: Xioami Mi 11T Pro (Android 12) and Xiaomi Mi 8 Lite (Android 10).
Well, when I launch the app in the Mi 8 Lite, it starts correctly, the pokemon list loads perfectly.
But when I launch the app with the Mi 11 T Pro, it doesn't load, nothing shows. I discovered two things:
If I open the Layout Inspector it loads inmediately, without doing anything more...
When the screen is empty (just after launch, before it loads), If I click 1-2 times on the screen it starts to send the request and loads correctly.
Why is this happening?
I attach my ViewModel and my MainActivity.
PokemonListViewModel.kt
#HiltViewModel
class PokemonListViewModel #Inject constructor(
private val repository: PokemonRepositoryImpl
) : ViewModel() {
private var currentPage = 0
var pokemonList = mutableStateOf<List<PokedexListEntry>>(listOf())
var loadError = mutableStateOf("")
var isLoading = mutableStateOf(false)
var endReached = mutableStateOf(false)
private var cachedPokemonList = listOf<PokedexListEntry>()
private var isSearchStarting = true
var isSearching = mutableStateOf(false)
init {
loadPokemonList()
}
// TODO: Search online, not only already loaded pokémon
fun searchPokemonList(query: String) {
val listToSearch = if (isSearchStarting) {
pokemonList.value
} else {
// If we typed at least one character
cachedPokemonList
}
viewModelScope.launch(Dispatchers.Default) {
if (query.isEmpty()) {
pokemonList.value = cachedPokemonList
isSearching.value = false
isSearchStarting = true
return#launch
}
val results = listToSearch.filter {
// Search by name or pokédex number
it.pokemonName.contains(query.trim(), true) ||
it.number.toString() == query.trim()
}
if (isSearchStarting) {
cachedPokemonList = pokemonList.value
isSearchStarting = false
}
// Update entries with the results
pokemonList.value = results
isSearching.value = true
}
}
fun loadPokemonList() {
viewModelScope.launch {
isLoading.value = true
val result = repository.getPokemonList(PAGE_SIZE, currentPage * PAGE_SIZE)
when (result) {
is Resource.Success -> {
endReached.value = currentPage * PAGE_SIZE >= result.data!!.count
val pokedexEntries = result.data.results.mapIndexed { index, entry ->
val number = getPokedexNumber(entry)
val url = getImageUrl(number)
PokedexListEntry(
entry.name.replaceFirstChar(Char::titlecase),
url,
number.toInt()
)
}
currentPage++
loadError.value = ""
isLoading.value = false
pokemonList.value += pokedexEntries
}
is Resource.Error -> {
loadError.value = result.message!!
isLoading.value = false
}
is Resource.Loading -> {
isLoading.value = true
}
}
}
}
private fun getImageUrl(number: String): String {
return "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${number}.png"
}
private fun getPokedexNumber(entry: Result): String {
return if (entry.url.endsWith("/")) {
entry.url.dropLast(1).takeLastWhile { it.isDigit() }
} else {
entry.url.takeLastWhile { it.isDigit() }
}
}
}
MainActivity.kt
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val argPokemonName = "pokemonName"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
JetpackComposePokedexTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "pokemon_list_screen") {
composable("pokemon_list_screen") {
PokemonListScreen(navController = navController)
}
composable(
"pokemon_detail_screen/{$argPokemonName}",
arguments = listOf(
navArgument(argPokemonName) {
type = NavType.StringType
}
)
) {
val pokemonName = remember {
it.arguments?.getString(argPokemonName)
}
PokemonDetailScreen(
pokemonName = pokemonName?.lowercase(Locale.ROOT) ?: "",
navController = navController
)
}
}
}
}
}
}
If someone knows why it doesn't load... I suspect that maybe init { } or Hilt injection are doing something that makes init doesn't start or something.
Thanks for your time and help!
Well, it seems is a Xiaomi reported Bug that google won't fix, you can see it here:
https://issuetracker.google.com/issues/227926002
It worked for me adding a little delay before set content and it seems to be working:
lifecycleScope.launch {
delay(300)
setContent {
JetpackComposePokedexTheme {
...
}
}
}
Also you can see: compose NavHost Start the white Screen

Jetpack Compose Navigation default value not working for boolean

Trying to send a default value of boolean type through to one of my screens using compose navigation, but the default value never gets applied - can anyone see what the issue might be here?
route declaration ->
sealed class Screens(
val coreRoute: String,
val routeWithArgs: String? = null
) {
fun navigateWithArgs(
navController: NavController,
coreRoute: String,
args: List<Any>
) {
navController.navigate("$coreRoute/${args.joinToString("/")}")
}
object Splash : Screens(coreRoute = "splash")
object Establish : Screens(
coreRoute = "establish",
routeWithArgs = "establish/{isChanging}"
)
}
Screen setup ->
#OptIn(ExperimentalAnimationApi::class)
fun NavGraphBuilder.addEstablishScreen(
navController: NavController
) {
Screens.Establish.routeWithArgs?.let { route ->
composable(
route = route,
arguments = listOf(navArgument("isChanging") {
defaultValue = false
type = NavType.BoolType
})
) {
EstablishComposable(
navigateToHome = {
navController.navigate(Screens.Home.coreRoute)
},
navigateBack = {
navController.navigateUp()
}
)
}
}
}
Navigating to screen ->
#OptIn(ExperimentalAnimationApi::class)
fun NavGraphBuilder.addSplashScreen(
navController: NavController
) {
composable(route = Screens.Splash.coreRoute) {
SplashComposable(
navigateToEstablish = {
Screens.Establish.routeWithArgs?.let { route -> navController.navigate(route) }
},
navigateToHome = {
navController.navigate(Screens.Home.coreRoute)
}
)
}
}

How to observe a MutableStateFlow list in Jetpack Compose

I have to implement Google's "Place Autocomplete" on Jetpack Compose, but the problem is that once I get the list of places, I can't update the UI.
Going into more detail, the places received from the google API are stored in a MutableStateFlow <MutableList <String>> and the status is observed in a Composable function via: databaseViewModel.autocompletePlaces.collectAsState(). However, when a new item is added to the list, the Composable function is not re-compiled
Class that gets the places:
class AutocompleteRepository(private val placesClient: PlacesClient)
{
val autocompletePlaces = MutableStateFlow<MutableList<String>>(mutableListOf())
fun fetchPlaces(query: String) {
val token = AutocompleteSessionToken.newInstance()
val request = FindAutocompletePredictionsRequest.builder()
.setSessionToken(token)
.setCountry("IT")
.setQuery(query)
.build()
placesClient.findAutocompletePredictions(request).addOnSuccessListener {
response: FindAutocompletePredictionsResponse ->
autocompletePlaces.value.clear()
for (prediction in response.autocompletePredictions) {
autocompletePlaces.value.add(prediction.getPrimaryText(null).toString())
}
}
}
}
ViewModel:
class DatabaseViewModel(application: Application): AndroidViewModel(application) {
val autocompletePlaces: MutableStateFlow<MutableList<String>>
val autocompleteRepository: AutocompleteRepository
init {
Places.initialize(application, apiKey)
val placesClient = Places.createClient(application)
autocompleteRepository = AutocompleteRepository(placesClient)
autocompletePlaces = autocompleteRepository.autocompletePlaces
}
fun fetchPlaces(query: String)
{
autocompleteRepository.fetchPlaces(query)
}
}
Composable function:
#Composable
fun dropDownMenu(databaseViewModel: DatabaseViewModel) {
var placeList = databaseViewModel.autocompletePlaces.collectAsState()
//From here on I don't think it's important, but I'll put it in anyway:
var expanded by rememberSaveable { mutableStateOf(true) }
var placeName by rememberSaveable { mutableStateOf("") }
Column {
OutlinedTextField(value = placeName, onValueChange =
{ newText ->
placeName = newText
databaseViewModel.fetchPlaces(newText)
})
DropdownMenu(expanded = expanded,
onDismissRequest = { /*TODO*/ },
properties = PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true)) {
placeList.value.forEach { item ->
DropdownMenuItem(text = { Text(text = item) },
onClick = {
placeName = item
expanded = false
})
}
}
}
}
EDIT:
solved by changing: MutableStateFlow<MutableList<String>>(mutableListOf()) to MutableStateFlow<List<String>>(listOf()), but I still can't understand what has changed, since the structure of the list was also changed in the previous code
class AutocompleteRepository(private val placesClient: PlacesClient)
{
val autocompletePlaces = MutableStateFlow<List<String>>(listOf()) //Changed
fun fetchPlaces(query: String) {
val token = AutocompleteSessionToken.newInstance()
val request = FindAutocompletePredictionsRequest.builder()
.setSessionToken(token)
.setCountry("IT")
.setQuery(query)
.build()
val temp = mutableListOf<String>()
placesClient.findAutocompletePredictions(request).addOnSuccessListener {
response: FindAutocompletePredictionsResponse ->
for (prediction in response.autocompletePredictions) {
temp.add(prediction.getPrimaryText(null).toString())
}
autocompletePlaces.value = temp //changed
}
}
}
In your old code you were just changing the MutableList instance inside the StateFlow by adding items to it. This does not trigger an update because it is still the same list (but with extra values in it).
With your new code you are changing the whole value of the StateFlow to a new list which triggers an update.
You can simplify your new code to something like:
autocompletePlaces.update {
it + response.autocompletePredictions.map { prediction ->
prediction.getPrimaryText(null).toString()
}
}

Flow doesn't update Composable

I faced the following problem:
There's the registration screen, which has several input fields on it. When a user enters something, the value is passed to ViewModel, set to screen state and passed back to the screen via StateFlow. From the composable, I'm observing this StateFlow. The problem is that Composable is not invalidated after emitting the new value to the Flow.
Here's the ViewModel code:
class RegistrationViewModel : BaseViewModel() {
private val screenData = CreateAccountRegistrationScreenData.init()
private val _screenDataFlow = MutableStateFlow(screenData)
internal val screenDataFlow = _screenDataFlow.asStateFlow()
internal fun updateFirstName(name: String) {
screenData.firstName = name
updateScreenData()
}
private fun updateScreenData() {
viewModelScope.launch {
_screenDataFlow.emit(screenData.copy())
}
println()
}
}
Here's the composable code:
#Composable
fun RegistrationScreen(navController: NavController, stepName: String) {
val focusManager = LocalFocusManager.current
val viewModel: RegistrationViewModel by rememberInstance()
val screenData by viewModel.screenDataFlow.collectAsState()
Scaffold {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onTap = {
focusManager.clearFocus()
})
},
) {
val (
textTopLabel,
textBottomLabel,
tfFirstName,
tfLastName,
tfEmail,
tfPassword,
btnNext
) = createRefs()
...
RegistrationOutlinedTextField(
value = screenData.firstName ?: "",
constraintAsModifier = {
constrainAs(tfFirstName) {
top.linkTo(textBottomLabel.bottom, margin = Padding30)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
},
label = { Text("First Name", style = PoppinsNormalStyle14) },
leadingIcon = {
Image(
painter = painterResource(id = R.drawable.ic_user_login),
contentDescription = null
)
},
onValueChange = { viewModel.updateFirstName(it) }
)
}
}
Thanks in advance for any help
Your problem is that you're mutating your state, so the equals used in the flow always returns true.
Change this
internal fun updateFirstName(name: String) {
screenData.firstName = name
updateScreenData()
}
private fun updateScreenData() {
viewModelScope.launch {
_screenDataFlow.emit(screenData.copy())
}
println()
}
to
internal fun updateFirstName(name: String) {
viewModelScope.launch {
_screenDataFlow.emit(screenData.copy(firstName = name))
}
}
In your case your MutableStateFlow holds a link to a mutable value, that's why when you pass the value which hashcode(which is calculated on all field values) is identical, the flow doesn't update the value.
Check out Why is immutability important in functional programming?
data class is a nice tool to be used, which will provide you all copy out of the box, but you should emit using var and only use val for your fields to avoid mistakes.
Also, with MutableStateFlow you always have access to value, so you don't need to store it in a separate variable:
class RegistrationViewModel : BaseViewModel() {
private val _screenDataFlow = MutableStateFlow(CreateAccountRegistrationScreenData())
internal val screenDataFlow = _screenDataFlow.asStateFlow()
internal fun updateFirstName(name: String) {
_screenDataFlow.update { screenData ->
screenData.copy(firstName = name)
}
}
}

Categories

Resources