3 title: Add State-based navigation
4 permalink: /rfc/0010-add-state-based-navigation
7 * Start date: 2023-10-17
8 * RFC PR: [4126](https://github.com/mozilla-mobile/firefox-android/pull/4126)
12 Following the acceptance of [0009 - Remove Interactors and Controllers](0009-remove-interactors-and-controllers), Fenix should have a method of navigation that is tied to the `lib-state` model to provide a method of handling navigation side-effects that is consistent with architectural goals. This architecture is defined at a high-level [here](https://github.com/mozilla-mobile/firefox-android/blob/9edfde0a1382b4d5fb4342792d98d4c1d4d41bef/fenix/docs/architecture-overview.md) and has example code in [this folder](https://github.com/mozilla-mobile/firefox-android/tree/9edfde0a1382b4d5fb4342792d98d4c1d4d41bef/fenix/docs/architectureexample).
16 Currently, methods of navigation throughout the app are varied. The `SessionControlController` provides [3 examples](https://searchfox.org/mozilla-mobile/rev/aa6bee71a6e0ea73f041a54ddf4d5d4e2f603429/firefox-android/fenix/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlController.kt#180) alone:
18 - `HomeActivity::openToBrowserAndLoad`
19 - Calling a `NavController` directly
20 - Callbacks like `showTabTray()`
22 To move to a more consistent Redux-like model, we need a way for features to fire `Action`s and have that result in navigation. This will help decouple our business logic from the Android platform, where a key example of this would be references to the `HomeActivity` throughout the app in order to access the `openToBrowserAndLoad` function.
26 Moving forward, navigation should be initiated through middlewares that respond to `Action`s. How these middleware handle navigation side-effects can be addressed on a per-case basis, but this proposal includes some generalized advice for 3 common use cases.
28 ### 1. Screen-based navigation
30 For screen-based navigation between screens like the settings pages or navigation to the home screen, middlewares should make direct use of a `NavController` that is hosted by the fragment of the current screen's scope.
32 For a hypothetical example:
35 sealed class HistoryAction {
36 object HomeButtonClicked : HistoryAction()
37 data class HistoryGroupClicked(val group: History.Group) : HistoryAction()
40 class HistoryNavigationMiddleware(private val getNavController: () -> NavController) : Middleware<HistoryState, HistoryAction> {
42 context: MiddlewareContext<HistoryState, HistoryAction>,
43 next: (HistoryFragmentAction) -> Unit,
44 action: HistoryFragmentAction,
48 is HomeButtonClicked -> getNavController().navigate(R.id.home_fragment)
49 is HistoryGroupClicked -> getNavController().navigate(R.id.history_metadata_fragment)
55 This should translate fairly easily to the Compose world. This example intentionally ignores passing the `group` through the navigation transition. It should be fairly trivial to convert data types to navigation arguments, or consider creating Stores with a scope large enough to maintain state across these transitions.
57 Note also the use of a lambda to retrieve the `NavController`. This should help avoid stale references when Stores outlive their parent fragment by using a `StoreProvider`.
59 ### 2. Transient effects
61 Transient effects can be handled by callbacks provided to a middleware. To build on our previous example:
64 sealed class HistoryAction {
65 object HomeButtonClicked : HistoryAction()
66 data class HistoryGroupClicked(val group: HistoryItem.Group) : HistoryAction()
67 data class HistoryItemLongClicked(val item: HistoryItem) : HistoryAction()
70 class HistoryUiEffectMiddleware(
71 private val displayMenuForItem: (HistoryItem) -> Unit,
72 ) : Middleware<HistoryState, HistoryAction> {
74 context: MiddlewareContext<HistoryState, HistoryAction>,
75 next: (HistoryFragmentAction) -> Unit,
76 action: HistoryFragmentAction,
80 is HistoryItemLongClicked -> displayMenuForItem(action.item)
81 is HomeButtonClicked, HistoryGroupClicked -> Unit
87 ### 3. The special case of `openToBrowserAndLoad`
89 Finally, we want a generally re-usable method of opening a new tab and navigating to the `BrowserFragment`. Fragment-based Stores can re-use a (theoretical) delegate to do so.
92 sealed class HistoryAction {
93 object HomeButtonClicked : HistoryAction()
94 data class HistoryGroupClicked(val group: History.Group) : HistoryAction()
95 data class HistoryItemLongClicked(val item: HistoryItem) : HistoryAction()
96 data class HistoryItemClicked(val item: History.Item) : HistoryAction()
99 class HistoryNavigationMiddleware(
100 private val browserNavigator: BrowserNavigator,
101 private val getNavController: () -> NavController,
102 ) : Middleware<HistoryState, HistoryAction> {
104 context: MiddlewareContext<HistoryState, HistoryAction>,
105 next: (HistoryFragmentAction) -> Unit,
106 action: HistoryFragmentAction,
110 is HomeButtonClicked -> navController.navigate(R.id.home_fragment)
111 is HistoryGroupClicked -> navController.navigate(R.id.history_metadata_fragment)
112 is HistoryItemClicked -> browserNavigator.openToBrowserAndLoad(action.item)
113 is HistoryItemLongClicked -> Unit
119 This delegate would wrap the current behavior exposed by `HomeActivity::openToBrowserAndLoad`, looking something roughly like:
122 class BrowserNavigator(
123 private val addTabUseCase: AddNewTabUseCase,
124 private val loadTabUseCase: DefaultLoadUrlUseCase,
125 private val searchUseCases: SearchUseCases,
126 private val navController: () -> NavController,
128 // logic to navigate to browser fragment and load a tab
134 ### 1. Observing navigation State from a AppStore through a binding in HomeActivity.
136 This was the previous proposal for this RFC. An example would roughly be:
139 sealed class AppAction {
140 object NavigateHome : AppAction()
144 val currentScreen: Screen
147 fun appReducer(state: AppState, action: AppAction): AppState = when (action) {
148 is NavigateHome -> state.copy(currentScreen = Screen.Home)
152 private val navigationObserver by lazy {
153 object : AbstractBinding<AppState>(components.appStore) {
154 override suspend fun onState(flow: Flow<AppState>) = flow
155 .distinctUntilChangedBy { it.screen }
156 .collectLatest { /* handleNavigation */ }
161 However, this implies some several issues:
162 1. We end up replicating the state of a `NavController` manually in a our custom State, risking out-of-sync issues.
163 2. We lose specificity of Actions by generalizing them globally. For example, instead of a `ToolbarAction.HomeClicked`, it would encourage re-use of a single `AppAction.NavigateHome`. Though seemingly convenient at first, it implies downstream problems for things like telemetry. To know where the navigation to home originated from, we would need to include additional properties (like direction) in the `Action`. Any future changes to the behavior of these Actions would need to be generalized for the whole app.
165 ### 2. Global navigation middleware attached to the AppStore.
167 This carries risk of the 2 issue listed above, and runs into immediate technical constraints. When the `AppStore` is constructed in `Core`, we do not have reference to an `Activity` and cannot retrieve a `NavController`. This could be mitigated by a mutable property or lazy getter that is set as Fragments or the Activity come into and out of scope. The current proposal will localize navigation transitions to feature areas which should keep them isolated in scope.