How to pass an object through Navigation in Android compose? - android

I am using Navigation Compose, and I am trying to pass an entire object to the next screen, So I am using a shared ViewModel through hilt, and created a mutablestate variable of that object and want to get its value in the next screen.
Like This
var campaign = mutableStateOf<Campaign?>(null)
private set
fun addCampaign(campaign: Campaign){
this.campaign.value = campaign
}
where Campaign is just a data-class.
In my Screen in Navigation
LazyColumn (
content =
{
items(viewModel.campaignListCurrent){ campaign ->
CampaignItem(
image = campaign.brand?.image ?: "",
title = campaign.name ,
id = campaign.id ,
description =campaign.description ,
date =campaign.createdAt ) {
viewModel.addCampaign(
CampaignsViewModel.Campaign(...) // the "campaign" object is used to fill this
)
Timber.tag("CampaignObject").v(viewModel.campaign.value.toString())
viewModel.changed = true
navController.navigate(Screen.CampaignDetailsScreen.route)
}
}
})
I can see when I log the data that it was stored successfully. yet in the next screen when I get the data from the same ViewModel, it's null. even though its a mutablestate and supposed to change its value and be observables, I don't think I get mutablestate behavior at all, and any link for a proper explanation for it will be appreciated.
val campaign by remember { viewModel.campaign }
Timber.v("CampaignDetailsScreen2: " + campaign.toString())
can someone explain to me why it doesn't work here, even if I used Launched effect??
why doesn't change its value here?

