Skip to content

Commit 3422083

Browse files
committed
[Update] migrate to nav3
1 parent 999eb05 commit 3422083

17 files changed

Lines changed: 272 additions & 144 deletions

File tree

app/build.gradle.kts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,14 @@ dependencies {
6868
implementation(libs.material3.window.size)
6969
implementation(libs.lifecycle.runtime.ktx)
7070

71-
implementation(libs.navigation.compose)
72-
implementation(libs.androidx.navigation.runtime.ktx)
71+
implementation(libs.androidx.navigation3.runtime)
72+
implementation(libs.androidx.navigation3.ui)
73+
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
7374

7475
implementation(libs.timber)
7576

7677
ksp(libs.hilt.android.compiler)
7778
implementation(libs.hilt.android)
78-
implementation(libs.hilt.navigation.compose)
7979

8080
implementation(libs.okhttp)
8181

app/src/main/java/com/rumosoft/marvelcompose/presentation/MainActivity.kt

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package com.rumosoft.marvelcompose.presentation
22

33
import android.app.Activity
44
import android.content.Context
5+
import android.content.Intent
56
import android.os.Bundle
67
import androidx.activity.ComponentActivity
78
import androidx.activity.compose.BackHandler
9+
import androidx.activity.compose.LocalActivity
810
import androidx.activity.compose.setContent
911
import androidx.activity.enableEdgeToEdge
1012
import androidx.compose.foundation.layout.Row
@@ -14,6 +16,7 @@ import androidx.compose.material3.SnackbarHost
1416
import androidx.compose.material3.SnackbarHostState
1517
import androidx.compose.material3.Surface
1618
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.LaunchedEffect
1720
import androidx.compose.runtime.getValue
1821
import androidx.compose.runtime.mutableStateOf
1922
import androidx.compose.runtime.remember
@@ -22,21 +25,23 @@ import androidx.compose.runtime.setValue
2225
import androidx.compose.ui.Modifier
2326
import androidx.compose.ui.platform.LocalContext
2427
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
25-
import androidx.navigation.NavHostController
26-
import androidx.navigation.compose.currentBackStackEntryAsState
27-
import androidx.navigation.compose.rememberNavController
28+
import androidx.navigation3.runtime.NavKey
2829
import com.rumosoft.components.presentation.component.isWindowCompact
2930
import com.rumosoft.components.presentation.deeplinks.CharactersScreen
3031
import com.rumosoft.components.presentation.deeplinks.ComicsScreen
32+
import com.rumosoft.components.presentation.deeplinks.DEEP_LINKS_BASE_PATH
3133
import com.rumosoft.components.presentation.deeplinks.Screen
3234
import com.rumosoft.components.presentation.theme.MarvelComposeTheme
3335
import com.rumosoft.marvelcompose.R
3436
import com.rumosoft.marvelcompose.presentation.navigation.BottomNavigationBar
3537
import com.rumosoft.marvelcompose.presentation.navigation.NavigationHost
3638
import com.rumosoft.marvelcompose.presentation.navigation.NavigationRailBar
39+
import com.rumosoft.marvelcompose.presentation.navigation.NavigationState
40+
import com.rumosoft.marvelcompose.presentation.navigation.Navigator
41+
import com.rumosoft.marvelcompose.presentation.navigation.Tabs
3742
import com.rumosoft.marvelcompose.presentation.navigation.Tabs.Characters
3843
import com.rumosoft.marvelcompose.presentation.navigation.Tabs.Comics
39-
import com.rumosoft.marvelcompose.presentation.navigation.onTabClick
44+
import com.rumosoft.marvelcompose.presentation.navigation.rememberNavigationState
4045
import dagger.hilt.android.AndroidEntryPoint
4146
import kotlinx.coroutines.CoroutineScope
4247
import kotlinx.coroutines.launch
@@ -63,7 +68,20 @@ class MainActivity : ComponentActivity() {
6368

6469
@Composable
6570
fun MarvelApp() {
66-
val navController = rememberNavController()
71+
val navigationState = rememberNavigationState(
72+
startRoute = CharactersScreen,
73+
topLevelRoutes = setOf<NavKey>(CharactersScreen, ComicsScreen)
74+
)
75+
val navigator = remember { Navigator(navigationState) }
76+
77+
// Handle deep links from intent
78+
val activity = LocalActivity.current
79+
LaunchedEffect(Unit) {
80+
activity?.intent?.let { intent ->
81+
handleDeepLink(intent, navigator)
82+
}
83+
}
84+
6785
val navigationItems = listOf(
6886
Characters,
6987
Comics,
@@ -78,11 +96,11 @@ fun MarvelApp() {
7896
topBar = { topBarContent() },
7997
snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
8098
bottomBar = {
81-
if (shouldShowBottomBar(navController)) {
99+
if (shouldShowBottomBar(navigationState)) {
82100
BottomNavigationBar(
83101
navigationItems = navigationItems,
84-
currentScreen = getCurrentScreen(navController),
85-
onTabClick = { onTabClick(it, navController) },
102+
currentScreen = getCurrentScreen(navigationState),
103+
onTabClick = { onTabClick(it, navigator) },
86104
)
87105
}
88106
},
@@ -91,18 +109,20 @@ fun MarvelApp() {
91109
Row {
92110
NavigationRailBar(
93111
navigationItems = navigationItems,
94-
currentScreen = getCurrentScreen(navController),
95-
onTabClick = { onTabClick(it, navController) },
112+
currentScreen = getCurrentScreen(navigationState),
113+
onTabClick = { onTabClick(it, navigator) },
96114
)
97115
NavigationHost(
98-
navController,
116+
navigationState = navigationState,
117+
navigator = navigator,
99118
setTopBarContent = { topBarContent = it },
100119
modifier = Modifier.padding(innerPadding),
101120
)
102121
}
103122
} else {
104123
NavigationHost(
105-
navController,
124+
navigationState = navigationState,
125+
navigator = navigator,
106126
setTopBarContent = { topBarContent = it },
107127
modifier = Modifier.padding(innerPadding),
108128
)
@@ -112,32 +132,27 @@ fun MarvelApp() {
112132

113133
@Composable
114134
private fun shouldShowBottomBar(
115-
navController: NavHostController
135+
navigationState: NavigationState
116136
): Boolean {
117-
println("isWindowCompact: ${isWindowCompact()}")
118-
println("currentRoute: ${currentRoute(navController)}")
119-
return isWindowCompact() && (
120-
currentRoute(navController)?.contains("CharactersScreen") == true ||
121-
currentRoute(navController)?.contains("ComicsScreen") == true)
137+
val isOnTopLevel = navigationState.backStacks[navigationState.topLevelRoute]?.let {
138+
it.size == 1
139+
} ?: true
140+
return isWindowCompact() && isOnTopLevel
122141
}
123142

124143
@Composable
125-
private fun getCurrentScreen(navController: NavHostController): Screen {
126-
val route = navController.currentBackStackEntryAsState().value?.destination?.route
127-
return when {
128-
route?.contains("CharactersScreen") == true -> CharactersScreen
129-
route?.contains("ComicsScreen") == true -> ComicsScreen
144+
private fun getCurrentScreen(navigationState: NavigationState): Screen {
145+
return when (navigationState.topLevelRoute) {
146+
is ComicsScreen -> ComicsScreen
130147
else -> CharactersScreen
131148
}
132149
}
133150

134151
@Composable
135152
private fun shouldShowNavigationRail() = !isWindowCompact()
136153

137-
@Composable
138-
private fun currentRoute(navController: NavHostController): String? {
139-
val navBackStackEntry by navController.currentBackStackEntryAsState()
140-
return navBackStackEntry?.destination?.route
154+
private fun onTabClick(tab: Tabs, navigator: Navigator) {
155+
navigator.navigate(tab.screen as NavKey)
141156
}
142157

143158
private fun onAppBack(
@@ -154,3 +169,12 @@ private fun onAppBack(
154169
}
155170
}
156171

172+
private fun handleDeepLink(intent: Intent, navigator: Navigator) {
173+
val uri = intent.data ?: return
174+
val path = uri.toString().removePrefix(DEEP_LINKS_BASE_PATH)
175+
when {
176+
path.startsWith("/comics") -> navigator.navigate(ComicsScreen as NavKey)
177+
path.startsWith("/characters") -> navigator.navigate(CharactersScreen as NavKey)
178+
}
179+
}
180+

app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/NavigationHost.kt

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,44 @@ package com.rumosoft.marvelcompose.presentation.navigation
33
import androidx.compose.runtime.Composable
44
import androidx.compose.ui.Modifier
55
import androidx.compose.ui.platform.testTag
6-
import androidx.navigation.NavHostController
7-
import androidx.navigation.compose.NavHost
6+
import androidx.navigation3.runtime.NavKey
7+
import androidx.navigation3.runtime.entryProvider
8+
import androidx.navigation3.ui.NavDisplay
89
import com.rumosoft.characters.presentation.navigation.charactersGraph
910
import com.rumosoft.comics.presentation.navigation.comicsGraph
10-
import com.rumosoft.components.presentation.deeplinks.CharactersScreen
11+
12+
import com.rumosoft.components.presentation.deeplinks.ComicDetails
13+
import com.rumosoft.components.presentation.deeplinks.ComicsScreen
1114

1215
const val NAVIGATION_HOST_TEST_TAG = "NavigationHost"
1316

1417
@Composable
1518
fun NavigationHost(
16-
navController: NavHostController,
19+
navigationState: NavigationState,
20+
navigator: Navigator,
1721
setTopBarContent: (@Composable () -> Unit) -> Unit,
1822
modifier: Modifier = Modifier,
1923
) {
20-
NavHost(
21-
navController,
22-
startDestination = CharactersScreen,
23-
modifier = modifier.testTag(NAVIGATION_HOST_TEST_TAG)
24-
) {
25-
charactersGraph(navController, setTopBarContent)
26-
comicsGraph(navController, setTopBarContent)
24+
val entryProvider = entryProvider<NavKey> {
25+
charactersGraph(
26+
navigate = navigator::navigate,
27+
onComicSelected = { comicId ->
28+
navigator.goBack()
29+
navigator.navigateOnTab(ComicsScreen, ComicDetails(comicId))
30+
},
31+
goBack = navigator::goBack,
32+
setTopBarContent = setTopBarContent,
33+
)
34+
comicsGraph(
35+
navigate = navigator::navigate,
36+
goBack = navigator::goBack,
37+
setTopBarContent = setTopBarContent,
38+
)
2739
}
28-
}
40+
41+
NavDisplay(
42+
entries = navigationState.toEntries(entryProvider),
43+
onBack = { navigator.goBack() },
44+
modifier = modifier.testTag(NAVIGATION_HOST_TEST_TAG),
45+
)
46+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.rumosoft.marvelcompose.presentation.navigation
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.MutableState
5+
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.runtime.saveable.rememberSerializable
9+
import androidx.compose.runtime.setValue
10+
import androidx.compose.runtime.snapshots.SnapshotStateList
11+
import androidx.compose.runtime.toMutableStateList
12+
import androidx.navigation3.runtime.NavBackStack
13+
import androidx.navigation3.runtime.NavEntry
14+
import androidx.navigation3.runtime.NavKey
15+
import androidx.navigation3.runtime.rememberDecoratedNavEntries
16+
import androidx.navigation3.runtime.rememberNavBackStack
17+
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
18+
import androidx.navigation3.runtime.serialization.NavKeySerializer
19+
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
20+
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer
21+
22+
/**
23+
* Create a navigation state that persists config changes and process death.
24+
*/
25+
@Composable
26+
fun rememberNavigationState(
27+
startRoute: NavKey,
28+
topLevelRoutes: Set<NavKey>
29+
): NavigationState {
30+
31+
val topLevelRoute = rememberSerializable(
32+
startRoute, topLevelRoutes,
33+
serializer = MutableStateSerializer(NavKeySerializer())
34+
) {
35+
mutableStateOf(startRoute)
36+
}
37+
38+
val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }
39+
40+
return remember(startRoute, topLevelRoutes) {
41+
NavigationState(
42+
startRoute = startRoute,
43+
topLevelRoute = topLevelRoute,
44+
backStacks = backStacks
45+
)
46+
}
47+
}
48+
49+
/**
50+
* State holder for navigation state.
51+
*/
52+
class NavigationState(
53+
val startRoute: NavKey,
54+
topLevelRoute: MutableState<NavKey>,
55+
val backStacks: Map<NavKey, NavBackStack<NavKey>>
56+
) {
57+
var topLevelRoute: NavKey by topLevelRoute
58+
val stacksInUse: List<NavKey>
59+
get() = if (topLevelRoute == startRoute) {
60+
listOf(startRoute)
61+
} else {
62+
listOf(startRoute, topLevelRoute)
63+
}
64+
}
65+
66+
/**
67+
* Convert NavigationState into NavEntries.
68+
*/
69+
@Composable
70+
fun NavigationState.toEntries(
71+
entryProvider: (NavKey) -> NavEntry<NavKey>
72+
): SnapshotStateList<NavEntry<NavKey>> {
73+
74+
val decoratedEntries = backStacks.mapValues { (_, stack) ->
75+
val decorators = listOf(
76+
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
77+
rememberViewModelStoreNavEntryDecorator<NavKey>(),
78+
)
79+
rememberDecoratedNavEntries(
80+
backStack = stack,
81+
entryDecorators = decorators,
82+
entryProvider = entryProvider
83+
)
84+
}
85+
86+
return stacksInUse
87+
.flatMap { decoratedEntries[it] ?: emptyList() }
88+
.toMutableStateList()
89+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.rumosoft.marvelcompose.presentation.navigation
2+
3+
import androidx.navigation3.runtime.NavKey
4+
5+
/**
6+
* Handles navigation events (forward and back) by updating the navigation state.
7+
*/
8+
class Navigator(val state: NavigationState) {
9+
fun navigate(route: NavKey) {
10+
if (route in state.backStacks.keys) {
11+
// This is a top level route, just switch to it.
12+
state.topLevelRoute = route
13+
} else {
14+
state.backStacks[state.topLevelRoute]?.add(route)
15+
}
16+
}
17+
18+
/**
19+
* Navigate to a route on a specific tab's back stack.
20+
* Switches to that tab and pushes the route there.
21+
*/
22+
fun navigateOnTab(tab: NavKey, route: NavKey) {
23+
state.topLevelRoute = tab
24+
state.backStacks[tab]?.add(route)
25+
}
26+
27+
fun goBack() {
28+
val currentStack = state.backStacks[state.topLevelRoute]
29+
?: error("Stack for ${state.topLevelRoute} not found")
30+
val currentRoute = currentStack.last()
31+
32+
// If we're at the base of the current route, go back to the start route stack.
33+
if (currentRoute == state.topLevelRoute) {
34+
state.topLevelRoute = state.startRoute
35+
} else {
36+
currentStack.removeLastOrNull()
37+
}
38+
}
39+
}

app/src/main/java/com/rumosoft/marvelcompose/presentation/navigation/onTabClick.kt

Lines changed: 0 additions & 16 deletions
This file was deleted.

feature-characters/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ dependencies {
6363
implementation(libs.material3)
6464
implementation(libs.lifecycle.runtime.ktx)
6565
implementation(libs.lifecycle.runtime.compose)
66-
implementation(libs.navigation.compose)
67-
implementation(libs.androidx.navigation.runtime.ktx)
66+
implementation(libs.androidx.navigation3.runtime)
67+
implementation(libs.androidx.navigation3.ui)
6868
implementation(libs.gson)
6969
implementation(libs.retrofit)
7070
implementation(libs.retrofit.converter.gson)

0 commit comments

Comments
 (0)