I have a login scren and when the login is successful and the view model updates the mutable state variable, my expectation is that a new composable function is called to show a new screen and the login one is removed. The problem is that when the new screen (aka Screen.AccountsScreen) is shown, its content keeps flashing/redrawing and same thing happen with the login form which never gets destroyed (I know this because the log message 'Recomponing...' gets printed endless). I assume this happens because the isLoginSuccessful state is always true. It seems I need an event that can be consumed only once, is this correct? If so, how can I do that?
LoginViewModel.kt
#HiltViewModel
class LoginViewModel #Inject constructor() : ViewModel() {
var isLoginSuccessful by mutableStateOf(false)
var errorMessage by mutableStateOf("")
fun onLoginClick(email: String, password:String) {
errorMessage = ""
if (credentialsValid(email, password)) {
isLoginSuccessful = true
} else {
errorMessage = "Email or password invalid"
isLoginSuccessful = false
}
}
}
LoginScreen.kt
#Composable
fun loginScreen(
navController: NavController,
viewModel: LoginViewModel = hiltViewModel()
) {
println("Recomponing...")
// Here gos the code for the login form
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Composite navigation recomposes both disappearing and appearing views during transition. This is the expected behavior.
You're calling navigate on each recomposition. Your problem lays in these lines:
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
You shouldn't change state directly from view builders. In this case LaunchedEffect should be used:
if (viewModel.isLoginSuccessful) {
LaunchedEffect(Unit) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Check out more in side effects documentation.
For me, I see flicker because the activity background is white, but I am on dark mode.
Change your app theme to daynight, try adding
implementation 'com.google.android.material:material:1.5.0'
and change your theme to
<style name="Theme.MyStockApp" parent="Theme.Material3.DayNight.NoActionBar" />
Related
I have an app that has a bottom nav. It has some tabs, now, from tab A I have a ticker that updates a value in the view every 5 seconds.
When I switch to tab B I'm expecting that the scope of the viewmodel that is associated with the route A is no longer active to keep executing the code, although I expect the viewmodel to survive since there is no sense of removing it on tab change.
My current code
NavGraph
NavHost(navController, startDestination = BottomNavItem.HomeScreen.screen_route) {
composable(BottomNavItem.HomeScreen.screen_route) {
val homeViewModel: HomeViewModel = hiltViewModel()
val homeUiState = homeViewModel.uiState.collectAsState()
HomeScreen(uiState = homeUiState.value)
}
composable(BottomNavItem.FiatToCryptoScreen.screen_route) {
val viewModel: CryptoToFiatViewModel = hiltViewModel()
val uiState = viewModel.uiState.collectAsState()
CryptoToFiatScreen(uiState = uiState.value)
}
}
Now, HomeScreen takes HomeViewModel, which in the init block, it will fire a request every 5 seconds to get latest results from a coin
#HiltViewModel
class HomeViewModel #Inject constructor(private val repo: HomeRepository) : ViewModel() {
init {
updateFeaturedCoin()
}
private fun updateFeaturedCoin() {
viewModelScope.launch {
while (isActive) {
val featuredCoinPrice = repo.getTickerForCoin("BTC")
if (featuredCoinPrice.isSuccess) {
homeScreenState.update {
it.copy(
isLoading = false,
featuredCoinPrice = featuredCoinPrice.getOrNull()?.price
)
}
}
delay(5000)
}
}
}
....
}
Now, this is working fine, my problem is that when I change tabs, let's say, going to CryptoTofiatScreen, and if I put a breakpoint in the isActive condition, this will never be false, and I need this cicle to stop executing if I move to another tab, because now the HomeViewModel is not in the foreground any more to update its view.
How can I tell HomeViewModel that is not active any more if I switch to another composable in the route?
I thought that scoping the viewmodel to its route will trigger an event to tell the viewmodel is not active any more if I change routes.
One option would be, to use a displosableEffect on the Home Composable, to start/stop viewmodel code:
DisposableEffect(Unit) {
homeViewModel.setIsActive(true)
onDispose {
homeViewModel.setIsActive(false)
}
}
This would require passing the viewModel instance to the component, instead of the just the state, like you are doing now.
I'm implementing registration in my application and, after filling in the respective fields, I click on a button that will make a registration request to the API. Meanwhile, I place a Loading View and when I receive the successful response, I execute the navigation to the OnBoarding screen. The issue is that the navController is always running the navigation and doing the navigation and popUp several times, when it should only do it once. I always get this warning on logs: Ignoring popBackStack to destination 29021787 as it was not found on the current back stack and I am not able to do any click or focus in the OnBoardingScreen.
My code:
val uiState by registerViewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
is BaseViewState.Loading -> LoadingView()
is BaseViewState.Error -> BannerView()
else -> {}
}
On button click I call the viewModel like this:
registerViewModel.onTriggerEvent(
RegisterEvent.CreateUser(
usernameInputState.value.text,
emailInputState.value.text,
passwordInputState.value.text
)
)
And, in ViewModel, I do my request like this:
override fun onTriggerEvent(eventType: RegisterEvent) {
when (eventType) {
is RegisterEvent.CreateUser -> createUser(eventType.username, eventType.email, eventType.password)
}
}
private fun createUser(username: String, email: String, password: String) = safeLaunch {
setState(BaseViewState.Loading)
execute(createUser(CreateUser.Params(username, email, password))) {
setState(BaseViewState.Data(RegisterViewState(it)))
}
}
I guess it should be caused by recomposition, because I put a breakpoint on first when scenario and it stops here multiple times, but only one on ViewModel. How can I fix this?
This issue is here
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
Every time you call navController.navigate NavHost will keep on passing through this block, executing an endless loop.
I suggest having the navigate call from a LaunchedEffect with a key (like this),
LaunchedEffect(key1 = "some key") {
navController.navigate(…)
}
or creating a separate structure namely "Events" where they are emitted as SharedFlow and observed via a Unit keyed LaunchedEffect
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
navController.navigate(…)
}
}
}
}
I am having this issue where I have to navigate when given state gets updated after an asynchronous task gets executed. I am doing it like this:
At ViewModel.kt
fun executeRandomTask() {
viewModelScope.launch {
runAsyncTask()
state = Success
}
}
At Composable.kt
LaunchedEffect(viewModel.state) {
if(viewModel.state is Success) {
navController.navigate("nextScreen")
}
}
Then in the next screen, I click the back navigation button (onBackPressed) and what happens, is that the effect gets launched again. So I end up again in "nextScreen".
When I do this next workaround:
DisposableEffect(viewModel.state) {
if(viewModel.state is Success) {
navController.navigate("nextScreen")
}
onDispose {
viewModel.state = null
}
}
Like this, the viewmodel state gets cleared and it also proves that what is happening is that the navigation controller destroys the previous screen (not sure if it is the intended behavior).
I am not sure about what I should be doing to avoid this, since this is a pretty common scenario and having to clear the state after a certain state is reached looks dirty.
I use SharedFlow for emitting one-time events like this
class MyViewModel : ViewModel() {
private val _eventFlow = MutableSharedFlow<OneTimeEvent>()
val eventFlow = _eventFlow.asSharedFlow()
private fun emitEvent(event: OneTimeEvent) {
viewModelScope.launch { _eventFlow.emit(event) }
}
}
The sealed class for defining events
sealed class OneTimeEvent {
object SomeEvent: OneTimeEvent()
}
And finally in the Screen collect the flow like this
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
LaunchedEffect(Unit) {
viewModel.eventFlow.collect { event ->
when(event){
SomeEvent -> {
//Do Something
}
}
}
}
}
So whenever your state changes you can emit some event and take action against it in your Screen.
I am working on a compose screen, where on application open, i redirect user to profile page. And if profile is complete, then redirect to user list page.
my code is like below
#Composable
fun UserProfile(navigateToProviderList: () -> Unit) {
val viewModel: MainActivityViewModel = viewModel()
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
return
}
else {
//compose elements here
}
}
but the app is blinking and when logged, i can see its calling the above redirect condition again and again. when going through doc, its mentioned that we should navigate only through callbacks. How do i handle this condition here? i don't have onCLick condition here.
Content of composable function can be called many times.
If you need to do some action inside composable, you need to use side effects
In this case LaunchedEffect should work:
LaunchedEffect(viewModel.userProfileComplete == true) {
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
}
}
In the key(first argument of LaunchedEffect) you need to specify some key. Each time this key changes since the last recomposition, the inner code will be called. You may put Unit there, in this case it'll only be called once, when the view appears at the first place
The LaunchedEffect did not work for me since I wanted to use it in UI thread but it wasn't for some reason :/
However, I made this for my self:
#Composable
fun <T> SelfDestructEvent(liveData: LiveData<T>, onEvent: (argument: T) -> Unit) {
val previousState = remember { mutableStateOf(false) }
val state by liveData.observeAsState(null)
if (state != null && !previousState.value) {
previousState.value = true
onEvent.invoke(state!!)
}
}
and you use it like this in any other composables:
SingleEvent(viewModel.someLiveData) {
//your action with that data, whenever it was triggered, but only once
}
in my viewmodel I have a function that sends a login request to a server, when successful i want to update a SharedFlow and using this trigger a navigation to another screen
class loginViewModel: ViewModel() {
private val _authToken = MutableSharedFlow<AuthToken>()
val authToken: SharedFlow<AuthToken> = _authToken
fun login() {
...loginRequest
.onSuccess {
_authToken.emit(value)}
}
in my nav graph I set up the viewmodel like so
private fun NavGraphBuilder.addLogin(navController: NavController) {
composable(AuthenticationScreens.Login.route) {
val loginViewModel: LoginViewModel = hiltViewModel()
val authToken by loginViewModel.authToken.collectAsState()
LoginScreen(
authToken = authToken,
viewModel = loginViewModel,
navigateToDashboard= {
navController.navigate(Dashboard.Dashboard.route)
}
)
}
}
i then do an if check in my loginscreen to navigate to the other screen when the SharedFlow updates
#Composable
fun LoginScreen(
authToken: AuthToken,
viewModel: LoginViewModel,
navigateToDashboard: () -> Unit) {
if (authToken.isNotBlank()) {
navigateToDashboard()}
}
The code fires but is called constantly even when i've navigated to the next screen, causing lots of flickering and bad UI. Is there a different way I'm supposed to handle navigation events like this or a way to have the composable only read the value once when required?
So i've figured out a way to do this but I still feel this isn't fully correct, I set the shared flow back to it's default state after navigating. If anyone has a more concise approach please let me know
LaunchedEffect(key1 = authToken) {
if (authToken.token?.isNotBlank()) {
navigateDashboard()
viewModel._authToken.emit(AuthToken("", 0))
}
}