I managed to solve this by Creating a SharedViewModel instance in the NavGraph to make sure it is the same Instance used in both composable functions.
#Composable
fun CampaignsScreen(
navController : NavController,
viewModel: CampaignsViewModel
) {
....
#Composable
fun CampaignDetailsScreen(
navController : NavController,
viewModel: CampaignsViewModel
) {
and in my NavGraph I passed the ViewModel as HiltViewmodel in the constructor and from it to the two screens
#Composable
fun SetUpNavGraph (navController : NavHostController,
campaignViewModel: CampaignsViewModel = hiltViewModel()) {
...........
loginNavGraph(navController = navController)
homeNavGraph(navController = navController, campaignsViewModel = campaignViewModel)
}
}
and in the home NavGraph I passed the parametere and its the same Instance in both
fun NavGraphBuilder.homeNavGraph(
navController : NavHostController,
campaignsViewModel: CampaignsViewModel
){
navigation(startDestination = Screen.HomeScreen.route, route = HOME_GRAPH_ROUTE) {
................
composable(Screen.Campaigns.route){
CampaignsScreen(navController = navController, viewModel = campaignsViewModel)
}
composable(Screen.CampaignDetailsScreen.route){
CampaignDetailsScreen(navController, viewModel = campaignsViewModel)
}
}
}
You can use backstackEntry and PreviouseBackStack to solve this but it won't work if you have a JSON Object within your object as you will need to Serialize and Deserialize this object manually and it will take more time and code to achieve the same goal for a shared ViewModel to pass Parclized object. until Google Solve passing Parclized objects in Compose Navigation I recommend backStack Entry if you don't have a JSON object within your paralyzed object and if you do, use a shared ViewModel between the tow composable function.

Why would it work if you are just remembering a local value and not even reading from the proper model?
val campaign = viewModel.campaign
Can't just use remember willy-nilly without understanding the proper usage.

Related

Composable architecture with viewmodels and LiveData/StateFlow

I've created simple application to scan products barcode and retrieve information from API by this code. My whole application is routed by this composable:
#Composable
fun AppNavigationHost(navController: NavHostController, modifier: Modifier = Modifier) {
val sharableViewModel: SharableViewModel = hiltViewModel()
NavHost(navController = navController, startDestination = Main.route, modifier = modifier) {
composable(route = Main.route) {
MainScreen(viewModel = sharableViewModel, onSuccessfulProductScan = {
LaunchedEffect(sharableViewModel.product) {
navController.navigate(ProductDetail.route) {
popUpTo(Main.route)
}
}
})
}
composable(route = ProductDetail.route) {
ProductDetailsScreen(viewModel = sharableViewModel)
}
}
}
It works in a simple way:
From MainScreen i call viewModel.findProduct when button is clicked.
When product not exists, it has state NOT_FOUND and simply returns message, that product does not exist. My state has type mutableStateFlow<ProductState>. When It was LiveData, I couldn't navigate between my composables.
When product exists, I update my product in viewModel of type mutableLiveData<Product>. When I change it to StateFlow, my whole app crushes.
ProductDetailsScreen observe product from viewModel. When it's filled with data, composable updates it's view with product information.
Now, I don't understand few things, like:
I must initialize my SharedViewModel in shareable place like AppNavigationHost. When I add hiltViewModel() as a default parameter in those composables, my app instantly crashes. Why I can't inject viewModel with it's state as a separated parameter in composable?
Why I need to use StateFlow when I have to navigate between composables, why LiveData is not enought to handle it? Is there any possibility to use StateFlow instead of LiveData through navigated views without handling LaunchedEffect scope?
Why I need to use LiveData to persist object in ViewModel between composables? StateFlow is bounded to viewModel, not to composable. It should "emit" my product once and next view should retrieve that event.
I wish I could understand well basics of composable concept, but some of them are not clear enough for me, e.g. passing data in other way than shareable view model.

JetPack Compose + Room + LiveData + ViewModel

In a Jetpack Compose component I'm subscribing to Room LiveData object using observeAsState.
The initial composition goes fine, data is received from ViewModel/LiveData/Room.
val settings by viewModel.settings.observeAsState(initial = AppSettings()) // Works fine the first time
A second composition is initiated, where settings - A non nullable variable is set to null, and the app crashed with an NPE.
DAO:
#Query("select * from settings order by id desc limit 1")
fun getSettings(): LiveData<AppSettings>
Repository:
fun getSettings(): LiveData<AppSettings> {
return dao.getSettings()
}
ViewModel:
#HiltViewModel
class SomeViewModel #Inject constructor(
private val repository: AppRepository
) : ViewModel() {
val settings = repository.getSettings()
}
Compose:
#OptIn(ExperimentalFoundationApi::class)
#Composable
fun ItemsListScreen(viewModel: AppViewModel = hiltViewModel()) {
val settings by viewModel.settings.observeAsState(initial = AppSettings())
Edit:
Just to clearify, the DB data does not change. the first time settings is fetched within the composable, a valid instance is returned.
Then the component goes into recomposition, when ItemsListScreen is invoked for the second time, then settings is null (the variable in ItemsListScreen).
Once the LiveData<Appsettings> is subscribed to will have a default value of null. So you get the default value required by a State<T> object, when you call LiveData<T>::observeAsState, followed by the default LiveData<T> value, this being null
LiveData<T> is a Java class that allows nullable objects. If your room database doesn't have AppSettings it will set it a null object on the LiveData<AppSettings> instance. As Room is also a Java library and not aware of kotlin language semantics.
Simply put this is an interop issue.
You should use LiveData<AppSettings?> in kotlin code and handle null objects, or use some sort of MediatorLiveData<T> that can filter null values for example some extensions functions like :
#Composable
fun <T> LiveData<T?>.observeAsNonNullState(initial : T & Any, default : T & Any) : State<T> =
MediatorLiveData<T>().apply {
addSource(this) { t -> value = t ?: default }
}.observeAsState(initial = initial)
#Composable
fun <T> LiveData<T?>.observeAsNonNullState(initial : T & Any) : State<T> =
MediatorLiveData<T>().apply {
addSource(this) { t -> t?.run { value = this } }
}.observeAsState(initial = initial)
If you only need to fetch settings when viewModel is initialised, you can try putting it in an init block inside your ViewModel.

Jetpack Compose Navigation - pass argument to startDestination

The app I'm building uses compose navigation with routes. The challenge is that the start destination is dynamic.
Here is a minimal example:
class MainActivity : ComponentActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "dynamic/1", // doesn't work
// startDestination = "static", // workaround
) {
composable(
route = "dynamic/{$ARG_ID}",
arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType }),
) {
val id = it.arguments?.getString(ARG_ID)
Text("dynamic route, received argument: $id!")
}
// part of the workaround
// composable(
// route = "static",
// ) {
// LaunchedEffect(this) {
// navController.navigate("dynamic/1")
// }
// }
}
}
}
companion object
{
const val ARG_ID = "id"
}
}
The app crashes with
java.lang.IllegalArgumentException: navigation destination route/1 is not a direct child of this NavGraph
The problem only exists if the "dynamic" route is used as start destination. This can be verified by using startDestination = "static".
Although, the "static" route workaround works I'm looking for a solution without it because it kind of obfuscates the code and also creates an additional entry in the back stack.
-> Full code sample to reproduce the issue
Related SO questions
Navigation Architecture Component- Passing argument data to the startDestination - Answers don't seem to be applicable to Compose Navigation.
Pass an argument to a nested navigation graph in Jetpack Compose - No answer given.
Compose Navigation - navigation destination ... is not a direct child of this NavGraph - The accepted answer doesn't resolve the issue.
Edit:
I want to stress that the original sample used to not contain the "static" composable. I only added the "static" composable to have a working startDestination and to prove that the "dynamic" composable can be navigated to.
Update:
Even switching to the query parameter syntax for optional arguments, providing a default value, and setting the start destination without any argument does not work.
The following variation
NavHost(
navController = navController,
startDestination = "dynamic",
) {
composable(
route = "dynamic?$ARG_ID={$ARG_ID}",
arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType; defaultValue = "1" }),
) {
val id = it.arguments?.getString(ARG_ID)
Text("dynamic route, received argument: $id!")
}
}
Leads to the exception
java.lang.IllegalArgumentException: navigation destination dynamic is not a direct child of this NavGraph
Full credit goes to ianhanniballake, who explained the solution to me in a comment. I'm going to show the working version of my code sample here.
The big insight to me was:
startDestination must not match a composable route in the sense of pattern matching but it must be exactly the same string.
That means an argument can't be set via startDestination directly but has to be set via the argument's defaultValue.
Here is the working sample:
class MainActivity : ComponentActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
NavHost(
navController = navController,
// 1st change: Set startDestination to the exact string of route
startDestination = "dynamic/{$ARG_ID}", // NOT "dynamic/1", provide arguments via defaultValue
) {
composable(
route = "dynamic/{$ARG_ID}",
// 2nd change: Set startDestination argument via defaultValue
arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType; defaultValue = "1" }),
) {
val id = it.arguments?.getString(ARG_ID)
Text("dynamic route, received argument: $id!")
}
}
}
}
companion object
{
const val ARG_ID = "id"
}
}
The approach equally works with the argument provided in the form of a query parameter.
To be honest, I see this as a small limitation because the start route now dictates what has to be the defaultValue. I might want to set a different defaultValue or none at all. Yet, in most cases this should be the most elegant solution.
should not be using dynamic route value in "startDestination" NavHost
--> navController.navigate(<dynamic route ‌>)
All credit goes to ianhanniballake and Peter. In my case I didn't add any additional (mandatory key/optional key) in route for the argument data. I kept the route clean like below:
Nav graph:
navigation(route = Route.Root.route, startDestination = Route.SubmitForm.route) {
composable(
route = Route.SubmitForm.route,
arguments = listOf(
navArgument(ARG_KEY) {
type = NavType.StringType
defaultValue = JsonConverter.toJson(user, User::class.java)
},
)
)
}
Route sealed class:
sealed class Route(val route: String) {
object MyRoute : Route("$ROOT/submit-form")
}
And in view model just get the data like this:
#HiltViewModel
class MyViewModel #Inject constructor(
stateHandle: SavedStateHandle,
) : ViewModel {
lateinit var user
init {
user = stateHandle.get<String>(ARG_NAME) // Supported data types
}
}
It worked for me.

