Skip to content

Commit 4013068

Browse files
committed
feat(navigation): keep tabs alive across a tab switch
A tab press cleared the whole backstack, so every tab was rebuilt from nothing on every press. Nav3 gives each entry its own ViewModelStore and saveable state, and both die with the entry, so returning to the wallet meant a cold ViewModel behind its loading spinner — which is what made the scanner-to-wallet switch after a claim read as a reload rather than a tab change. Tab homes now stay on the stack and the target moves to the top. Its entry is the one already there, so the tab comes back with its ViewModels, rememberSaveable state and list scroll position intact. Everything that is not a tab home is still dropped: the screens pushed on the outgoing tab and any sheet over them, which clearing always discarded and which would otherwise sit under the tab being opened. Back walks the visited tabs before it leaves the app, rather than leaving from whichever tab is showing. That is the cost of the retention — an entry has to be on the stack to survive — and it is not avoidable by refusing to pop: NavDisplay enables predictive back on stack depth alone, so a swallowed pop would show a seek preview and snap back. Two readers assumed the old shape. The nav bar's selection walked the stack from the bottom, which is now the tab visited first rather than the tab showing, and the scanner ran its camera only when it was the only entry on the stack, which never held again. Both now ask what is on top. MainRoot compares its launch graph from the active tab up, so the retained tabs beneath it don't read as a mismatch and reset the user to the launch route. Deeplinks are unaffected — they still arrive through navigateAll, which clears. A link is an entry point into the app rather than a move between tabs.
1 parent 62ccdab commit 4013068

5 files changed

Lines changed: 172 additions & 13 deletions

File tree

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import com.flipcash.app.core.LocalUserManager
2424
import dev.chrisbanes.haze.HazeState
2525
import com.flipcash.app.core.navigation.NavBarButton
2626
import com.flipcash.app.core.navigation.asNavBarTab
27-
import com.flipcash.app.core.navigation.destinationRoute
27+
import com.flipcash.app.core.navigation.switchTab
2828
import com.flipcash.app.core.ui.NavigationBar
2929
import com.flipcash.app.core.ui.rememberNavigationBarState
3030
import com.flipcash.app.session.LocalSessionController
@@ -39,8 +39,9 @@ import kotlinx.coroutines.flow.map
3939

