So I did this and I hate it. How do you properly r...
# compose
m
So I did this and I hate it. How do you properly return results from one nav destination to another when popping back?
Copy code
/**
 * Observes the callback result from previous NavController backStackEntry and makes sure,
 * that it's cleared so the LiveData doesn't trigger again (by default it triggers with the old value
 * for every resubscription)
 *
 * Ref: <https://developer.android.com/guide/navigation/navigation-programmatic#returning_a_result>
 */
fun <T> NavController.navigationCallbackFlow(argKey: String) = this
    .currentBackStackEntry!!
    .savedStateHandle
    .getLiveData<T>(argKey)
    .asFlow()
    .map { this.currentBackStackEntry!!.savedStateHandle.get<T>(argKey) }
    .onEach { this.currentBackStackEntry!!.savedStateHandle.remove<T>(argKey) }
    .filterNotNull()
t
I would use nested nav and put all the shared data in a shared view model
👍 1
m
this is a different approach and probably an interesting one, but I wonder how to solve this particular problem (this way of providing callbacks seems to be encouraged by docs). going back to your solution - you scope that viewmodel to the nested graph and inject it into those composables?
t
Yes
Something like this
Copy code
navigation(startDestination = "nestedB", route = "b") {
                        composable(route = "nestedB") {
                            val backStackEntry =
                                remember { navController.getBackStackEntry("b") }
                            ComposableB(
                                navController = navController,
                                hiltViewModel(backStackEntry)
                            )
                        }
                        composable(route = "nestedC") {
                            val backStackEntry =
                                remember { navController.getBackStackEntry("b") }
                            ComposableC(
                                navController = navController,
                                hiltViewModel(backStackEntry)
                            )
                        }
                    }
🙏 1
m
but it's a bit problematic when it comes to passing arguments... I mean if I wanted to attach a subgraph for e.g. some ProductDetails with some productId argument, then I would have to basically copy the same root pattern for
startDestination
and
route
but with a different path e.g.
product/{productId}
and
productGraph/{productId}
, right?
i
Can you explain what concrete use case you're trying to implement by returning a result? It seems you are mixing approaches for returning an event (something that needs to be processed once in an Effect before being removed) and state (where you'd observeAsState). How you are supposed to handle those is different, so knowing what you're actually trying to do is important
m
Thank you for your interest @Ian Lake The most simple use cas for this is: • ComposableA launches ComposableB for result • ComposableB does its thing, then sets the result (i.e.
previousBackStackEntry.savedStateHandle.set(...)
) and invoked popBackStack. • ComposableA wants to show some snackbar to show that there's been success It works fine, but then if you navigate from ComposableA to anything else and go back, the livedata will return again showing that snackbar. Of course you can navigate to ComposableB again and perform this action again. Or just pop the back stack by navigating back. And the snackbar should appear or not appear accordingly.
i
The Snackbar case is covered in the LaunchedEffect docs as an example - your state (in this case, your result) is what drives the Snackbar from being displayed. Only after it is displayed, would you do the side effect of calling `remove`:
Copy code
// Never use navController.currentBackStackEntry inside of your
// composable screen as that value changes immediately when you
// call navigate, despite your screen still being recomposed during
// the exit animations. Always use the NavBackStackEntry passed to you.
val savedStateHandle = it.savedStateHandle
val deletedItemId = savedStateHandle.getLiveData<String>(RESULT_KEY)
  .observeAsState(null)