Pass an argument to a nested navigation graph in Jetpack Compose

From the docs, I see you can nest navigation graphs like so:
NavHost(navController, startDestination = "home") {
...
// Navigating to the graph via its route ('login') automatically
// navigates to the graph's start destination - 'username'
// therefore encapsulating the graph's internal routing logic
navigation(startDestination = "username", route = "login") {
composable("username") { ... }
composable("password") { ... }
composable("registration") { ... }
}
...
}
I am wondering, how would one pass an argument in the route, and make that available to all composables inside the nav graph?
Here's my current nav graph:
navigation(
// I'd like to grab this parameter
route = "dashboard?classId={classId}",
startDestination = Route.ScreenOne.route) {
composable(Route.ScreenOne.route) {
// And then pass the parameter here, or to any composable below
ScreenOne(classId)
}
composable(Route.ScreenTwo.route) {
ScreenTwo()
}
composable(Route.ScreenThree.route) {
ScreenThree()
}
}
I am basically trying to avoid setting the classId navigation argument individually on each composable route. I didn't see a way to pass a list of arguments to navigation() like you can in a composable().
It might be that what I am describing isn't possible, but looking forward to anyone's thoughts!
You can access the graph arguments from child composables:
navController.getBackStackEntry("dashboard?classId={classId}").arguments?.getString("classId")
From a quick test within a Hilt-based project, it looks like passing a property in as an argument to a navigation graph component results in the property being available in the savedStateHandle for any ViewModels that are made whilst that graph is in memory.
For example:
// 1. Define your routes.
sealed class Destination(val route: String) {
object TestGraph : Destination("TEST_GRAPH/{${Arguments.testParameter}}") {
object Arguments {
const val testParameter = "testParameter"
}
fun route(testParameter: String): String {
return "TEST_GRAPH/$testParameter"
}
object FirstScreen : Destination("FIRST_SCREEN")
}
}
// 2. Create a graph extension on NavGraphBuilder for the navigation graph.
private fun NavGraphBuilder.testGraph(navController: NavHostController) {
navigation(
startDestination = Destination.TestGraph.FirstScreen.route,
route = Destination.TestGraph.route,
arguments = listOf(
navArgument(Destination.TestGraph.Arguments.testParameter) { type = NavType.StringType }
)
) {
composable(route = Destination.TestGraph.FirstScreen.route) {
FirstScreen()
}
}
}
// 3. Use the line below to navigate to this new graph.
navController.navigateTo(Destination.TestGraph.route("xyz"))
// 4. Access the savedStateHandle via the VM to get the parameter.
#Composable
fun FirstScreen(
viewModel: FirstScreenViewModel = hiltViewModel(),
) {
//...
}
#HiltViewModel
class FirstScreenViewModel #Inject constructor(
savedStateHandle: SavedStateHandle,
): ViewModel() {
private val testParameter: String = checkNotNull(savedStateHandle[Destination.TestGraph.Arguments.testParameter])
//...
}
I assume this works as the argument is created and maintained at the navigation graph-level. So, providing the graph is in memory, the property is accessible via savedStateHandle. If you were to pop this graph off of the navigation stack, I would expect the value to not be accessible anymore. Hope that helps!

Jetpack compose NavHost prevent recomposition screens

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

Categories

Resources