From 3811dfe662b0a48520bac2d22198c8a0d9d8b6d0 Mon Sep 17 00:00:00 2001 From: Kaung Khant Soe Date: Fri, 2 May 2025 18:23:45 +0700 Subject: [PATCH] [#610] [Part 2] Update navigation library and refactor to follow unidirectional data flow for template compose --- .../extensions/NavHostControllerExt.kt | 65 +++++++++++++++ .../compose/extensions/NavigationExt.kt | 81 +++++++++++++++++++ .../template/compose/ui/AppDestination.kt | 6 +- .../template/compose/ui/AppNavGraph.kt | 33 -------- .../compose/ui/base/BaseAppDestination.kt | 32 ++++++++ .../compose/ui/base/BaseDestination.kt | 19 ----- .../template/compose/ui/base/BaseViewModel.kt | 3 - .../ui/screens/main/MainDestination.kt | 4 +- .../compose/ui/screens/main/MainNavGraph.kt | 7 +- .../ui/screens/main/home/HomeScreen.kt | 3 - .../ui/screens/main/home/HomeScreenTest.kt | 4 - template-compose/gradle/libs.versions.toml | 6 +- 12 files changed, 188 insertions(+), 75 deletions(-) create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavHostControllerExt.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavigationExt.kt create mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseAppDestination.kt delete mode 100644 template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavHostControllerExt.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavHostControllerExt.kt new file mode 100644 index 000000000..b22cff343 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavHostControllerExt.kt @@ -0,0 +1,65 @@ +package co.nimblehq.template.compose.extensions + +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import co.nimblehq.template.compose.ui.base.BaseAppDestination +import kotlin.collections.component1 +import kotlin.collections.component2 + +/** + * Use this extension or [navigate(BaseAppDestination.Up())] to prevent duplicated navigation events + */ +fun NavHostController.navigateAppDestinationUp() { + navigateTo(BaseAppDestination.Up()) +} + +private const val IntervalInMillis: Long = 1000L +private var lastNavigationEventExecutedTimeInMillis: Long = 0L + +/** + * Use this extension to prevent duplicated navigation events with the same destination in a short time + */ +private fun NavHostController.throttleNavigation( + appDestination: BaseAppDestination, + onNavigate: () -> Unit, +) { + val currentTime = System.currentTimeMillis() + if (currentBackStackEntry?.destination?.route == appDestination.route + && (currentTime - lastNavigationEventExecutedTimeInMillis < IntervalInMillis) + ) { + return + } + lastNavigationEventExecutedTimeInMillis = currentTime + + onNavigate() +} + +/** + * Navigate to provided [BaseAppDestination] + * Caution to use this method. This method use savedStateHandle to store the Parcelable data. + * When previousBackstackEntry is popped out from navigation stack, savedStateHandle will return null and cannot retrieve data. + * eg.Login -> Home, the Login screen will be popped from the back-stack on logging in successfully. + */ +fun NavHostController.navigateTo( + appDestination: T, + builder: (NavOptionsBuilder.() -> Unit)? = null, +) = throttleNavigation(appDestination) { + when (appDestination) { + is BaseAppDestination.Up -> { + appDestination.results.forEach { (key, value) -> + previousBackStackEntry?.savedStateHandle?.set(key, value) + } + navigateUp() + } + else -> { + appDestination.parcelableArgument?.let { (key, value) -> + currentBackStackEntry?.savedStateHandle?.set(key, value) + } + navigate(route = appDestination.destination) { + if (builder != null) { + builder() + } + } + } + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavigationExt.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavigationExt.kt new file mode 100644 index 000000000..59934def1 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/NavigationExt.kt @@ -0,0 +1,81 @@ +package co.nimblehq.template.compose.extensions + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import co.nimblehq.template.compose.ui.base.BaseAppDestination + +private const val NavAnimationDurationInMillis = 300 + +fun AnimatedContentTransitionScope.enterSlideInLeftTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutLeftTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.enterSlideInRightTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutRightTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.enterSlideInUpTransition() = + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun AnimatedContentTransitionScope.exitSlideOutDownTransition() = + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(NavAnimationDurationInMillis) + ) + +fun NavGraphBuilder.composable( + destination: BaseAppDestination, + deepLinks: List = emptyList(), + enterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = { + enterSlideInLeftTransition() + }, + exitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = { + exitSlideOutLeftTransition() + }, + popEnterTransition: (AnimatedContentTransitionScope.() -> EnterTransition?)? = { + enterSlideInRightTransition() + }, + popExitTransition: (AnimatedContentTransitionScope.() -> ExitTransition?)? = { + exitSlideOutRightTransition() + }, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + composable( + route = destination.route, + arguments = destination.arguments, + deepLinks = deepLinks, + enterTransition = enterTransition, + exitTransition = exitTransition, + popEnterTransition = popEnterTransition, + popExitTransition = popExitTransition, + content = content + ) +} + diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt index be93bcfd7..0bb226d83 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt @@ -1,10 +1,10 @@ package co.nimblehq.template.compose.ui -import co.nimblehq.template.compose.ui.base.BaseDestination +import co.nimblehq.template.compose.ui.base.BaseAppDestination sealed class AppDestination { - object RootNavGraph : BaseDestination("rootNavGraph") + object RootNavGraph : BaseAppDestination("rootNavGraph") - object MainNavGraph : BaseDestination("mainNavGraph") + object MainNavGraph : BaseAppDestination("mainNavGraph") } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt index e2c662a68..7d0dbcd7e 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt @@ -1,13 +1,8 @@ package co.nimblehq.template.compose.ui import androidx.compose.runtime.Composable -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.navDeepLink -import co.nimblehq.template.compose.ui.base.BaseDestination import co.nimblehq.template.compose.ui.screens.main.mainNavGraph @Composable @@ -22,31 +17,3 @@ fun AppNavGraph( mainNavGraph(navController = navController) } } - -fun NavGraphBuilder.composable( - destination: BaseDestination, - content: @Composable (NavBackStackEntry) -> Unit, -) { - composable( - route = destination.route, - arguments = destination.arguments, - deepLinks = destination.deepLinks.map { - navDeepLink { - uriPattern = it - } - }, - content = content - ) -} - -fun NavHostController.navigate(destination: BaseDestination) { - when (destination) { - is BaseDestination.Up -> { - destination.results.forEach { (key, value) -> - previousBackStackEntry?.savedStateHandle?.set(key, value) - } - navigateUp() - } - else -> navigate(route = destination.destination) - } -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseAppDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseAppDestination.kt new file mode 100644 index 000000000..7a42c89ea --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseAppDestination.kt @@ -0,0 +1,32 @@ +package co.nimblehq.template.compose.ui.base + +import androidx.navigation.NamedNavArgument + +const val KeyResultOk = "keyResultOk" + +/** + * Use "class" over "object" for destinations with [parcelableArgument] usage or a [navArgument] with [defaultValue] set + * to reset destination nav arguments. + */ +abstract class BaseAppDestination(val route: String = "") { + + open val arguments: List = emptyList() + + open val deepLinks: List = listOf( + "https://android.nimblehq.co/$route", + "android://$route", + ) + + open var destination: String = route + + open var parcelableArgument: Pair? = null + + data class Up( + val results: HashMap = hashMapOf(), + ) : BaseAppDestination() { + + fun put(key: String, value: Any) = apply { + results[key] = value + } + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt deleted file mode 100644 index 9efb868b2..000000000 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.nimblehq.template.compose.ui.base - -import androidx.navigation.NamedNavArgument - -abstract class BaseDestination(val route: String = "") { - - open val arguments: List = emptyList() - - open val deepLinks: List = emptyList() - - open var destination: String = route - - data class Up(val results: HashMap = hashMapOf()) : BaseDestination() { - - fun addResult(key: String, value: Any) = apply { - results[key] = value - } - } -} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt index 44b27a425..76fc1f058 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt @@ -18,9 +18,6 @@ abstract class BaseViewModel : ViewModel() { protected val _error = MutableSharedFlow() val error = _error.asSharedFlow() - protected val _navigator = MutableSharedFlow() - val navigator = _navigator.asSharedFlow() - /** * To show loading manually, should call `hideLoading` after */ diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt index 038759363..c92364859 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt @@ -1,8 +1,8 @@ package co.nimblehq.template.compose.ui.screens.main -import co.nimblehq.template.compose.ui.base.BaseDestination +import co.nimblehq.template.compose.ui.base.BaseAppDestination sealed class MainDestination { - object Home : BaseDestination("home") + object Home : BaseAppDestination("home") } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt index b6230c0dd..a0694b402 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt @@ -3,9 +3,8 @@ package co.nimblehq.template.compose.ui.screens.main import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.navigation +import co.nimblehq.template.compose.extensions.composable import co.nimblehq.template.compose.ui.AppDestination -import co.nimblehq.template.compose.ui.composable -import co.nimblehq.template.compose.ui.navigate import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen fun NavGraphBuilder.mainNavGraph( @@ -16,9 +15,7 @@ fun NavGraphBuilder.mainNavGraph( startDestination = MainDestination.Home.destination ) { composable(MainDestination.Home) { - HomeScreen( - navigator = { destination -> navController.navigate(destination) } - ) + HomeScreen() } } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt index a953e0220..b93874fb4 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt @@ -17,7 +17,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.nimblehq.template.compose.R import co.nimblehq.template.compose.extensions.collectAsEffect -import co.nimblehq.template.compose.ui.base.BaseDestination import co.nimblehq.template.compose.ui.base.BaseScreen import co.nimblehq.template.compose.ui.models.UiModel import co.nimblehq.template.compose.ui.showToast @@ -28,11 +27,9 @@ import timber.log.Timber @Composable fun HomeScreen( viewModel: HomeViewModel = hiltViewModel(), - navigator: (destination: BaseDestination) -> Unit, ) = BaseScreen { val context = LocalContext.current viewModel.error.collectAsEffect { e -> e.showToast(context) } - viewModel.navigator.collectAsEffect { destination -> navigator(destination) } val uiModels: List by viewModel.uiModels.collectAsStateWithLifecycle() diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt index e3a2d12d2..e8c56d259 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt @@ -7,7 +7,6 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import co.nimblehq.template.compose.R import co.nimblehq.template.compose.domain.usecases.UseCase import co.nimblehq.template.compose.test.MockUtil -import co.nimblehq.template.compose.ui.base.BaseDestination import co.nimblehq.template.compose.ui.screens.BaseScreenTest import co.nimblehq.template.compose.ui.screens.MainActivity import co.nimblehq.template.compose.ui.theme.ComposeTheme @@ -16,7 +15,6 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.* import org.junit.* import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -31,7 +29,6 @@ class HomeScreenTest : BaseScreenTest() { private val mockUseCase: UseCase = mockk() private lateinit var viewModel: HomeViewModel - private var expectedDestination: BaseDestination? = null @Before fun setUp() { @@ -67,7 +64,6 @@ class HomeScreenTest : BaseScreenTest() { ComposeTheme { HomeScreen( viewModel = viewModel, - navigator = { destination -> expectedDestination = destination } ) } } diff --git a/template-compose/gradle/libs.versions.toml b/template-compose/gradle/libs.versions.toml index 13fb434b1..4a4784691 100644 --- a/template-compose/gradle/libs.versions.toml +++ b/template-compose/gradle/libs.versions.toml @@ -9,12 +9,12 @@ accompanist = "0.30.1" chucker = "4.0.0" composeBom = "2025.02.00" # @kaungkhantsoe Will update in a separate PR -composeNavigation = "2.5.3" +composeNavigation = "2.8.9" core = "1.15.0" datastore = "1.1.2" detekt = "1.21.0" gradle = "8.8.1" -hilt = "2.52" +hilt = "2.53" hiltNavigation = "1.2.0" javaxInject = "1" junit = "4.13.2" @@ -24,7 +24,7 @@ kotlinxCoroutines = "1.7.3" kover = "0.7.3" ksp = "2.1.0-1.0.29" lifecycle = "2.8.7" -mockk = "1.13.5" +mockk = "1.13.17" moshi = "1.15.1" nimbleCommon = "0.1.2" okhttp = "4.12.0"