Skip to content

Commit d220269

Browse files
committed
[#610] [Part 1] Update navigation library and refactor to follow unidirectional data flow for sample compose
1 parent 55e73e6 commit d220269

File tree

20 files changed

+325
-158
lines changed

20 files changed

+325
-158
lines changed

sample-compose/app/build.gradle.kts

+4-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ android {
3636
targetSdk = libs.versions.androidTargetSdk.get().toInt()
3737
versionCode = libs.versions.androidVersionCode.get().toInt()
3838
versionName = libs.versions.androidVersionName.get()
39-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
39+
testInstrumentationRunner = "co.nimblehq.sample.compose.HiltTestRunner"
4040
}
4141

4242
buildTypes {
@@ -155,6 +155,9 @@ dependencies {
155155
androidTestImplementation(libs.test.compose.ui.junit4)
156156
androidTestImplementation(libs.test.rules)
157157
androidTestImplementation(libs.test.mockk.android)
158+
androidTestImplementation(libs.test.navigation)
159+
androidTestImplementation(libs.test.hilt.android)
160+
kspAndroidTest(libs.test.hilt.android.kotlin)
158161

159162
// Unable to resolve activity for Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
160163
// cmp=co.nimblehq.sample.compose/androidx.activity.ComponentActivity } --
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package co.nimblehq.sample.compose
2+
3+
import android.app.Application
4+
import android.content.Context
5+
import androidx.test.runner.AndroidJUnitRunner
6+
import dagger.hilt.android.testing.HiltTestApplication
7+
8+
class HiltTestRunner : AndroidJUnitRunner() {
9+
override fun newApplication(
10+
cl: ClassLoader?,
11+
className: String?,
12+
context: Context?
13+
): Application {
14+
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,45 @@
11
package co.nimblehq.sample.compose.ui.screens.main.home
22

33
import androidx.activity.compose.setContent
4+
import androidx.compose.ui.platform.LocalContext
45
import androidx.compose.ui.test.assertIsDisplayed
56
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
67
import androidx.compose.ui.test.junit4.createAndroidComposeRule
8+
import androidx.compose.ui.test.longClick
79
import androidx.compose.ui.test.onNodeWithText
810
import androidx.compose.ui.test.performClick
11+
import androidx.compose.ui.test.performTouchInput
12+
import androidx.navigation.compose.ComposeNavigator
13+
import androidx.navigation.testing.TestNavHostController
914
import androidx.test.ext.junit.rules.ActivityScenarioRule
1015
import androidx.test.rule.GrantPermissionRule
1116
import co.nimblehq.sample.compose.domain.usecases.GetModelsUseCase
1217
import co.nimblehq.sample.compose.domain.usecases.IsFirstTimeLaunchPreferencesUseCase
1318
import co.nimblehq.sample.compose.domain.usecases.UpdateFirstTimeLaunchPreferencesUseCase
1419
import co.nimblehq.sample.compose.test.MockUtil
1520
import co.nimblehq.sample.compose.test.TestDispatchersProvider
16-
import co.nimblehq.sample.compose.ui.base.BaseDestination
21+
import co.nimblehq.sample.compose.ui.AppNavGraph
1722
import co.nimblehq.sample.compose.ui.screens.MainActivity
1823
import co.nimblehq.sample.compose.ui.screens.main.MainDestination
1924
import co.nimblehq.sample.compose.ui.theme.ComposeTheme
25+
import dagger.hilt.android.testing.BindValue
26+
import dagger.hilt.android.testing.HiltAndroidRule
27+
import dagger.hilt.android.testing.HiltAndroidTest
2028
import io.mockk.every
2129
import io.mockk.mockk
2230
import kotlinx.coroutines.flow.flowOf
23-
import org.junit.Assert.assertEquals
2431
import org.junit.Before
2532
import org.junit.Rule
2633
import org.junit.Test
2734

35+
@HiltAndroidTest
2836
class HomeScreenTest {
37+
@get:Rule(order = 0)
38+
var hiltRule = HiltAndroidRule(this)
2939

30-
@get:Rule
40+
@get:Rule(order = 1)
3141
val composeRule = createAndroidComposeRule<MainActivity>()
42+
private lateinit var navController: TestNavHostController
3243

3344
/**
3445
* More test samples with Runtime Permissions https://alexzh.com/ui-testing-of-android-runtime-permissions/
@@ -38,24 +49,27 @@ class HomeScreenTest {
3849
android.Manifest.permission.CAMERA
3950
)
4051

41-
private val mockGetModelsUseCase: GetModelsUseCase = mockk()
42-
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk()
43-
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase = mockk()
52+
private val mockGetModelsUseCase: GetModelsUseCase = mockk(relaxed = true)
53+
private val mockIsFirstTimeLaunchPreferencesUseCase: IsFirstTimeLaunchPreferencesUseCase = mockk(relaxed = true)
54+
private val mockUpdateFirstTimeLaunchPreferencesUseCase: UpdateFirstTimeLaunchPreferencesUseCase =
55+
mockk(relaxed = true)
4456

45-
private lateinit var viewModel: HomeViewModel
46-
private var expectedDestination: BaseDestination? = null
57+
// Cannot mock viewModel with mockk here because it will throw ClassCastException
58+
// Ref: https://github.com/mockk/mockk/issues/321
59+
@BindValue
60+
val viewModel: HomeViewModel = HomeViewModel(
61+
mockGetModelsUseCase,
62+
mockIsFirstTimeLaunchPreferencesUseCase,
63+
mockUpdateFirstTimeLaunchPreferencesUseCase,
64+
TestDispatchersProvider
65+
)
4766

4867
@Before
4968
fun setUp() {
69+
hiltRule.inject()
70+
5071
every { mockGetModelsUseCase() } returns flowOf(MockUtil.models)
5172
every { mockIsFirstTimeLaunchPreferencesUseCase() } returns flowOf(false)
52-
53-
viewModel = HomeViewModel(
54-
mockGetModelsUseCase,
55-
mockIsFirstTimeLaunchPreferencesUseCase,
56-
mockUpdateFirstTimeLaunchPreferencesUseCase,
57-
TestDispatchersProvider
58-
)
5973
}
6074

6175
@Test
@@ -71,23 +85,52 @@ class HomeScreenTest {
7185
}
7286

7387
@Test
74-
fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposable {
88+
fun when_clicking_on_a_list_item__it_navigates_to_Second_screen() = initComposableNavigation {
7589
onNodeWithText("1").performClick()
7690

77-
assertEquals(expectedDestination, MainDestination.Second)
91+
onNodeWithText("Second").assertIsDisplayed()
92+
93+
navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Second.route, null)
94+
}
95+
96+
@Test
97+
fun when_long_clicking_on_a_list_item_and_click_edit__it_navigates_to_Third_screen() = initComposableNavigation {
98+
onNodeWithText("1").performTouchInput { longClick() }
99+
100+
onNodeWithText("Edit").performClick()
101+
102+
onNodeWithText("Third").assertIsDisplayed()
103+
104+
navController.currentBackStackEntry?.destination?.hasRoute(MainDestination.Third.route, null)
78105
}
79106

80107
private fun initComposable(
81108
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
82109
) {
83110
composeRule.activity.setContent {
111+
navController = TestNavHostController(LocalContext.current)
112+
navController.navigatorProvider.addNavigator(ComposeNavigator())
84113
ComposeTheme {
85114
HomeScreen(
86115
viewModel = viewModel,
87-
navigator = { destination -> expectedDestination = destination }
116+
onNavigateToSecondScreen = {},
117+
onNavigateToThirdScreen = {},
88118
)
89119
}
90120
}
91121
testBody(composeRule)
92122
}
123+
124+
private fun initComposableNavigation(
125+
testBody: AndroidComposeTestRule<ActivityScenarioRule<MainActivity>, MainActivity>.() -> Unit
126+
) {
127+
composeRule.activity.setContent {
128+
navController = TestNavHostController(LocalContext.current)
129+
navController.navigatorProvider.addNavigator(ComposeNavigator())
130+
ComposeTheme {
131+
AppNavGraph(navController)
132+
}
133+
}
134+
testBody(composeRule)
135+
}
93136
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package co.nimblehq.sample.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.NavHostController
13+
import androidx.navigation.NavOptionsBuilder
14+
import androidx.navigation.compose.composable
15+
import co.nimblehq.sample.compose.ui.base.BaseAppDestination
16+
17+
/**
18+
* Use this extension or [navigate(BaseAppDestination.Up())] to prevent duplicated navigation events
19+
*/
20+
fun NavHostController.navigateAppDestinationUp() {
21+
navigateTo(BaseAppDestination.Up())
22+
}
23+
24+
/**
25+
* TODO Create new class extend NavHostController then move the related codes to that class
26+
*/
27+
private const val IntervalInMillis: Long = 1000L
28+
private var lastNavigationEventExecutedTimeInMillis: Long = 0L
29+
30+
/**
31+
* Use this extension to prevent duplicated navigation events with the same destination in a short time
32+
*/
33+
private fun NavHostController.throttleNavigation(
34+
appDestination: BaseAppDestination,
35+
onNavigate: () -> Unit,
36+
) {
37+
val currentTime = System.currentTimeMillis()
38+
if (currentBackStackEntry?.destination?.route == appDestination.route
39+
&& (currentTime - lastNavigationEventExecutedTimeInMillis < IntervalInMillis)
40+
) {
41+
return
42+
}
43+
lastNavigationEventExecutedTimeInMillis = currentTime
44+
45+
onNavigate()
46+
}
47+
48+
/**
49+
* Navigate to provided [BaseAppDestination]
50+
* Caution to use this method. This method use savedStateHandle to store the Parcelable data.
51+
* When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data.
52+
* eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully.
53+
*/
54+
fun <T : BaseAppDestination> NavHostController.navigateTo(
55+
appDestination: T,
56+
builder: (NavOptionsBuilder.() -> Unit)? = null,
57+
) = throttleNavigation(appDestination) {
58+
when (appDestination) {
59+
is BaseAppDestination.Up -> {
60+
appDestination.results.forEach { (key, value) ->
61+
previousBackStackEntry?.savedStateHandle?.set(key, value)
62+
}
63+
navigateUp()
64+
}
65+
else -> {
66+
appDestination.parcelableArgument?.let { (key, value) ->
67+
currentBackStackEntry?.savedStateHandle?.set(key, value)
68+
}
69+
navigate(route = appDestination.destination) {
70+
if (builder != null) {
71+
builder()
72+
}
73+
}
74+
}
75+
}
76+
}
77+
78+
private const val NavAnimationDurationInMillis = 300
79+
80+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInLeftTransition() =
81+
slideIntoContainer(
82+
towards = AnimatedContentTransitionScope.SlideDirection.Start,
83+
animationSpec = tween(NavAnimationDurationInMillis)
84+
)
85+
86+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutLeftTransition() =
87+
slideOutOfContainer(
88+
towards = AnimatedContentTransitionScope.SlideDirection.Start,
89+
animationSpec = tween(NavAnimationDurationInMillis)
90+
)
91+
92+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInRightTransition() =
93+
slideIntoContainer(
94+
towards = AnimatedContentTransitionScope.SlideDirection.End,
95+
animationSpec = tween(NavAnimationDurationInMillis)
96+
)
97+
98+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutRightTransition() =
99+
slideOutOfContainer(
100+
towards = AnimatedContentTransitionScope.SlideDirection.End,
101+
animationSpec = tween(NavAnimationDurationInMillis)
102+
)
103+
104+
fun AnimatedContentTransitionScope<NavBackStackEntry>.enterSlideInUpTransition() =
105+
slideIntoContainer(
106+
towards = AnimatedContentTransitionScope.SlideDirection.Up,
107+
animationSpec = tween(NavAnimationDurationInMillis)
108+
)
109+
110+
fun AnimatedContentTransitionScope<NavBackStackEntry>.exitSlideOutDownTransition() =
111+
slideOutOfContainer(
112+
towards = AnimatedContentTransitionScope.SlideDirection.Down,
113+
animationSpec = tween(NavAnimationDurationInMillis)
114+
)
115+
116+
fun NavGraphBuilder.composable(
117+
destination: BaseAppDestination,
118+
deepLinks: List<NavDeepLink> = emptyList(),
119+
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
120+
enterSlideInLeftTransition()
121+
},
122+
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
123+
exitSlideOutLeftTransition()
124+
},
125+
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
126+
enterSlideInRightTransition()
127+
},
128+
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
129+
exitSlideOutRightTransition()
130+
},
131+
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
132+
) {
133+
composable(
134+
route = destination.route,
135+
arguments = destination.arguments,
136+
deepLinks = deepLinks,
137+
enterTransition = enterTransition,
138+
exitTransition = exitTransition,
139+
popEnterTransition = popEnterTransition,
140+
popExitTransition = popExitTransition,
141+
content = content
142+
)
143+
}
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
package co.nimblehq.sample.compose.ui
22

3-
import co.nimblehq.sample.compose.ui.base.BaseDestination
3+
import co.nimblehq.sample.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.sample.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.sample.compose.ui.base.BaseDestination
116
import co.nimblehq.sample.compose.ui.screens.main.mainNavGraph
127

138
@Composable
@@ -22,42 +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-
/**
43-
* Navigate to provided [BaseDestination] with a Pair of key value String and Data [parcel]
44-
* Caution to use this method. This method use savedStateHandle to store the Parcelable data.
45-
* When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data.
46-
* eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully.
47-
*/
48-
fun NavHostController.navigate(destination: BaseDestination, parcel: Pair<String, Any?>? = null) {
49-
when (destination) {
50-
is BaseDestination.Up -> {
51-
destination.results.forEach { (key, value) ->
52-
previousBackStackEntry?.savedStateHandle?.set(key, value)
53-
}
54-
navigateUp()
55-
}
56-
else -> {
57-
parcel?.let { (key, value) ->
58-
currentBackStackEntry?.savedStateHandle?.set(key, value)
59-
}
60-
navigate(route = destination.destination)
61-
}
62-
}
63-
}

0 commit comments

Comments
 (0)