4040
/**
4141
* The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever
42-
* top-level route is a tab home and switches tabs by **swapping the current screen** (single
43-
* backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet).
42+
* top-level route is a tab home and switches tabs by moving the target's home to the top of the
43+
* backstack (see [switchTab]), keeping the tabs already visited alive rather than rebuilding each
44+
* one on every press.
4445
*
4546
* Only visible when the current route maps to a tab.
4647
*
@@ -60,9 +61,12 @@ internal fun AppNavigationBar(
6061
// the expansion doesn't recompose the bar.
6162
cardExpansion: CardExpansionController? = null,
6263
) {
63-
// Selection follows the base of the backstack (the tab "home"), so it stays correct while a
64-
// sheet/modal sits on top and is right on launch. The top route only gates visibility.
65-
val selectedTab = navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() }
64+
// Selection follows the topmost tab home, so it stays correct while a sheet/modal or a pushed
65+
// detail sits over it and is right on launch. Read from the top down rather than the bottom up
66+
// because the tabs below the active one are the retained ones (see [switchTab]) — the base of
67+
// the stack is the tab visited first, not the tab showing. The top route only gates visibility.
68+
val selectedTab = navigator.backStack.asReversed()
69+
.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() }
6670
val topTab = (navigator.currentRouteKey as? AppRoute)?.asNavBarTab()
6771

6872
// A BottomBar modal (e.g. Add Money) renders in the nav content, above this bar; hide the bar so
@@ -126,10 +130,7 @@ internal fun AppNavigationBar(
126130
.padding(horizontal = CodeTheme.dimens.grid.x8)
127131
.padding(bottom = CodeTheme.dimens.grid.x3),
128132
state = state,
129-
onButtonClick = { button ->
130-
// Tab bar semantics: swap the current screen (single backstack).
131-
navigator.replaceAll(button.destinationRoute())
132-
},
133+
onButtonClick = { button -> navigator.switchTab(button) },
133134
hazeState = hazeState,
134135
avatar = avatar,
135136
)

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.flipcash.app.core.AppRoute
2727
import com.flipcash.app.core.DisplayNameSource
2828
import com.flipcash.app.core.userprofile.UpdateProfileStep
2929
import com.flipcash.app.core.navigation.DeeplinkAction
30+
import com.flipcash.app.core.navigation.asNavBarTab
3031
import com.flipcash.app.core.navigation.homeRoute
3132
import com.flipcash.app.core.extensions.navigateAll
3233
import com.flipcash.app.core.extensions.resolveBackStack
@@ -123,7 +124,7 @@ internal fun MainRoot(
123124
}
124125

125126
if (launch != null) {
126-
val current = navigator.backStack.toList()
127+
val current = navigator.backStack.toList().fromActiveTab()
127128
val target = launch.resolvedBackStack()
128129

129130
// Skip if the current stack already matches or extends the target.
@@ -169,6 +170,19 @@ internal data class LaunchNavGraph(
169170
resolveBackStack(baseRoutes, deeplinkRoutes)
170171
}
171172

173+
/**
174+
* The backstack from the active tab up, dropping the tab homes retained beneath it.
175+
*
176+
* A tab switch keeps the tabs already visited on the stack (see `switchTab`), so the base of the
177+
* stack is the tab visited first, not the one showing. The launch graph only ever describes the
178+
* active tab and what sits over it, so comparing against the whole stack would read the retained
179+
* tabs as a mismatch and reset the user to the launch route on any auth/flags re-emission.
180+
*/
181+
private fun List<NavKey>.fromActiveTab(): List<NavKey> {
182+
val active = indexOfLast { (it as? AppRoute)?.asNavBarTab() != null }
183+
return if (active > 0) drop(active) else this
184+
}
185+
172186
/**
173187
* Returns true if [this] list starts with [prefix] (element-wise structural equality).
174188
* An exact match also returns true (prefix == full list).
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.flipcash.app.core.navigation
2+
3+
import androidx.compose.runtime.snapshots.Snapshot
4+
import androidx.navigation3.runtime.NavKey
5+
import com.flipcash.app.core.AppRoute
6+
import com.getcode.navigation.core.CodeNavigator
7+
8+
/**
9+
* Switch to [tab]'s home, keeping the tab homes already visited.
10+
*
11+
* A tab press used to clear the whole backstack, so every tab was rebuilt from nothing on every
12+
* press: Nav3 gives each entry its own `ViewModelStore` and saveable state, and both die with the
13+
* entry. Returning to the wallet meant a cold ViewModel behind its loading spinner, which is what
14+
* made the scanner-to-wallet switch after a claim read as a reload rather than a tab change.
15+
*
16+
* So the tab homes stay on the stack and the target moves to the top. Its entry — and with it its
17+
* ViewModels, `rememberSaveable` state and list scroll position — is the one already there, so the
18+
* tab comes back as the user left it.
19+
*
20+
* Back now walks the visited tabs before it leaves the app, rather than leaving from whichever tab
21+
* is showing. That is the cost of the retention: an entry has to be on the stack to survive, and a
22+
* stack the user can see is a stack they can go back through.
23+
*
24+
* Deeplinks are unaffected — they still arrive through `navigateAll`, which clears. A link is an
25+
* entry point into the app rather than a move between tabs, and it should not inherit whatever the
26+
* previous session left behind it.
27+
*/
28+
fun CodeNavigator.switchTab(tab: NavBarButton) {
29+
val current = backStack.toList()
30+
val next = current.afterSwitchingTo(tab)
31+
if (next == current) return
32+
33+
Snapshot.withMutableSnapshot {
34+
backStack.clear()
35+
backStack.addAll(next)
36+
}
37+
}
38+
39+
/**
40+
* The stack [switchTab] leaves behind, in the order back will walk it.
41+
*
42+
* Everything that is not a tab home is dropped: the screens pushed on the outgoing tab, and any
43+
* sheet over them. Clearing the stack always discarded those, and carrying them across would leave
44+
* another tab's detail screen sitting under the one being opened.
45+
*
46+
* A pure function over the keys, because that is the part worth asserting on — [switchTab] applies
47+
* it to a live [androidx.navigation3.runtime.NavBackStack] inside a snapshot, so the whole swap
48+
* lands in one frame and no entry is seen to leave and come back.
49+
*/
50+
fun List<NavKey>.afterSwitchingTo(tab: NavBarButton): List<NavKey> {
51+
val destination = tab.destinationRoute()
52+
val retained = filter { key ->
53+
val home = (key as? AppRoute)?.asNavBarTab()
54+
home != null && home != tab
55+
}
56+
return retained + destination
57+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.flipcash.app.core.navigation
2+
3+
import androidx.navigation3.runtime.NavKey
4+
import com.flipcash.app.core.AppRoute
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertSame
8+
9+
/**
10+
* A tab press used to clear the backstack, which took each tab's ViewModels and saved state with it.
11+
* These assert the shape of the stack it leaves instead — the retained entries are the whole point,
12+
* since an entry that stays on the stack is an entry Nav3 does not tear down.
13+
*/
14+
class TabSwitchTest {
15+
16+
private val scanner = AppRoute.Main.Scanner
17+
private val wallet = AppRoute.Sheets.Wallet
18+
private val menu = AppRoute.Sheets.Menu
19+
20+
@Test
21+
fun `the tab already visited stays on the stack, under the one being opened`() {
22+
val stack = listOf<NavKey>(wallet)
23+
24+
assertEquals(listOf(wallet, scanner), stack.afterSwitchingTo(NavBarButton.Scanner))
25+
}
26+
27+
@Test
28+
fun `switching back reuses the entry that is already there`() {
29+
val stack = listOf<NavKey>(wallet).afterSwitchingTo(NavBarButton.Scanner)
30+
31+
val back = stack.afterSwitchingTo(NavBarButton.Wallet)
32+
33+
assertEquals(listOf(scanner, wallet), back)
34+
// Same key instance, so it keeps the same contentKey and Nav3 hands the entry — its
35+
// ViewModelStore, its saveable state — straight back rather than building a new one.
36+
assertSame(wallet, back.last())
37+
}
38+
39+
@Test
40+
fun `a tab is never on the stack twice, however often it is pressed`() {
41+
var stack = listOf<NavKey>(wallet)
42+
repeat(3) {
43+
stack = stack.afterSwitchingTo(NavBarButton.Scanner).afterSwitchingTo(NavBarButton.Wallet)
44+
}
45+
46+
assertEquals(listOf(scanner, wallet), stack)
47+
}
48+
49+
@Test
50+
fun `screens pushed on the outgoing tab are dropped, not carried across`() {
51+
val stack = listOf<NavKey>(wallet, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory))
52+
53+
// The wallet's home is kept — that is the entry being retained — but its open sheet is not,
54+
// or it would sit underneath the scanner and be what back returned to.
55+
assertEquals(listOf(wallet, scanner), stack.afterSwitchingTo(NavBarButton.Scanner))
56+
}
57+
58+
@Test
59+
fun `pressing the tab you are already on pops back to its home`() {
60+
val stack = listOf<NavKey>(wallet, AppRoute.Main.Sheet(AppRoute.Sheets.ActivityHistory))
61+
62+
assertEquals(listOf(wallet), stack.afterSwitchingTo(NavBarButton.Wallet))
63+
}
64+
65+
@Test
66+
fun `pressing the tab you are on with nothing over it changes nothing`() {
67+
val stack = listOf<NavKey>(scanner, wallet)
68+
69+
assertEquals(stack, stack.afterSwitchingTo(NavBarButton.Wallet))
70+
}
71+
72+
@Test
73+
fun `back walks the tabs in the order they were visited`() {
74+
var stack = listOf<NavKey>(wallet)
75+
stack = stack.afterSwitchingTo(NavBarButton.TipCard)
76+
stack = stack.afterSwitchingTo(NavBarButton.Scanner)
77+
78+
// Back from the scanner returns to the You tab, then the wallet, then leaves the app —
79+
// the deliberate cost of keeping the entries alive.
80+
assertEquals(listOf(wallet, menu, scanner), stack)
81+
}
82+
}

‎apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,13 @@ internal fun Scanner() {
157157
}
158158
}
159159

160-
LaunchedEffect(navigator.backStack.size) {
161-
previewing = navigator.backStack.size <= 1
160+
// The camera runs while nothing covers the scanner. This used to ask whether the scanner was the
161+
// only entry on the stack, which stopped meaning the same thing once a tab switch began keeping
162+
// the tabs already visited underneath it (see `switchTab`): the scanner tab is reached with those
163+
// beneath it, so the stack is never depth-1 and the preview never came up. Depth was only ever a
164+
// proxy for "nothing is on top"; ask that instead.
165+
LaunchedEffect(navigator.currentRouteKey) {
166+
previewing = navigator.currentRouteKey == AppRoute.Main.Scanner
162167
}
163168

164169
LaunchedEffect(billState.bill) {

0 commit comments

Comments
 (0)