Type-Safe Compose Navigation 이 androidx-navigation 2.8.0-alpha08 버전에 드디어 등장 하게되었다.
따라서 이를 최대한 빨리 적용해보려고 하였으나, alpha 단계 였던 관계로(글을 쓰는 시점은 2.8.0-rc01) 공식문서 및 타 레퍼런스를 찾아봐도, 다양한 상황에 대한 적용 방법(Bottom Navigation 과 연계, Nested Navigation)이 소개되어 있지 않아, 적용을 미루고 있었다.
이후 시간이 지나고, 여러 레퍼런스들이 등장하여, 적용 방법을 확인할 수 있었고,
이번글에서는 Type-Safe Compose Navigation 을 중첩(Nested) 네비게이션 구조에서 적용하는 방법을 알아보도록 하겠다.
전반적인 적용 방법과, Bottom Navigation 과 Navigation 의 연계는 DroidKnights 레포지토리에 이미 적용이 되어 있어, 해당 레포의 코드를 확인해보는 것이 더 나은 방법일 것 같아, 이번 글에서는 다루지 않도록 하겠다.
DroidKnights 레포지토리
DroidKnights Type-Safe Compose Navigation Apply PR
우선 적용을 위해 작성한 코드는 다음과 같다.
import kotlinx.serialization.Serializable
sealed interface MainTabRoute : Route {
@Serializable
data object Home : MainTabRoute
@Serializable
data object Map : MainTabRoute
@Serializable
data object Waiting : MainTabRoute
@Serializable
data object Menu : MainTabRoute
}
sealed interface Route {
// 이번 글에서 다루는 중첩 네비게이션
@Serializable
data object Booth {
@Serializable
data class BoothDetail(val boothId: Long) : Route
@Serializable
data object BoothLocation : Route
}
@Serializable
data object LikeBooth : Route
}
각각의 Route 들을 object, 혹은 data class 로 각각 선언해도 되지만, MainTab 내에 포함된 Route, 그 외에 Route 들을 묶어 가독성을 높이기 위해 sealed interface 를 사용하였다.
(feature 별로 모듈을 분리하였다면, core 모듈내에 navigation 모듈을 생성하여, 해당 모듈을 feature 모듈에서 참조하는 식으로 구현해주면 될 것이다.)
다음은 네비게이션 쪽이다.
fun NavController.navigateToBoothDetail(
boothId: Long,
) {
navigate(Route.Booth.BoothDetail(boothId))
}
fun NavController.navigateToBoothLocation() {
navigate(Route.Booth.BoothLocation)
}
fun NavGraphBuilder.boothNavGraph(
padding: PaddingValues,
navController: NavHostController,
popBackStack: () -> Unit,
navigateToBoothLocation: () -> Unit,
) {
navigation<Route.Booth>(
startDestination = Route.Booth.BoothDetail::class,
) {
composable<Route.Booth.BoothDetail> { navBackStackEntry ->
val viewModel = navBackStackEntry.sharedViewModel<BoothViewModel>(navController)
BoothDetailRoute(
padding = padding,
onBackClick = popBackStack,
navigateToBoothLocation = navigateToBoothLocation,
viewModel = viewModel,
)
}
composable<Route.Booth.BoothLocation> { navBackStackEntry ->
val viewModel = navBackStackEntry.sharedViewModel<BoothViewModel>(navController)
BoothLocationRoute(
onBackClick = popBackStack,
viewModel = viewModel,
)
}
}
}
하단의 기존 코드와 비교해봤을 때, 하드코딩된 문자열 Route 를 쓰지않고, Route sealed interface 내에 각각의 화면 Tab 을 지정하는 방식으로 변경하여, 휴먼 에러를 방지할 수 있고, 기존의 신경 써줘야 했던 부분 들이 많이 해결된 것을 확인 할 수 있다.
기존에 신경 써줘야 했던 부분들 중
대표적으로 argument 로 url 을 던져줘야 하는 케이스
const val BOOTH_ID = "booth_id"
const val BOOTH_ROUTE = "booth_route/{$BOOTH_ID}"
const val BOOTH_DETAIL_ROUTE = "booth_detail_route"
const val BOOTH_LOCATION_ROUTE = "booth_location_route"
fun NavController.navigateToBoothDetail(
boothId: Long,
) {
navigate("booth_route/$boothId")
}
fun NavController.navigateToBoothLocation() {
navigate(BOOTH_LOCATION_ROUTE)
}
fun NavGraphBuilder.boothNavGraph(
padding: PaddingValues,
navController: NavHostController,
popBackStack: () -> Unit,
navigateToBoothLocation: () -> Unit,
) {
navigation(
startDestination = BOOTH_DETAIL_ROUTE,
route = BOOTH_ROUTE,
arguments = listOf(
navArgument(BOOTH_ID) {
type = NavType.LongType
},
),
) {
composable(route = BOOTH_DETAIL_ROUTE) { entry ->
val viewModel = entry.sharedViewModel<BoothViewModel>(navController)
BoothDetailRoute(
padding = padding,
onBackClick = popBackStack,
navigateToBoothLocation = navigateToBoothLocation,
viewModel = viewModel,
)
}
composable(route = BOOTH_LOCATION_ROUTE) { entry ->
val viewModel = entry.sharedViewModel<BoothViewModel>(navController)
BoothLocationRoute(
onBackClick = popBackStack,
viewModel = viewModel,
)
}
}
}
Argument 를 전달하는 케이스 같은 경우 에는
기존의 방식으론 불가능 했던(가능하지만, 우회하는 방식을 통해 가능했던) primitive type 이 아닌, custom type 을 전달하는 경우도, 비교적 쉽게 구현이 가능하게 되었다.
Navigation 또는 intent 로 argument 를 전달하는 경우, 전달 받는 화면의 viewModel 내에 savedStateHandle 를 통해 화면 단으로 전달받지 않고, 바로 뷰모델로 전달받을 수 있다.
전달 받은 argument 를 통해 viewModel 의 init 블럭에서 API 를 호출하는 식으로 보통 상세 화면을 구현하게 되는데, 화면 단으로 받게 되면, 뷰모델로 내에 변수로 저장해줘야 하는 작업이 수반되기 때문에 위의 방식을 선호하고 있다. (화면내에서 직접 API 를 호출하는 방식으로 변경하는 방법도 고려할 수 있겠다.)
해당 방식을 프로젝트에 적용하고 있었기 때문에, 이를 Type-Safe Compose Navigation 에서는 어떻게 적용해야 하는지 검색을 해보았으나, 아직 공식 문서나 기술 블로그 등의 레퍼런스에서는 해당 내용을 다룬 글을 찾을 수 없었다.
// navigation 파일 내에서 정의한 상수들
const val BOOTH_ID = "booth_id"
const val BOOTH_ROUTE = "booth_route/{$BOOTH_ID}"
const val BOOTH_DETAIL_ROUTE = "booth_detail_route"
const val BOOTH_LOCATION_ROUTE = "booth_location_route"
// 전달 받는 화면의 뷰모델
import com.unifest.android.feature.booth.navigation.BOOTH_ID
@HiltViewModel
class BoothViewModel @Inject constructor(
private val boothRepository: BoothRepository,
private val likedBoothRepository: LikedBoothRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel(), ErrorHandlerActions {
private val boothId: Long = requireNotNull(savedStateHandle.get<Long>(BOOTH_ID)) { "boothId is required." }
기존에는 다음과 같이 navigation 파일 내에서 사용하는 상수들을 가져와서 이를 bundle 형태로 data 를 저장하는 savedStateHandle 의 Key 로 사용 했었기에, 해당 상수들을 전부 사용하지 않게 되어, key 값으로 어떤 값을 지정 해줘야 하는지를 알 수 없는 문제가 발생하였다.
결론부터 말하면, Route sealed interface 내에 data class 의 property 명을 그대로 key 로 받으면 전달받는 것이 가능하였다.
이전에
sealed interface Route {
// 이번 글에서 다루는 중첩 네비게이션
@Serializable
data object Booth {
@Serializable
data class BoothDetail(val boothId: Long) : Route
@Serializable
data object BoothLocation : Route
}
@Serializable
data object LikeBooth : Route
}
BoothDetail data class 의 property 명을 'boothId' 를 지정하였기 때문에, 이를 그대로 savedStateHandle 의 key 로 지정하면 되지 않나 추측을 해보았는데,
@HiltViewModel
class BoothViewModel @Inject constructor(
private val boothRepository: BoothRepository,
private val likedBoothRepository: LikedBoothRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel(), ErrorHandlerActions {
companion object {
private const val BOOTH_ID = "boothId"
}
private val boothId: Long = requireNotNull(savedStateHandle.get<Long>(BOOTH_ID)) { "boothId is required." }
해당 추측은 유효하였고, 성공적으로 boothId 를 전달 받을 수 있었다.
Type-Safe 한 방식으로 Compose 환경에서 중첩 네비게이션을 구현하고, argument 를 주고 받는 방법을 학습할 수 있었다.
참고)
https://developer.android.com/guide/navigation/design/type-safety
https://github.com/droidknights/DroidKnightsApp/pull/301
https://medium.com/@poulastaadas2/type-safe-nested-navigation-in-jetpack-compose-c8a5bdd2d17d
https://medium.com/androiddevelopers/navigation-compose-meet-type-safety-e081fb3cf2f8
https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-savedstate
(ノω<。)ノ))☆.。