Bug 1879146 - Move mozac.org docs back into the android-components folder. r=owlish...
[gecko.git] / mobile / android / android-components / docs / rfcs / 0010-add-state-based-navigation.md
blob56e68c044ab1658f06a8202328cfda1675853ba1
1 ---
2 layout: page
3 title: Add State-based navigation
4 permalink: /rfc/0010-add-state-based-navigation
5 ---
7 * Start date: 2023-10-17
8 * RFC PR: [4126](https://github.com/mozilla-mobile/firefox-android/pull/4126)
10 ## Summary
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). 
14 ## Motivation
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.
24 ## Proposal
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:
33 ```kotlin
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> {
41     override fun invoke(
42         context: MiddlewareContext<HistoryState, HistoryAction>,
43         next: (HistoryFragmentAction) -> Unit,
44         action: HistoryFragmentAction,
45     ) {
46         next(action)
47         when (action) {
48             is HomeButtonClicked -> getNavController().navigate(R.id.home_fragment)
49             is HistoryGroupClicked -> getNavController().navigate(R.id.history_metadata_fragment)
50         }
51     }
53 ```
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:
63 ```kotlin
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> {
73     override fun invoke(
74         context: MiddlewareContext<HistoryState, HistoryAction>,
75         next: (HistoryFragmentAction) -> Unit,
76         action: HistoryFragmentAction,
77     ) {
78         next(action)
79         when (action) {
80             is HistoryItemLongClicked -> displayMenuForItem(action.item)
81             is HomeButtonClicked, HistoryGroupClicked -> Unit
82         }
83     }
85 ```
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.
91 ```kotlin
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> {
103     override fun invoke(
104         context: MiddlewareContext<HistoryState, HistoryAction>,
105         next: (HistoryFragmentAction) -> Unit,
106         action: HistoryFragmentAction,
107     ) {
108         next(action)
109         when (action) {
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
114         }
115     }
119 This delegate would wrap the current behavior exposed by `HomeActivity::openToBrowserAndLoad`, looking something roughly like:
121 ```kotlin
122 class BrowserNavigator(
123     private val addTabUseCase: AddNewTabUseCase,
124     private val loadTabUseCase: DefaultLoadUrlUseCase,
125     private val searchUseCases: SearchUseCases,
126     private val navController: () -> NavController,
127 ) {
128     // logic to navigate to browser fragment and load a tab
132 ## Alternatives
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:
138 ```kotlin
139 sealed class AppAction {
140     object NavigateHome : AppAction()
143 data class AppState(
144     val currentScreen: Screen
147 fun appReducer(state: AppState, action: AppAction): AppState = when (action) {
148     is NavigateHome -> state.copy(currentScreen = Screen.Home)
151 // in HomeActivity
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 */ }
157     }
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.