I have following code for my main screen:
#Composable
fun MainScreen() {
val viewModel = getViewModel<MainViewModel>()
val tasks: List<Task> by viewModel.taskList.observeAsState(listOf())
LazyColumn(contentPadding = PaddingValues(bottom = 96.dp)) {
items(tasks) { task ->
if (task == tasks[0]) {
ListItemActive(task = task)
} else {
ListItemInactive(task = task)
}
}
}
val isInProgress: Boolean by viewModel.isInProgress.observeAsState(false)
if (isInProgress) {
Preloader()
}
}
Navigation is managed in main activity like this:
NavHost(navController = navController, startDestination = Screen.Main.route) {
composable(Screen.Login.route) { LoginScreen() }
composable(Screen.Main.route) { MainScreen() }
}
The problem is that whenever I adjust paddings or other sizes in android studio my Main screen composition is reevaluated. Which is what one would expect, but instead of taking existing view model, new one is created. And onCleared is not called for the old one. Is this an expected behavior, or am I missing something?
EDIT:
I'm also encountering a crash when any changes are made in code and live editor tries to update them on emulator.
Exception: reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
It happens in code below, when accessing SharedPreferences. This code is called from init block in view model which is initialised a second time during padding change in code.
httpClient.addInterceptor { chain ->
val request: Request = chain
.request()
.newBuilder()
.addHeader("Authorization", "Bearer ${get<PreferenceRepository>().accessToken}")
.build()
chain.proceed(request)
}
Though it does seem to be linked to viewModelScope I'm using. Replacing it with GlobalScope stops app from crashing.
Koin 3.1.2 doesn't work with Navigation Compose. The bug is tracked on GitHub: https://github.com/InsertKoinIO/koin/issues/1079.
As an alternative you can use cokoin library:
#Composable
fun App() {
val navController = rememberNavController()
KoinNavHost(navController, startDestination = "1") {
composable("1") {
val navViewModel = getNavViewModel<NavViewModel>()
//...
}
}
}
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.
whenever I try to update a mutable state flow (uiState), the code in the init of the viewModel executes again (and hence the uiState resets).
This only happens if I use any info related to the previous state of the uiState (at least that's why I think given the following code).
The Code:
init {
Log.d("Error log", "init again")
}
private val _uiState: MutableStateFlow<CreateGameState> =
MutableStateFlow(
CreateGameState.InputDataState(
selectedHeroes = listOf(),
selectedVillain = null,
selectedEncounters = listOf(),
)
)
val uiState: StateFlow<CreateGameState> = _uiState
fun onAction(action: CreateGameActions) {
when (action) {
is CreateGameActions.SelectHero -> {
when (_uiState.value) {
is CreateGameState.InputDataState -> {
_uiState.update {
/* this line causes the error */(it as CreateGameState.InputDataState).copy(selectedHeroes = it.selectedHeroes.plusElement(action.hero))
/* this line doesn't cause error */(it as CreateGameState.InputDataState).copy(selectedHeroes = listOf(Hero.SPIDERMAN))
}
}
}
}
}
}
}
So when only executing the line that causes the error, the Log of the init function is shown in Logcat again.
With this, I loose the uiState and my app gets stuck on the initial state always.
Thanks a lot for your help in advance!
#Tenfour04 was right.
I turns out I was injecting the viewModel in a composable with the constructor as a default parameter. Recomposition was causing the creation of a new ViewModel.
Simply by injecting the same instance of the viewModel solved it.
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.
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))
}
}
When trying to invoke the Firebase Auth UI, using the below code the compiler throws java.lang.IllegalStateException: Launcher has not been initialized. Not sure, why the launcher is not initialized
#Composable
internal fun ProfileUI(profileViewModel: ProfileViewModel) {
val loginLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result != null) {
//do something
}
}
if (profileViewModel.isAnonymousUser) {
loginLauncher.launch(profileViewModel.buildLoginIntent())
} else {
}
}
override fun buildLoginIntent(): Intent {
val authUILayout = AuthMethodPickerLayout.Builder(R.layout.auth_ui)
.setGoogleButtonId(R.id.btn_gmail)
.setEmailButtonId(R.id.btn_email)
.build()
return AuthUI.getInstance().createSignInIntentBuilder()
.setIsSmartLockEnabled(!BuildConfig.DEBUG)
.setAvailableProviders(
listOf(
AuthUI.IdpConfig.EmailBuilder().build(),
AuthUI.IdpConfig.GoogleBuilder().build()
)
)
.enableAnonymousUsersAutoUpgrade()
.setLogo(R.mipmap.ic_launcher)
.setAuthMethodPickerLayout(authUILayout)
.build()
}
java.lang.IllegalStateException: Launcher has not been initialized
at androidx.activity.compose.ActivityResultLauncherHolder.launch(ActivityResultRegistry.kt:153)
at androidx.activity.compose.ManagedActivityResultLauncher.launch(ActivityResultRegistry.kt:142)
at androidx.activity.result.ActivityResultLauncher.launch(ActivityResultLauncher.java:47)
at com.madhu.locationbuddy.profile.ProfileUIKt.ProfileUI(ProfileUI.kt:37)
at com.madhu.locationbuddy.profile.ProfileUIKt.ProfileUI(ProfileUI.kt:15)
Any ideas on how to resolve this issue?
As per the Side-effects in Compose documentation:
Composables should be side-effect free.
Key Term: A side-effect is a change to the state of the app that happens outside the scope of a composable function.
Launching another activity, such as calling launch, is absolutely a side effect and therefore should never be done as part of the composition itself.
Instead, you should put your call to launch within one of the Effect APIs, such as SideEffect (if you want it to run on every composition) or LaunchedEffect (which only runs when the input changes - that would be appropriate if profileViewModel.isAnonymousUser was being driven by a mutableStateOf()).
Therefore your code could be changed to:
internal fun ProfileUI(profileViewModel: ProfileViewModel) {
val loginLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result != null) {
//do something
}
}
if (profileViewModel.isAnonymousUser) {
SideEffect {
loginLauncher.launch(profileViewModel.buildLoginIntent())
}
} else {
// Output your UI, etc.
}
}