There are two screens in the app. Screen A and Screen B. The UI and navigation logic of Screen A is based on the state class.
ScreenAState
data class ScreenAState(
val sourceName: String = "",
val navigateToScreenB: Boolean = false
)
If the user meets the requirements, the value of navigateToScreenB is changed to true and the user is navigated to Screen B using the following code.
if (uiState.navigateToScreenB) {
LaunchedEffect(uiState.navigateToScreenB) {
findNavController().navigate(actionToScreenB)
}
}
Now, the problem occurs when the user presses the back button on Screen B. As soon as the user comes back from Screen B to Screen A, the user is again navigated to Screen B and the loop continues if the back button is pressed again on Screen B.
I am not sure if I am using the LaunchedEffect properly. Any help will be appreciated. Thank You.
You should set navigateToScreenB to false after perform the navigation.
Declaring something like this in your view model.
class YourViewModel: ViewModel() {
private val _uiState = MutableStateFlow(ScreenAState())
val uiState = _uiState.asStateFlow()
fun onNavigateToScreenB() {
uiState.update {
it.copy(navigateToScreenB = false)
}
}
...
}
and in your screen:
val uiState by yourViewModel.uiState.collectAsState()
if (uiState.navigateToScreenB) {
LaunchedEffect(uiState.navigateToScreenB) {
viewModel.onNavigateToScreenB()
findNavController().navigate(actionToScreenB)
}
}
Related
I'm trying to implement some kind of LaunchedEffectOnce as I want to track a ContentViewed event. So my requirement is that every time the user sees the content provided by the composable, an event should get tracked.
Here is some example code of my problem:
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
val items by viewModel.itemsToDisplay.collectAsState(initial = emptyList())
ItemList(items)
// when the UI is displayed, the VM should track an event (only once)
LaunchedEffectOnce { viewModel.trackContentViewed() }
}
#Composable
private fun LaunchedEffectOnce(doOnce: () -> Unit) {
var wasExecuted by rememberSaveable { mutableStateOf(false) }
if (!wasExecuted) {
LaunchedEffect(key1 = rememberUpdatedState(newValue = executed)) {
doOnce()
wasExecuted = true
}
}
}
This code is doing do the following:
Tracks event when MyScreen is composed
Does NOT track when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
But what I wan't to achieve is the following:
Tracks event when MyScreen is composed
Tracks when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
My ViewModel looks like that:
class MyViewModel() : ViewModel() {
val itemsToDisplay: Flow<List<Item>> = GetItemsUseCase()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val contentTracking: Flow<Tracking?> = GetTrackingUseCase()
.distinctUntilChanged { old, new -> old === new }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun trackContentViewed(){
// track last element in contentTracking
}
}
I really hope someone can help me and can explain what I'm doing wrong here. Thanks in advance!
Assuming the following are true
your view model is scoped to the Fragment in which MyScreen enters composition
your composables leave the composition when you navigate to an item screen and re-enter composition when you navigate back
then you can simply track inside the view model itself whether specific content was already viewed in this view model's scope. Then when you navigate to any of the items screens you reset that "tracking state".
If you need to track only a single element of content then just a Boolean variable would be enough, but in case you need to track more than one element, you can use either a HashSet or a mutableSetOf (which returns a LinkedHashSet instead). Then when you navigate to any of the item screen you reset that variable or clear the Set.
Your VM code would then change to
class MyViewModel() : ViewModel() {
// ... you existing code remains unchanged
private var viewedContent = mutableSetOf<Any>()
fun trackContentViewed(key: Any){
if (viewedContent.add(key)) {
// track last element in contentTracking
Log.d("Example", "Key $key tracked for 'first time'")
} else {
// content already viewed for this key
Log.d("Example", "Key $key already tracked before")
}
}
fun clearTrackedContent() {
viewedContent.clear()
}
}
and the MyScreen composable would change to
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
// ... you existing code remains unchanged
// Every time this UI enters the composition (but not on recomposition)
// the VM will be notified
LaunchedEffect(Unit) {
viewModel.trackContentViewed(key = "MyScreen") // or some other key
}
}
Where you start the navigation to an item screen (probably in some onClick handler on items) you would call viewmodel.clearTrackedContent().
Since (1) is true when ViewModels are requested inside a Fragment/Activity and if (2) is also true in your case, then the VM instance will survive configuration changes (orientation change, language change...) and the Set will take care of tracking.
If (2) is not true in your case, then you have two options:
if at least recomposition happens when navigating back, replace LaunchedEffect with SideEffect { viewModel.trackContentViewed(key = "MyScreen") }
if your composables are not even recomposed then you will have to call viewModel.trackContentViewed also when navigating back.
I will take a simple sample.
I have 2 Screens: Screen A and Screen B. From Screen A, I open Screen B. And when I return Screen B to Screen A, I want to transfer data back to Screen A.
With Android Fragment, I can use Shared ViewModel or Fragment Result API to do this.
But with Android Compose, the Fragment Result Api is not in Compose. With using Shard ViewModel, what lifecycle do I have to attach Shared ViewModel so it can keep alive? Activity, ... or something else.
Or is there another way to do this?
If you use jetpack navigation, you can pass back data by adding it to the previous back stack entry's savedStateHandle. (Documentation)
Screen B passes data back:
composable("B") {
ComposableB(
popBackStack = { data ->
// Pass data back to A
navController.previousBackStackEntry
?.savedStateHandle
?.set("key", data)
navController.popBackStack()
}
)
}
Screen A Receives data:
composable("A") { backStackEntry ->
// get data passed back from B
val data: T by backStackEntry
.savedStateHandle
.getLiveData<T>("key")
.observeAsState()
ComposableA(
data = data,
navToB = {
// optional: clear data so LiveData emits
// even if same value is passed again
backStackEntry.savedStateHandle.remove("key")
// navigate ...
}
)
}
Replace "key" with a unique string, T with the type of your data and data with your data.
All of your compose composition operations happens within a single activity view hierarchy thus your ViewModel lifecycle will inevitably be bound to that root activity. It can actually be accessed from your composition through LocalLifecycleOwner.current.
Keep in mind that Compose is a totally different paradigm than activity/fragment, you can indeed share ViewModel across composables but for the sake of keeping those simple you can also just "share" data simply by passing states using mutable values and triggering recomposition.
class MySharedViewModel(...) : ViewModel() {
var sharedState by mutableStateOf<Boolean>(...)
}
#Composable
fun MySharedViewModel(viewModel: MySharedViewModel = viewModel()) {
// guessing you already have your own screen display logic
// This also works with compose-navigator
ComposableA(stateResult = viewModel.sharedState)
ComposableB(onUpdate = { viewModel.sharedState = false })
}
fun ComposableA(stateResult: Boolean) {
....
}
fun ComposableB(onUpdate: () -> Unit) {
Button(onClick = { onUpdate() }) {
Text("Update ComposableA result")
}
}
Here you'll find further documentation on managing states with compose
Let's say there are two screens.
1 - FirstScreen it will receive some data and residing on bottom in back stack user will land here from Second screen by press back button.
2 - SecondScreen it will send/attach some data to be received on previous first screen.
Lets start from second screen sending data, for that you can do something like this:
navController.previousBackStackEntry
?.savedStateHandle
?.set("key", viewModel.getFilterSelection().toString())
navController.popBackStack()
Now lets catch that data on first screen for that you can do some thing like this:
if (navController.currentBackStackEntry!!.savedStateHandle.contains("key")) {
val keyData =
navController.currentBackStackEntry!!.savedStateHandle.get<String>(
"key"
) ?: ""
}
Worked perfectly for me.
navigation compose version 2.4.0-alpha06
I have a Navigation Drawer using Scaffold and part of the items are dynamically generated by ViewModel.
Example items are
Home
A
B
C
...
Settings
where A, B, C, ... all share same Screen called Category, with just different arguments passed (e.g. Category/A, Category/B).
Inside my Scaffold
...
val items = viewModel.getDrawerItems()
// This gives something like
// ["Home", "Category/A", "Category/B", "Category/C", ..., "Settings"]
// where each String represents "route"
...
val backstackEntry = navController.currentBackStackEntryAsState()
val currentScreen = Screen.fromRoute(
backstackEntry.value?.destination?.route
)
Log.d("Drawer", "currentScreen: $currentScreen")
items.forEach { item ->
DrawerItem(
item = item,
isSelected = currentScreen.name == item.route,
onItemClick = {
Log.d("Drawer", "destinationRoute: ${item.route}")
navController.navigate(item.route)
scope.launch {
scaffoldState.drawerState.close()
}
}
)
}
This code works pretty well, except when I visit Home screen, I want to clear all backstack upto Home not inclusive.
I've tried adding NavOptionsBuilder
...
navController.navigate(item.route) {
popUpTo(currentScreen.name) {
inclusive = true
saveState = true
}
}
...
However, this doesn't work because currentScreen.name will give something like Category/{title} and popUpTo only tries to look up exact match from the backstack, so it doesn't pop anything.
Is there real compose-navigation way to solve this? or should I save the last "title" somewhere in ViewModel and use it?
This tutorial from Google has similar structure, but it just stacks screens so going back from screen A -> B -> A and clicking back will just go back to B -> A, which is not ideal behavior for me.
Thank you in advance.
You can make an extension function to serve the popUpTo functionality at all places.
fun NavHostController.navigateWithPopUp(
toRoute: String, // route name where you want to navigate
fromRoute: String // route you want from popUpTo.
) {
this.navigate(toRoute) {
popUpTo(fromRoute) {
inclusive = true // It can be changed to false if you
// want to keep your fromRoute exclusive
}
}
}
Usage
navController.navigateWithPopUp(Screen.Home.name, Screen.Login.name)
When you're specifying popUpTo you should pass same item you're navigating to in this case:
navController.navigate(item.route) {
popUpTo(item.route) {
inclusive = true
}
}
Also not sure if you need to specify saveState in this case, it's up to you:
Whether the back stack and the state of all destinations between the current destination and the NavOptionsBuilder.popUpTo ID should be saved for later restoration via NavOptionsBuilder.restoreState or the restoreState attribute using the same NavOptionsBuilder.popUpTo ID (note: this matching ID is true whether inclusive is true or false).
Inspired by #Philip Dukhov's answer, I was able to achieve what I wanted.
...
navController.navigate(item.route) {
// keep backstack until user goes to Home
if (item.route == Screen.Home.name) {
popUpTo(item.route) {
inclusive = true
saveState = true
}
} else {
// only restoreState for non-home screens
restoreState = true
}
...
Unfortunately, if I add launchSingleTop = true, Screen with different argument is not recomposed for some reason, but that's probably another topic.
I have the following flow: When the app starts, a screen with popular items is displayed. User logs in, on successful login the backstack is popped and the user returns to the screen with the popular items. But when logged in, the items that the user liked/looked at last should also be displayed.
In non-Compose, I'd just retrigger the function in the viewModel that gets all items. But in Compose, I'd end up in an endless loop if I tried to call the function from the Composable.
My question is - how can I reload the items after the user returns from a successful login to the start screen? And what is best practice in such a case?
ViewModel
private val _itemsFlow = MutableStateFlow(emptyList())
val itemsFlow: StateFlow<List<Item>> = _itemsFlow
init {
getItems()
}
private fun getItems() {
viewModelScope.launch {
itemRepository.getItems().collect { items ->
_itemsFlow.value = items
}
}
}
Composable
#Composable
fun Home(viewModel: HomeViewModel = hiltViewModel()) {
val items by viewModel.itemsFlow.collectAsState()
...
// used later in a LazyRow
}
Check out side-effects
In your case something like this will help:
LaunchedEffect(Unit) {
viewModel.getItems()
}
as you see this is how i implemented NavHost with MaterialBottomNavigation, i have many items on both Messages and Feeds screens, when i navigate between them both screens, they automatically recomposed but i don't wanna because of much data there it flickring and fps drops to under 10 when navigating, i tried to initialize data viewModels before NavHost but still same result, is there any way to compose screens once and update them just when viewModels data updated?
#Composable
private fun MainScreenNavigationConfigurations(
navController: NavHostController,
messagesViewModel: MessagesViewModel = viewModel(),
feedsViewModel: FeedsViewModel = viewModel(),
) {
val messages: List<Message> by messagesViewModel.messages.observeAsState(listOf())
val feeds: List<Feed> by feedsViewModel.messages.observeAsState(listOf())
NavHost(
navController = navController,
startDestination = "Messages"
) {
composable("Messages") {
Messages(navController, messages)
}
composable("Feeds") { Feeds(navController, feeds) }
}
}
I had a similar problem. In my case I needed to instantiate a boolean state "hasAlreadyNavigated".
The problem was:
-> Screen 1 should navigate to Screen 2;
-> Screen 1 has a conditional statement for navigating directly to screen 2 or show a content screen with an action button that navigates to Screen 2;
-> After it navigates to Screen 2, Screen 1 recomposes and it reaches the if statement again, causing a "navigation loop".
val hasAlreadyNavigated = remember { mutableStateOf(false) }
if (!hasAlreadyNavigated.value) {
if (!screen1ViewModel.canNavigate()) {
Screen1Content{
hasAlreadyNavigated.value = true
screen1ViewModel.allowNavigation()
navigateToScreen2()
}
} else {
hasAlreadyNavigated.value = true
navigateToScreen2()
}
}
With this solution, i could prevent recomposition and the "re-navigation".
I don't know if we need to be aware and build composables thinking of this recomposition after navigation or it should be library's responsibility.
Please use this code above your code. It will remember state of your current screen.
val navController = rememberNavController()
for more info check this out:
https://developer.android.com/jetpack/compose/navigation
Passing the navcontroller as a parameter causes recomposition. Use it as a lambda instead.
composable("Messages") {
Messages( onClick = {navController.navigate(route = "Click1")},
onClick2 = {navController.navigate(route = "Click2")},
messages)
}