Skip to content

Commit b3efc02

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

File tree

21 files changed

+329
-158
lines changed

21 files changed

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

0 commit comments

Comments
 (0)