Learning navigation in compose I feel like documen...
# compose
n
Learning navigation in compose I feel like documentation (and example projects as well) are too basic. While documentation and NowInAndroid covers how to: • Do navigation in single “HomeScreen” with BottomNavigation + subscreens for each BottomNavigation item. • Split navigation to a features and make it somewhat typesafe It does not give any guidance how to do quite common things like: • How to trigger navigation after some async operation in ViewModel. • How navigation should be triggered from ViewModel: ◦ Using Some Router class in the ViewModel ◦ Navigation event exposed as part of UiState or as one-shot event (effect) ◦ Some other way • Implementation of less basic but still very popular navigation flow: AuthenticationScreen -> HomeScreen (with BottomNavigation and subscreens) -> Some kind of DetailsScreen opened on top of HomeScreen. I’m looking for example projects and articles that already figured it out in a nice way. I appreciate any help.
👍 5
👍🏾 1
s
We’ve had good luck with treating navigation events just as you would with any other kind of event. Meaning you bake that into the UIState, and have the UI react to that state accordingly and report that it’s handled back to the ViewModel. No way to lose an event this way or act on an event twice and other problems like that.
🙏 1
e
just as you would with any other kind of event. Meaning you bake that into the UIState…
My only gripe with this is that events are not state 😅 , so i have a hard time, personally, adding say a boolean “navigateToDetail = true” in my ui model/state. We have also treated nav as an event but slightly different from @Stylianos Gakis. We send the events to a navigator that uses a buffered channel and relays it to the nav controller. Implementation in a nutshell:
Copy code
class MainNavigator : Navigator {
  internal val destinations = Channel<Destination>()
  override fun navigate(destination: Destination) {
    destinations.trySend(destination)
  }
}

