[Android] Type-Safe Compose Navigation 적용 - 중첩(Nested) 네비게이션 구조

이지훈·2024년 8월 22일
0
post-thumbnail
post-custom-banner

서두

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

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

4개의 댓글

comment-user-thumbnail
2024년 8월 22일

(ノω<。)ノ))☆.。

답글 달기
comment-user-thumbnail
2024년 10월 11일

지훈님 안녕하세요! 글 잘봤습니다~~ 예시로 작성해주신 Booth 관련된 코드들은 혹시 비공개이신가요?!

2개의 답글