Skip to content

Commit 5f0d06b

Browse files
committed
[#610] [Part 2] Update navigation library and refactor to follow unidirectional data flow for template compose
1 parent d220269 commit 5f0d06b

File tree

12 files changed

+191
-75
lines changed

12 files changed

+191
-75
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package co.nimblehq.template.compose.extensions
2+
3+
import androidx.navigation.NavHostController
4+
import androidx.navigation.NavOptionsBuilder
5+
import co.nimblehq.template.compose.ui.base.BaseAppDestination
6+
import kotlin.collections.component1
7+
import kotlin.collections.component2
8+
9+
/**
10+
* Use this extension or [navigate(BaseAppDestination.Up())] to prevent duplicated navigation events
11+
*/
12+
fun NavHostController.navigateAppDestinationUp() {
13+
navigateTo(BaseAppDestination.Up())
14+
}
15+
16+
/**
17+
* TODO Create new class extend NavHostController then move the related codes to that class
18+
*/
19+
private const val IntervalInMillis: Long = 1000L
20+
private var lastNavigationEventExecutedTimeInMillis: Long = 0L
21+
22+
/**
23+
* Use this extension to prevent duplicated navigation events with the same destination in a short time
24+
*/
25+
private fun NavHostController.throttleNavigation(
26+
appDestination: BaseAppDestination,
27+
onNavigate: () -> Unit,
28+
) {
29+
val currentTime = System.currentTimeMillis()
30+
if (currentBackStackEntry?.destination?.route == appDestination.route
31+
&& (currentTime - lastNavigationEventExecutedTimeInMillis < IntervalInMillis)
32+
) {
33+
return
34+
}
35+
lastNavigationEventExecutedTimeInMillis = currentTime
36+
37+
onNavigate()
38+
}
39+
40+
/**
41+
* Navigate to provided [BaseAppDestination]
42+
* Caution to use this method. This method use savedStateHandle to store the Parcelable data.
43+
* When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data.
44+
* eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully.
45+
*/
46+
fun <T : BaseAppDestination> NavHostController.navigateTo(
47+
appDestination: T,
48+
builder: (NavOptionsBuilder.() -> Unit)? = null,
49+
) = throttleNavigation(appDestination) {
50+
when (appDestination) {
51+
is BaseAppDestination.Up -> {
52+
appDestination.results.forEach { (key, value) ->
53+
previousBackStackEntry?.savedStateHandle?.set(key, value)
54+
}
55+
navigateUp()
56+
}
57+
else -> {
58+
appDestination.parcelableArgument?.let { (key, value) ->
59+
currentBackStackEntry?.savedStateHandle?.set(key, value)
60+
}
61+
navigate(route = appDestination.destination) {
62+
if (builder != null) {
63+
builder()
64+
}
65+
}
66+
}
67+
}
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package co.nimblehq.template.compose.extensions
2+
3+
import androidx.compose.animation.AnimatedContentScope
4+
import androidx.compose.animation.AnimatedContentTransitionScope
5+
import androidx.compose.animation.EnterTransition
6+
import androidx.compose.animation.ExitTransition
7+
import androidx.compose.animation.core.tween
8+
import androidx.compose.runtime.Composable
9+
import androidx.navigation.NavBackStackEntry
10+
import androidx.navigation.NavDeepLink
11+
import androidx.navigation.NavGraphBuilder
12+
import androidx.navigation.compose.composable
13+
import co.nimblehq.template.compose.ui.base.BaseAppDestination
14+
15+
private const val NavAnimationDurationInMillis = 300
16+
17+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInLeftTransition() =
18+
slideIntoContainer(
19+
towards = AnimatedContentTransitionScope.SlideDirection.Start,
20+
animationSpec = tween(NavAnimationDurationInMillis)
21+
)
22+
23+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutLeftTransition() =
24+
slideOutOfContainer(
25+
towards = AnimatedContentTransitionScope.SlideDirection.Start,
26+
animationSpec = tween(NavAnimationDurationInMillis)
27+
)
28+
29+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInRightTransition() =
30+
slideIntoContainer(
31+
towards = AnimatedContentTransitionScope.SlideDirection.End,
32+
animationSpec = tween(NavAnimationDurationInMillis)
33+
)
34+
35+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutRightTransition() =
36+
slideOutOfContainer(
37+
towards = AnimatedContentTransitionScope.SlideDirection.End,
38+
animationSpec = tween(NavAnimationDurationInMillis)
39+
)
40+
41+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInUpTransition() =
42+
slideIntoContainer(
43+
towards = AnimatedContentTransitionScope.SlideDirection.Up,
44+
animationSpec = tween(NavAnimationDurationInMillis)
45+
)
46+
47+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutDownTransition() =
48+
slideOutOfContainer(
49+
towards = AnimatedContentTransitionScope.SlideDirection.Down,
50+
animationSpec = tween(NavAnimationDurationInMillis)
51+
)
52+
53+
fun NavGraphBuilder.composable(
54+
destination: BaseAppDestination,
55+
deepLinks: List<NavDeepLink> = emptyList(),
56+
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
57+
enterSlideInLeftTransition()
58+
},
59+
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
60+
exitSlideOutLeftTransition()
61+
},
62+
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
63+
enterSlideInRightTransition()
64+
},
65+
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
66+
exitSlideOutRightTransition()
67+
},
68+
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
69+
) {
70+
composable(
71+
route = destination.route,
72+
arguments = destination.arguments,
73+
deepLinks = deepLinks,
74+
enterTransition = enterTransition,
75+
exitTransition = exitTransition,
76+
popEnterTransition = popEnterTransition,
77+
popExitTransition = popExitTransition,
78+
content = content
79+
)
80+
}
81+
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package co.nimblehq.template.compose.ui
22