if (deletedItemId != null) {
  // I assume you already have a snackbarHostState declared in
  // your composable or as part of a ScaffoldState: use that here
  LaunchedEffect(snackbarHostState, deletedItemId) {
    try {
      val result = snackbarHostState.showSnackbar(
        message = "Item deleted"
        actionLabel = "Undo"
      )
      if (result == SnackbarResult.ActionPerformed) {
        // Trigger your undo
      }
    } finally {
      // Use a finally block to *always* run this code, even if your
      // snackbar hasn't been removed before the user navigated to a
      // new screen: this is what ensures the Snackbar only is shown once
      savedStateHandle.remove(RESULT_KEY)
    }
  }
}
You never want to call
SaveStateHandle.remove
as part of composition (that's a side-effect and composition should always be side effect free)
m
That's an important note, thank you,however I don't think it addresses the problem. Even if I
SavedStateHandle.remove
later, the LiveData will still hold on to the last value it emitted. And will redeliver it when subscribed again. I guess that
val deletedItemId = savedStateHandle.getLiveData<String>(RESULT_KEY).observeAsState(null)
will prevent it from being delivered again, because it's conflated, but that doesn't completely solve the problem. Let me explain. Following your
deletedItemId
example, after I delete item
123
in the ComposableB started for result (e.g. dialog) I want both these scenarios to work fine: 1. I open ComposableB again, and return from it with back button - the snackbar should not appear. 2. I open ComposableB again and delete item
123
again (e.g. because the previous deleting was undone) - the snackbar should appear again. In the piece of code above, the second scenario won't work, right?
t
but it’s a bit problematic when it comes to passing arguments... I mean if I wanted to attach a subgraph for e.g. some ProductDetails with some productId argument, then I would have to basically copy the same root pattern for 
startDestination
 and 
route
 but with a different path e.g. 
product/{productId}
 and 
productGraph/{productId}
, right?
Yeah. For me it should be like this
Copy code
product/{productId}
product/{productId}/subGraph1
product/{productId}/subGraph2
i
...did you even try my code? It handles both those scenarios perfectly -
remove
clears the LiveData
m
didn't have opportunity yet, so sorry. will get back to you as soon as I try it out and before I do, I shut my mouth 😉
@Ian Lake okay I get where I had the issue. The clue is
Copy code
// Never use navController.currentBackStackEntry inside of your
// composable screen as that value changes immediately when you
// call navigate, despite your screen still being recomposed during
// the exit animations. Always use the NavBackStackEntry passed to you.
That means that we can't use the savedStateHandle passed to the viewmodel and handle this logic there - we need to grab it directly inside the composable and there's no way around it, right? In the current project I have koin, but the same will be true for hilt I guess.
d
@Ian Lake Is there another approach if the launched destination for the result is a bottom sheet destination? When navigating to a bottom sheet destination, the current destination will not be recomposed, so if we remove the key from the StateHandle, the livedata will be detached and not receive further updates.
i
The
SavedStateHandle
associated with an individual VIewModel (remember: you can create many, many ViewModels for the same destinatinon and each has its own
SavedStateHandle
) is not the same instance as `navBackStackEntry.savedStateHandle`; they aren't interchangeable. If you're using
navBackStackEntry.savedStateHandle
, then your 'current' NavBackStackEntry is the one passed to your
composable
destination
😥 1
d
Copy code
composable("routeA") { navBackStackEntry ->
    val savedStateHandle = navBackStackEntry.savedStateHandle
    val deletedItemId = savedStateHandle.getLiveData<String>(RESULT_KEY)
        .observeAsState(null)
    LaunchedEffect(snackbarHostState, deletedItemId) {
        try {
          // do something
        } finally {
          savedStateHandle.remove(RESULT_KEY)
        }
    }
}

bottomSheet("routeB") {
    // in some click handler
    navController.previousBackStackEntry?.savedStateHandle?.set(RESULT_KEY, "deleted_id")
}
I’m using
navBackStackEntry.savedStateHandle
like this, but i noticed in routeA, the livedata will only receive the first result. if i navigate to routeB bottom sheet again to set the result, the livedata in routeA composable will not receive further updates since
savedStateHandle.remove(RESULT_KEY)
will detach the livedata.
i
In the Compose world (where you are using the result as state and using
observeAsState()
),
savedStateHandle.getLiveData<String>(KEY).value = null
will do the same thing as
remove
, but without the detach of the LiveData
👍 1
d
just a follow up, what if null is a valid value? say instead of deletion, it is a selection (which can be null for deselecting)
i
Then
observeAsState(null)
would need to be fixed, wouldn't it 🙂 You can use whatever sentinel value you want instead of null
👍 1
m
Okay so: • The savedStateHandle put into the viewmodel via e.g. Hilt will be there for the lifetime of viewmodel (obviously) • But every time there is navigation to the composable, the savedStateHandle passed through the navigation lambda will be recreated But they both access the same arguments (the first one is not "stale"). When I used the savedStateHandle from viewmodel it worked fine, the livedata returned the value. The only problem was that once I resubscribed to it, it returned the old value even though I already removed it. If I use the fresh savedStateHandle when popping back to that composable as you suggest, all that changes is that it subscribes to the fresh instance of livedata which causes the desired behavior. The arguments in both savedStateHandles (fresh and the one accessed via viewmodel) have the right values, it's just that one livedata redelivers old value despite me removing it from args and the other doesn't, because it has nothing to redeliver at the point where we access it. If I'm right then I would argue it's an issue with args livedata which should clear the value and deliver null when we remove from args.
i
the savedStateHandle passed through the navigation lambda will be recreated
No, you always get the same
savedStateHandle
back for the same
NavBackStackEntry
m
Okay, but the backstackentry when I return to composable is different than the one that was originally pqssed to viewmodel so the savedStateHandle too, right? In my case I wasn't using hilt viewmodels but rather accessing currentBackStackEntry from mavController but the same principle applies I think
i
It is
getLiveData()
that returns a brand new
LiveData
instance after you
remove
the previous instance - holding onto that previous instance after a
remove
call would be the same type of issue that Desmond pointed out
m
Oh, that makes sense
That's some great insights Ian, thank you so much :)
i
The problem with using
currentBackStackEntry
is when it comes to animations - when you navigate from A to B, you don't go from one composition of just A to one composition of just B, but there's a time during the animation that both A and B are recomposing. However, during that time,
currentBackStackEntry
is always the final destination (B) even when you are accessing
navController.currentBackStackEntry
from the recomposing screen A
✔️ 1
Hence, if you read
navController.currentBackStackEntry
in screen A, you won't actually get the screen A
NavBackStackEntry
- that's why you are specifically passed A's
NavBackStackEntry
- that's what you need to be using anytime you are in screen A
✔️ 1
m
But if I understand right, it can be the original A NavBackStackEntry (so e.g. the savedstatehandle passed via hiltviewmodel and living in the viewmodel) not necessarily the one passed immediately as part of navigation every time navigate is called (the one that I called fresh earlier). This will still be the right one. Just need to be sure to avoid currentBackStackEntry because of the reason you explained. I just need to make sure to refresh the instance of livedata whenever I navigate back (not sure if it makes sense to do this yet but looking for ways to handle that on the viewmodel level rather than handling directly in composable)
i
Every ViewModel instance gets its very own
SavedStateHandle
- if you look at
navBackStackEntry.savedStateHandle
, all it does is create its own ViewModel and access its
SavedStateHandle
: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:navigati[…]/src/main/java/androidx/navigation/NavBackStackEntry.kt;l=118
m
I did something like this, wonder what you think https://gist.github.com/micHar/716d810f319973beb86ebbb1a5257be8
I know you've been on vacation Ian and a lot of time passed since I asked, but if you had a moment to take a look, I'd appreciate it :) I'm using this without any issues but I may be missing some edge cases
286 Views