Navigation Graph는 앱 내의 화면들(destinations)과 그들 간의 이동 경로(actions)를 정의하는 구조다. 기존 XML 기반 Navigation과 달리 Compose에서는 코드로 Navigation Graph를 정의한다.
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
startDestination: String = "home"
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
// 여기서 destinations과 actions을 정의한다
composable("home") { HomeScreen(navController) }
composable("profile") { ProfileScreen(navController) }
composable("settings") { SettingsScreen(navController) }
}
}
Navigation Graph의 핵심 구성요소:
복잡한 앱에서는 기능별로 Navigation Graph를 분리하여 관리할 수 있다:
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = "main") {
// 메인 그래프
composable("main") { MainScreen(navController) }
// 인증 관련 중첩 그래프
navigation(startDestination = "login", route = "auth") {
composable("login") { LoginScreen(navController) }
composable("register") { RegisterScreen(navController) }
composable("forgot_password") { ForgotPasswordScreen(navController) }
}
// 유저 관련 중첩 그래프
navigation(startDestination = "user_details", route = "user") {
composable("user_details") { UserDetailsScreen(navController) }
composable("edit_profile") { EditProfileScreen(navController) }
}
}
}
중첩 Navigation Graph를 사용할 경우 다음과 같은 이점을 누릴 수 있다.
Destinations은 앱 내에서 사용자가 탐색할 수 있는 개별 화면들을 의미한다.
Compose에서는 composable()
함수를 사용하여 destination을 정의한다.
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(navController = navController)
}
}
실제 앱에서는 화면 간 데이터 전달이 필요한 경우가 많다:
// 경로에 파라미터 포함
composable(
route = "restaurant/{restaurantId}",
arguments = listOf(navArgument("restaurantId") { type = NavType.StringType })
) { backStackEntry ->
val restaurantId = backStackEntry.arguments?.getString("restaurantId") ?: ""
RestaurantDetailScreen(restaurantId = restaurantId, navController = navController)
}
// 선택적 파라미터 사용
composable(
route = "search?cuisine={cuisine}",
arguments = listOf(
navArgument("cuisine") {
type = NavType.StringType
defaultValue = ""
nullable = true
}
)
) { backStackEntry ->
val cuisine = backStackEntry.arguments?.getString("cuisine") ?: ""
SearchResultScreen(cuisine = cuisine, navController = navController)
}
문자열 기반 라우팅은 오타 위험이 있다. 이를 해결하기 위한 접근법은 두 가지가 있다:
// 경로 상수 정의
object NavDestinations {
const val HOME = "home"
const val RESTAURANT_DETAILS = "restaurant/{restaurantId}"
const val SEARCH = "search?cuisine={cuisine}"
// 파라미터를 포함한 경로 생성 함수
fun restaurantDetailsRoute(restaurantId: String): String =
"restaurant/$restaurantId"
fun searchRoute(cuisine: String = ""): String =
"search?cuisine=$cuisine"
}
// 각 경로를 데이터 클래스로 정의
@Serializable
data class Home(val dummy: String = "")
@Serializable
data class RestaurantDetails(val restaurantId: String)
@Serializable
data class SearchResults(val cuisine: String = "")
// 사용 예시
composable<RestaurantDetails> { backStackEntry ->
val route = backStackEntry.toRoute<RestaurantDetails>()
RestaurantDetailScreen(
restaurantId = route.restaurantId,
navController = navController
)
}
// 네비게이션 호출
navController.navigate(RestaurantDetails(restaurantId = "123"))
직렬화 가능한 데이터 클래스를 사용하면 컴파일 타임에 타입 안전성을 보장하고, 경로 구성이 더 명확해진다.
Global Actions는 앱 내 어느 화면에서든 특정 목적지로 이동할 수 있게 해주는 기능이다. 예를 들어, 에러 발생 시 로그인 화면으로 보내거나 예약 완료 후 확인 화면으로 이동하는 경우에 유용하다.
Compose Navigation에서는 XML과 달리 별도의 액션 정의 대신 NavController를 통해 직접 구현한다:
class NavigationActions(private val navController: NavController) {
// 홈으로 이동 (백스택 클리어)
fun navigateToHome() {
navController.navigate(NavDestinations.HOME) {
popUpTo(navController.graph.id) {
inclusive = true
}
}
}
// 로그인으로 이동 (현재 화면만 백스택에서 제거)
fun navigateToLogin() {
navController.navigate(NavDestinations.LOGIN) {
popUpTo(navController.currentDestination?.id ?: return) {
inclusive = true
}
}
}
// 예약 완료 후 확인 화면으로 이동
fun navigateToReservationConfirmation(reservationId: String) {
navController.navigate(NavDestinations.reservationConfirmationRoute(reservationId)) {
popUpTo(NavDestinations.RESTAURANT_DETAILS) {
inclusive = true
}
}
}
}
이를 활용하는 방법:
@Composable
fun MainApp() {
val navController = rememberNavController()
val navigationActions = remember(navController) {
NavigationActions(navController)
}
// 액션 전달
AppNavHost(
navController = navController,
navigationActions = navigationActions
)
}
// 사용 예제
@Composable
fun ReservationScreen(
navigationActions: NavigationActions,
/* 기타 매개변수 */
) {
Button(onClick = {
// 예약 처리 후
navigationActions.navigateToReservationConfirmation("12345")
}) {
Text("예약하기")
}
}
Deeplink는 앱 외부에서 앱 내 특정 화면으로 직접 접근할 수 있게 해주는 기능이다.
이메일, 웹사이트, 푸시 알림 등에서 사용자를 앱의 특정 화면으로 직접 안내할 수 있다.
Compose Navigation에서는 composable()
함수 내에서 deepLinks
파라미터를 통해 딥링크를 정의한다:
문자열 기반 접근법:
composable(
route = "restaurant/{restaurantId}",
arguments = listOf(navArgument("restaurantId") { type = NavType.StringType }),
deepLinks = listOf(
navDeepLink {
uriPattern = "https://myrestaurantapp.com/restaurant/{restaurantId}"
action = Intent.ACTION_VIEW
},
navDeepLink {
uriPattern = "restaurantapp://restaurant/{restaurantId}"
}
)
) { backStackEntry ->
val restaurantId = backStackEntry.arguments?.getString("restaurantId") ?: ""
RestaurantDetailScreen(restaurantId = restaurantId, navController = navController)
}
타입 안전 접근법 (권장):
@Serializable
data class RestaurantDetails(val restaurantId: String)
val uri = "https://myrestaurantapp.com"
composable<RestaurantDetails>(
deepLinks = listOf(
navDeepLink<RestaurantDetails>(basePath = "$uri/restaurant")
)
) { backStackEntry ->
val route = backStackEntry.toRoute<RestaurantDetails>()
RestaurantDetailScreen(
restaurantId = route.restaurantId,
// 내비게이션 콜백 전달
onNavigateToReservation = { restaurantId ->
navController.navigate(ReservationForm(restaurantId = restaurantId))
}
)
}
앱 내에서 알림 등을 통해 딥링크를 생성할 때 다음과 같이 PendingIntent를 사용할 수 있다:
val restaurantId = "123456"
val context = LocalContext.current
val deepLinkIntent = Intent(
Intent.ACTION_VIEW,
"https://myrestaurantapp.com/restaurant/$restaurantId".toUri(),
context,
MainActivity::class.java
)
val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(deepLinkIntent)
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}
// 알림에서 사용하는 예
NotificationCompat.Builder(context, channelId)
.setContentTitle("예약 확인")
.setContentText("음식점 상세 정보를 확인해보세요.")
.setContentIntent(deepLinkPendingIntent)
.build()
딥링크가 작동하려면 매니페스트 파일에 Intent 필터를 추가해야 한다:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- 웹 URL 딥링크 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="myrestaurantapp.com" />
</intent-filter>
<!-- 커스텀 스킴 딥링크 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="restaurantapp" />
</intent-filter>
</activity>
딥링크를 통해 앱이 실행될 때 데이터를 처리하는 방법:
@Composable
fun MainActivity() {
val navController = rememberNavController()
// 딥링크로 전달된 Intent 처리
LaunchedEffect(Unit) {
val navBackStackEntry = navController.currentBackStackEntry
val savedStateHandle = navBackStackEntry?.savedStateHandle
savedStateHandle?.get<String>("deeplink_path")?.let { path ->
// 딥링크 경로에 따른 추가 처리
}
}
AppNavHost(navController = navController)
}
ViewModel에서 내비게이션 인자를 안전하게 접근하기 위해 SavedStateHandle을 활용할 수 있다:
class RestaurantViewModel(
savedStateHandle: SavedStateHandle,
private val restaurantRepository: RestaurantRepository
) : ViewModel() {
// 문자열 기반 접근법
private val restaurantId: String = checkNotNull(savedStateHandle["restaurantId"])
// 또는 타입 안전 접근법 (권장)
private val route = savedStateHandle.toRoute<RestaurantDetails>()
// 레포지토리에서 데이터 로드
val restaurantInfo: StateFlow<Restaurant> = restaurantRepository
.getRestaurantInfo(route.restaurantId)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
Restaurant.Empty
)
}
이렇게 하면 복잡한 객체를 직접 전달하는 대신 최소한의 식별자만 내비게이션 인자로 전달하고, ViewModel에서 데이터 계층으로부터 필요한 정보를 로드할 수 있다.