Jetpack Compose 네비게이션

이윤설·2025년 2월 27일
0

안드로이드 연구소

목록 보기
25/33

Navigation Graph는 앱 내의 화면들(destinations)과 그들 간의 이동 경로(actions)를 정의하는 구조다. 기존 XML 기반 Navigation과 달리 Compose에서는 코드로 Navigation Graph를 정의한다.

기본 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의 핵심 구성요소:

  • NavHost: Navigation Graph의 컨테이너 역할
  • NavController: 네비게이션 상태를 관리하고 화면 전환을 처리
  • startDestination: 앱이 처음 시작될 때 표시되는 화면

중첩 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: 앱의 화면 정의하기

Destinations은 앱 내에서 사용자가 탐색할 수 있는 개별 화면들을 의미한다.
Compose에서는 composable() 함수를 사용하여 destination을 정의한다.

기본 Destination 정의

NavHost(navController = navController, startDestination = "home") {
    composable("home") { 
        HomeScreen(navController = navController) 
    }
}

파라미터가 있는 Destination

실제 앱에서는 화면 간 데이터 전달이 필요한 경우가 많다:

// 경로에 파라미터 포함
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)
}

타입 안전한 Destination 접근

문자열 기반 라우팅은 오타 위험이 있다. 이를 해결하기 위한 접근법은 두 가지가 있다:

  1. 경로 상수와 헬퍼 함수 사용:
// 경로 상수 정의
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"
}
  1. 직렬화 가능한 데이터 클래스 사용 (권장):
// 각 경로를 데이터 클래스로 정의
@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로 어디서든 이동하기

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로 딥링크 생성하기

앱 내에서 알림 등을 통해 딥링크를 생성할 때 다음과 같이 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()

AndroidManifest.xml 설정

딥링크가 작동하려면 매니페스트 파일에 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)
}

SavedStateHandle을 활용한 인자 검색

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에서 데이터 계층으로부터 필요한 정보를 로드할 수 있다.

profile
화려한 외면이 아닌 단단한 내면

0개의 댓글

관련 채용 정보