@Composable
internal fun CollectFromNavigator(
  navController: NavController,
  navigator: MainNavigator,
  deepLinkLauncher: DeepLinkLauncher,
) {
  LaunchedEffect(navigator) {
    navigator.destinations
      .receiveAsFlow()
      .collect { navController.handle(it, deepLinkLauncher) }
  }
}
f
how would you handle with this navigator the app being backgrounded while the async operation is in process, and completes while the app is backgrounded? Using navigation events as part of the state seems a better approach to me
e
How would it be different when its part of the state? If its necessary to not lose any events use a channel that buffers nav events (just like above)
f
as part of the state you wouldn't try to navigate while in background because the UI is not collecting the state
e
You’re also not trying to navigate here also, you just send a navigation signal (which is the exact same thing as updating your state with some navigation flag) pending when the collector/ui/whatever is ready to start processing again.
s
We’ve had good luck with treating navigation events just as you would with any other kind of event. Meaning you bake that into the UIState, and have the UI react to that state accordingly and report that it’s handled back to the ViewModel. No way to lose an event this way or act on an event twice and other problems like that.
We also do it like that. Our UiState is published as a
StateFlow
and may contain a field
navigation
for example which can be an enum or a sealed interface if you need to pass additional data for the navigation “event”. Inside the VM we have a
MutableStateFlow<Navigation>
which we combine into the UiState with Flow operators. So for example inside suspend functions we can set a value on the MutableState which is reflected in the UiState. Then in Compose we do
Copy code
LaunchedEffect(uiState.navigation) {
  when (uiState.navigation) {
    Navigation.Link1 -> onNavigateToLink1()
    ...
  }
  viewModel.confirmNavigation()
}
where
viewModel.confirmNavigation()
will set the MutableState to a default value (
None
) or
null
to not handle this event again.
f
You’re also not trying to navigate here also, you just send a navigation signal (which is the exact same thing as updating your state with some navigation flag) pending when the collector/ui/whatever is ready to start processing again.
but your navigator holds a reference to the NavController, which implies it triggers navigation. If you want to do this, then it is your responsibility to monitor the state of the app and delay the navigation until the app comes back to background, foreground which you get for free by using events as part of the state. You can certainly do as you suggest, but, in my opinion, it is a more convoluted and fragile solution than simply passing the events in the state
🚫 1
s
then it is your responsibility to monitor the state of the app and delay the navigation until the app comes back to background
you’ll get this for free when you use
collectAsStateWithLifecyle
on your UiState
StateFlow
.
f
right, but not in a singleton navigator class, that's what I mean when I said you get it for free as part of the UI state
s
I wouldn’t use a singleton navigator class and I also wouldn’t use channel. I think the Flow solution mentioned above is fine, though not perfect. But it works 🙂
e
but your navigator holds a reference to the NavController, which implies it triggers navigation
I’m sorry but thats not implied anywhere in the code I shared 🤔
responsibility to monitor the state of the app and delay the navigation until the app comes back to foreground
thats what the coroutine scope thats collecting the channel is for, the scope delimits the operations inside it so if you want the collection to last as long as app is on foreground, it goes without saying that you will create a scope thats alive as long as app is in foreground and collect the navigation events in that scope
it is a more convoluted and fragile solution than simply passing the events in the state
Hmm hard disagree here but its subjective so no need for a back and forth. Having to “report back” to the presentation logic from every single point where navigation takes place seems more brittle than fire & forget. Using a channel makes it such that if a nav event is handled its automatically removed from the queue and you dont run the risk of say someone not reporting back and suddenly navigating multiple times on subsequent state emissions. Again, navigation is not state, modelling it as state while is not technically wrong, is just a little suspect (… to me)
you’ll get this for free when you use
collectAsStateWithLifecyle
on your UiState
StateFlow
Navigation is not a state, a stateflow is quite frankly one the worst model you can use for navigation events. You are in fact better off using a channel or a plain old queue if not in coroutines world.
s
Having to “report back” to the presentation logic from every single point where navigation takes place seems more brittle than fire & forget.
The solution that I presented above has only a single location in Compose for handling navigation, which is the
LaunchedEffect
part and where also the
confirmNavigation()
callback is called.
e
The solution that I presented above has only a single location
…that you have to call for every screen 🙂 and if one forgets to call viewModel.confirmNavigation(), bad things begin to happen all of a sudden, thats the part I was pointing out
s
Navigation is not a state, a stateflow is quite frankly one the worst model you can use for navigation events.
I agree that navigation events should not be state however I believe this is the best trade-off with a MVVM architecture to not introduce other concepts and still use StateFlow, coroutines etc. I think there is no clear architecture recommendation for “how to handle navigation from ViewModel” but I believe if I’m not mistaken that the solution that I presented was recommended by Google engineers but unfortunately I don’t have a source. I might be wrong.
e
Its totally fine. I would say experiment and settle on what works for you.
👍🏼 1
f
fire and forget is not how I would handle navigation, you need to ensure the navigation event has been handled, which is not fire and forget
s
Good point
f
Google definitively suggested treating events as part of the state, with a callback to clear the event once acknowledged and acted upon
here's a doc from one of their engineers on this subject https://manuelvivo.dev/blueprints-migration-compose
thank you color 1
e
Yeah and that is totally fine. Personally, I wouldn’t do (or recommend) such myself (at this stage).
f
well, you just suggested fire and forget above
Having to “report back” to the presentation logic from every single point where navigation takes place seems more brittle than fire & forget.
this link specifically discusses navigation event as part of the state
e
well, you just suggested fire and forget above
which is no different from “update the state & hope someone collects it” 😅 are we delving into technicalities? in both cases your there is no way for your _viewModel_/whatever to know if the navigation actually happened.
f
yes, that's the whole point, the UI will acknowledge the navigation event, that's what removes it from the state. As long as it's not acknowledged, it will remain in the state until we navigate to that destination
s
in both cases your there is no way for your _viewModel_/whatever to know if the navigation actually happened
That’s true unless you have a listener on your navigation framework and confirm that the new destination is the desired destination. But I would say this is overengineered. At some point you have to trust your framework 😉
1
e
But theres a risk here if UI does not ack, that you run the navigation multiple times. Whereas using a queue (ie channel) guarantees it will be handled once.
f
no, because it's state, you can't ack it multiple times, when you ack it it is removed from the state
s
Whereas using a queue (ie channel) guarantees it will be handled once.
But here you could “miss” an event if you don’t get the scopes and lifecycles right. Every solution has pros and cons.
☝️ 1
f
with the navigator class you need to be aware of not only if the app is foregrounded, but also which screen is in view so know whehter you can or can't navigate
e
Totally understand but as long as you don’t ack, say somebody forgot to implement that, you can navigate multiple times with new state emissions.
Every solution has pros and cons
Exactly. I outlined the one I could not live with personally.
f
what if the user backed of the screen before the async task completed? you could be navigating to a new destination that is no longer relevant or expected
Totally understand but as long as you don’t ack,
sure, but now we're talking about a buggy implementation, so all bets are off
😂 1
👍🏾 1
k
I think navigation in compose is somewhat weird. The Viewstate should hold what’s the current screen and it shouldn’t be done in compose in my opinion. I personally currently use a self written small navigator and compose just consumes the top level navigationdestination and then shows the respective composable.
1
e
but also which screen is in view so know whehter you can or can’t navigate
This is unnecessary. Even more so in navigation compose where you dont have to specify what are “valid directions” (like in fragments) and navigation is handled via “route”s instead.
but also which screen is in view so know wheter you can or can’t navigate
Correct, you can run into this if your presentation logic is not properly scoped to the destination. But at that point you also have other issues and the navigation arch is not one of them.
n
Got a lot from a discussion alone, thank you guys. Any tips on how to organize ui flows with authorization in compose?
d
But theres a risk here if UI does not ack, that you run the navigation multiple times. Whereas using a queue (ie channel) guarantees it will be handled once.
Well now that depends on how the navigation is handled from the UI side. Let’s say the
LaunchedEvent
that handles the navigation propagated from the viewmodel sends the acknowledgment first. For example:
Copy code
@Composable
fun TestScreen(
    navigateAway: () -> Unit,
) {
    
    val viewModel = object {
        private val _event = MutableStateFlow(null)
        val event: StateFlow<String?> = _event.asStateFlow()
        fun eventHandled() {
            _event.update { null }
        }
    }
    
    val event by viewModel.event.collectAsState()
    
    LaunchedEffect(event) {
        if (event != null) {
            viewModel.eventHandled()
            navigateAway()
        }
    }
    
}
So this method should ensure the correct order of events.