Compose side effects + Jetpack navigation + onBackPressed = Stuck navigation - android

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.

Related

Navigation is being called every time in Jetpack Compose

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(…)
}
}
}
}

Why does Flow (kotlinx.coroutines.flow) not working with Retry even though I manually set as null in Android?

So basically, on the snackbar action button, I want to Retry API call if user click on Retry.
I have used core MVVM architecture with Flow. I even used Flow between Viewmodel and view as well. Please note that I was already using livedata between view and ViewModel, but now the requirement has been changed and I have to use Flow only. Also I'm not using and shared or state flow, that is not required.
Code:
Fragment:
private fun apiCall() {
viewModel.fetchUserReviewData()
}
private fun setObservers() {
lifecycleScope.launch {
viewModel.userReviewData?.collect {
LogUtils.d("Hello it: " + it.code)
setLoadingState(it.state)
when (it.status) {
Resource.Status.ERROR -> showErrorSnackBarLayout(-1, it.message, {
// Retry action button logic
viewModel.userReviewData = null
apiCall()
})
}
}
}
Viewmodel:
var userReviewData: Flow<Resource<ReviewResponse>>? = emptyFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
userReviewData = flow {
emit(Resource.loading(true))
repository.getUserReviewData().collect {
emit(it)
}
}
}
EDIT in ViewModel:
// var userReviewData = MutableStateFlow<Resource<ReviewResponse>>(Resource.loading(false))
var userReviewData = MutableSharedFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
viewModelScope.launch {
userReviewData.emit(Resource.loading(true))
repository.getUserReviewData().collect {
userReviewData.emit(it)
}
}
}
override fun onCreate() {}
}
EDIT in Activity:
private fun setObservers() {
lifecycleScope.launchWhenStarted {
viewModel.userReviewData.collect {
setLoadingState(it.state)
when (it.status) {
Resource.Status.SUCCESS ->
if (it.data != null) {
val reviewResponse: ReviewResponse = it.data
if (!AppUtils.isNull(reviewResponse)) {
setReviewData(reviewResponse.data)
}
}
Resource.Status.ERROR -> showErrorSnackBarLayout(it.code, it.message) {
viewModel.fetchUserReviewData()
}
}
}
}
}
Now, I have only single doubt, should I use state one or shared one? I saw Phillip Lackener video and understood the difference, but still thinking what to use!
The thing is we only support Portrait orientation, but what in future requirement comes? In that case I think I have to use state one so that it can survive configuration changes! Don't know what to do!
Because of the single responsibility principle, the ViewModel alone should be updating its flow to show the latest requested data, rather than having to cancel the ongoing request and resubscribe to a new one from the Fragment side.
Here is one way you could do it. Use a MutableSharedFlow for triggering fetch requests and flatMapLatest to restart the downstream flow on a new request.
A Channel could also be used as a trigger, but it's a little more concise with MutableSharedFlow.
//In ViewModel
private val fetchRequest = MutableSharedFlow<Unit>(replay = 1, BufferOverflow.DROP_OLDEST)
var userReviewData = fetchRequest.flatMapLatest {
flow {
emit(Resource.loading(true))
emitAll(repository.getUserReviewData())
}
}.shareIn(viewModelScope, SharingStarted.WhlieSubscribed(5000), 1)
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
fetchRequest.tryEmit(Unit)
}
Your existing Fragment code above should work with this, but you no longer need the ?. null-safe call since the flow is not nullable.
However, if the coroutine does anything to views, you should use viewLifecycle.lifecycleScope instead of just lifecycleScope.

How to check if a composable is on top of the back stack or not?

I have a navigation graph containing a HomeScreen and a MyBottomSheet. For bottom sheets I am using Accompanist Navigation.
Both of the destinations share a common ViewModel which is scoped to that navigation graph. From that ViewModel I am exposing a Flow<MyEvent> where MyEvent is:
sealed interface MyEvent
object MyEvent1: MyEvent
object MyEvent2: MyEvent
The two composables look like this:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
...
}
#Composable
fun MyBottomSheet(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event2) {
handleEvent2()
}
}
}
...
}
Note that I want HomeScreen to handle Event1 and MyBottomSheet to handle Event2 (these events are basically navigation events).
The problem is that when MyBottomSheet is visible, both the composables are collecting the flow at the same time because of which Event2 also gets collected by HomeScreen. What I want is that when MyBottomSheet is the topmost destination in back stack, HomeScreen shouldn't be collecting flow. One of the possible solutions in my mind is:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(isHomeScreenOnTheTopOfBackStack) {
if(isHomeScreenOnTheTopOfBackStack) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
}
...
}
Now here, how can I check if HomeScreen is on top of the back stack or not?
Or is there a better way to approach this problem?
Turned out it was quite easy. We can use navController.currentBackStackEntryAsState() in the nav graph and pass the Boolean to the HomeScreen.
composable("home") {
val isOnTop = navController.currentBackStackEntryAsState().value?.destination?.route == "home"
HomeScreen(
viewModel = //,
isHomeScreenOnTheTopOfBackStack = isOnTop
)
}

Why the view keeps flashing when using jetpack navigation with Compose?

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" />

conditional navigation in compose, without click

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
}

Categories

Resources