3-
import co.nimblehq.template.compose.ui.base.BaseDestination
3+
import co.nimblehq.template.compose.ui.base.BaseAppDestination
44

55
sealed class AppDestination {
66

7-
object RootNavGraph : BaseDestination("rootNavGraph")
7+
object RootNavGraph : BaseAppDestination("rootNavGraph")
88

9-
object MainNavGraph : BaseDestination("mainNavGraph")
9+
object MainNavGraph : BaseAppDestination("mainNavGraph")
1010
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
package co.nimblehq.template.compose.ui
22

33
import androidx.compose.runtime.Composable
4-
import androidx.navigation.NavBackStackEntry
5-
import androidx.navigation.NavGraphBuilder
64
import androidx.navigation.NavHostController
75
import androidx.navigation.compose.NavHost
8-
import androidx.navigation.compose.composable
9-
import androidx.navigation.navDeepLink
10-
import co.nimblehq.template.compose.ui.base.BaseDestination
116
import co.nimblehq.template.compose.ui.screens.main.mainNavGraph
127

138
@Composable
@@ -22,31 +17,3 @@ fun AppNavGraph(
2217
mainNavGraph(navController = navController)
2318
}
2419
}
25-
26-
fun NavGraphBuilder.composable(
27-
destination: BaseDestination,
28-
content: @Composable (NavBackStackEntry) -> Unit,
29-
) {
30-
composable(
31-
route = destination.route,
32-
arguments = destination.arguments,
33-
deepLinks = destination.deepLinks.map {
34-
navDeepLink {
35-
uriPattern = it
36-
}
37-
},
38-
content = content
39-
)
40-
}
41-
42-
fun NavHostController.navigate(destination: BaseDestination) {
43-
when (destination) {
44-
is BaseDestination.Up -> {
45-
destination.results.forEach { (key, value) ->
46-
previousBackStackEntry?.savedStateHandle?.set(key, value)
47-
}
48-
navigateUp()
49-
}
50-
else -> navigate(route = destination.destination)
51-
}
52-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package co.nimblehq.template.compose.ui.base
2+
3+
import androidx.navigation.NamedNavArgument
4+
5+
const val KeyResultOk = "keyResultOk"
6+
7+
/**
8+
* Use "class" over "object" for destinations with [parcelableArgument] usage or a [navArgument] with [defaultValue] set
9+
* to reset destination nav arguments.
10+
*/
11+
abstract class BaseAppDestination(val route: String = "") {
12+
13+
open val arguments: List<NamedNavArgument> = emptyList()
14+
15+
open val deepLinks: List<String> = listOf(
16+
"https://android.nimblehq.co/$route",
17+
"android://$route",
18+
)
19+
20+
open var destination: String = route
21+
22+
open var parcelableArgument: Pair<String, Any?>? = null
23+
24+
data class Up(
25+
val results: HashMap<String, Any> = hashMapOf(),
26+
) : BaseAppDestination() {
27+
28+
fun put(key: String, value: Any) = apply {
29+
results[key] = value
30+
}
31+
}
32+
}

template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt

-19
This file was deleted.

template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt

-3
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ abstract class BaseViewModel : ViewModel() {
1818
protected val _error = MutableSharedFlow<Throwable>()
1919
val error = _error.asSharedFlow()
2020

21-
protected val _navigator = MutableSharedFlow<BaseDestination>()
22-
val navigator = _navigator.asSharedFlow()
23-
2421
/**
2522
* To show loading manually, should call `hideLoading` after
2623
*/
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package co.nimblehq.template.compose.ui.screens.main
22

3-
import co.nimblehq.template.compose.ui.base.BaseDestination
3+
import co.nimblehq.template.compose.ui.base.BaseAppDestination
44

55
sealed class MainDestination {
66

7-
object Home : BaseDestination("home")
7+
object Home : BaseAppDestination("home")
88
}

template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt

+2-5
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ package co.nimblehq.template.compose.ui.screens.main
33
import androidx.navigation.NavGraphBuilder
44
import androidx.navigation.NavHostController
55
import androidx.navigation.navigation
6+
import co.nimblehq.template.compose.extensions.composable
67
import co.nimblehq.template.compose.ui.AppDestination
7-
import co.nimblehq.template.compose.ui.composable
8-
import co.nimblehq.template.compose.ui.navigate
98
import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen
109

1110
fun NavGraphBuilder.mainNavGraph(
@@ -16,9 +15,7 @@ fun NavGraphBuilder.mainNavGraph(
1615
startDestination = MainDestination.Home.destination
1716
) {
1817
composable(MainDestination.Home) {
19-
HomeScreen(
20-
navigator = { destination -> navController.navigate(destination) }
21-
)
18+
HomeScreen()
2219
}
2320
}
2421
}

template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt

-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
1717
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1818
import co.nimblehq.template.compose.R
1919
import co.nimblehq.template.compose.extensions.collectAsEffect
20-
import co.nimblehq.template.compose.ui.base.BaseDestination
2120
import co.nimblehq.template.compose.ui.base.BaseScreen
2221
import co.nimblehq.template.compose.ui.models.UiModel
2322
import co.nimblehq.template.compose.ui.showToast
@@ -28,11 +27,9 @@ import timber.log.Timber
2827
@Composable
2928
fun HomeScreen(
3029
viewModel: HomeViewModel = hiltViewModel(),
31-
navigator: (destination: BaseDestination) -> Unit,
3230
) = BaseScreen {
3331
val context = LocalContext.current
3432
viewModel.error.collectAsEffect { e -> e.showToast(context) }
35-
viewModel.navigator.collectAsEffect { destination -> navigator(destination) }
3633

3734
val uiModels: List<UiModel> by viewModel.uiModels.collectAsStateWithLifecycle()
3835

template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt

-4
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
77
import co.nimblehq.template.compose.R
88
import co.nimblehq.template.compose.domain.usecases.UseCase
99
import co.nimblehq.template.compose.test.MockUtil
10-
import co.nimblehq.template.compose.ui.base.BaseDestination
1110
import co.nimblehq.template.compose.ui.screens.BaseScreenTest
1211
import co.nimblehq.template.compose.ui.screens.MainActivity
1312
import co.nimblehq.template.compose.ui.theme.ComposeTheme
@@ -16,7 +15,6 @@ import io.mockk.every
1615
import io.mockk.mockk
1716
import kotlinx.coroutines.flow.flow
1817
import kotlinx.coroutines.flow.flowOf
19-
import kotlinx.coroutines.test.*
2018
import org.junit.*
2119
import org.junit.runner.RunWith
2220
import org.robolectric.RobolectricTestRunner
@@ -31,7 +29,6 @@ class HomeScreenTest : BaseScreenTest() {
3129
private val mockUseCase: UseCase = mockk()
3230

3331
private lateinit var viewModel: HomeViewModel
34-
private var expectedDestination: BaseDestination? = null
3532

3633
@Before
3734
fun setUp() {
@@ -67,7 +64,6 @@ class HomeScreenTest : BaseScreenTest() {
6764
ComposeTheme {
6865
HomeScreen(
6966
viewModel = viewModel,
70-
navigator = { destination -> expectedDestination = destination }
7167
)
7268
}
7369
}

template-compose/gradle/libs.versions.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ accompanist = "0.30.1"
99
chucker = "4.0.0"
1010
composeBom = "2025.02.00"
1111
# @kaungkhantsoe Will update in a separate PR
12-
composeNavigation = "2.5.3"
12+
composeNavigation = "2.8.9"
1313
core = "1.15.0"
1414
datastore = "1.1.2"
1515
detekt = "1.21.0"
1616
gradle = "8.8.1"
17-
hilt = "2.52"
17+
hilt = "2.53"
1818
hiltNavigation = "1.2.0"
1919
javaxInject = "1"
2020
junit = "4.13.2"
@@ -24,7 +24,7 @@ kotlinxCoroutines = "1.7.3"
2424
kover = "0.7.3"
2525
ksp = "2.1.0-1.0.29"
2626
lifecycle = "2.8.7"
27-
mockk = "1.13.5"
27+
mockk = "1.13.17"
2828
moshi = "1.15.1"
2929
nimbleCommon = "0.1.2"
3030
okhttp = "4.12.0"

0 commit comments

Comments
 (0)