Compose를 통해 이동

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

Navigation 구성요소Jetpack Compose 애플리케이션을 지원합니다. Navigation 구성요소의 인프라와 기능을 활용하면서 컴포저블 간에 이동할 수 있습니다.

설정

Compose를 지원하려면 앱 모듈의 build.gradle 파일에서 다음 종속 항목을 사용하세요.

Groovy

dependencies {
    def nav_version = "2.5.2"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    def nav_version = "2.5.2"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

시작하기

NavController는 Navigation 구성요소의 중심 API로, 스테이트풀(Stateful)이며 앱의 화면과 각 화면 상태를 구성하는 컴포저블의 백 스택을 추적합니다.

컴포저블에서 rememberNavController() 메서드를 사용하여 NavController를 만들 수 있습니다.

val navController = rememberNavController()

컴포저블 계층 구조에서 NavController를 만드는 위치는 이를 참조해야 하는 모든 컴포저블이 액세스할 수 있는 곳이어야 합니다. 이는 상태 호이스팅의 원칙을 준수하며, 이렇게 하면 NavController와 상태를 사용할 수 있습니다. 상태는 currentBackStackEntryAsState()를 통해 제공되며 화면 외부에서 컴포저블을 업데이트하기 위한 정보 소스로 사용됩니다. 이 기능의 예를 보려면 하단 탐색 메뉴와 통합을 참고하세요.

NavHost 만들기

NavController를 단일 NavHost 컴포저블과 연결해야 합니다. NavHost는 구성 가능한 대상을 지정하는 탐색 그래프와 NavController를 연결합니다. 구성 가능한 대상은 컴포저블 간에 이동할 수 있어야 합니다. 여러 컴포저블 간에 이동하는 과정에서 NavHost의 콘텐츠가 자동으로 재구성됩니다. 탐색 그래프의 구성 가능한 대상은 각각 경로와 연결됩니다.

NavHost를 만들려면 이전에 rememberNavController()를 통해 만든 NavController뿐만 아니라 그래프의 시작 대상 경로도 필요합니다. NavHost를 만드는 데는 탐색 그래프를 생성하는 Navigation Kotlin DSL의 람다 구문이 사용됩니다. composable() 메서드를 사용하여 탐색 구조에 추가할 수 있습니다. 이 메서드를 사용하려면 경로뿐만 아니라 대상에 연결해야 할 컴포저블도 제공해야 합니다.

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

탐색 그래프에서 구성 가능한 대상으로 이동하려면 navigate 메서드를 사용해야 합니다. navigate는 대상의 경로를 나타내는 단일 String 매개변수를 사용합니다. 탐색 그래프 내의 컴포저블에서 이동하려면 navigate를 호출하세요.

navController.navigate("friendslist")

기본적으로 navigate는 새 대상을 백 스택에 추가합니다. 추가 탐색 옵션을 navigate() 호출에 연결하여 navigate 동작을 수정할 수 있습니다.

// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
    launchSingleTop = true
}

추가 사용 사례는 popUpTo 가이드를 참고하세요.

NavControllernavigate 함수는 NavController의 내부 상태를 수정합니다. 단일 정보 소스 원칙을 준수하려면 NavController 인스턴스를 끌어올리는 구성 가능한 함수 또는 상태 홀더만 탐색 호출을 해야 합니다. UI 계층 구조 아래쪽에 있는 다른 구성 가능한 함수에서 트리거된 탐색 이벤트는 함수를 사용하여 이러한 이벤트를 호출자에게 적절히 노출해야 합니다.

다음 예에서는 구성 가능한 MyAppNavHost 함수를 NavController 인스턴스의 단일 정보 소스로 보여줍니다. ProfileScreen은 사용자가 버튼을 탭할 때 호출되는 함수로 이벤트를 노출합니다. 앱의 여러 화면으로 이동하는 MyAppNavHostProfileScreen을 호출할 때 올바른 대상으로 탐색을 호출합니다.

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = "profile"
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        composable("profile") {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate("friendsList") },
                /*...*/
            )
        }
        composable("friendslist") { FriendsListScreen(/*...*/) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -> Unit,
    /*...*/
) {
    /*...*/
    Button(onClick = onNavigateToFriends) {
        Text(text = "See friends list")
    }
}

navigate()는 컴포저블 자체의 일부가 아닌 콜백의 일부로만 호출하여 모든 재구성에서 navigate()를 호출하지 않도록 해야 합니다.

앱에서 특정 로직을 처리하는 방법을 알고 있는 호출자에게 구성 가능한 함수 이벤트를 노출하는 것은 상태를 끌어올릴 때 Compose에서 사용하기에 좋은 방법입니다.

이벤트를 개별 람다 매개변수로 노출하면 함수 서명이 오버로드될 수 있지만 구성 가능한 함수 책임의 가시성이 극대화됩니다. 함수의 기능을 한눈에 확인할 수 있습니다.

함수 선언에서 매개변수 수를 줄일 수 있는 다른 대안은 처음에는 작성하기에 더 편할 수도 있지만 장기적으로는 몇 가지 단점이 발생하게 됩니다. 예를 들어 ProfileScreenEvents와 같은 래퍼 클래스를 만들어 모든 이벤트를 한곳에 집중시킬 수 있습니다. 이렇게 하면 컴포저블이 함수 정의를 실행할 때 컴포저블이 어떤 역할을 하는지에 관한 가시성이 줄어듭니다. 또한 프로젝트 수에 다른 클래스와 메서드가 추가되며, 구성 가능한 함수를 호출할 때마다 이러한 클래스의 인스턴스를 만들고 기억해야 합니다. 또한 이 래퍼 클래스를 최대한 재사용하려면 이 패턴의 경우 권장사항인 필요한 부분에만 컴포저블에 전달하는 것이 아닌 UI 계층 구조 아래쪽으로 해당 클래스의 인스턴스를 전달하는 방식이 선호됩니다.

Navigation Compose는 구성 가능한 대상 간의 인수 전달도 지원합니다. 이렇게 하려면 기본 탐색 라이브러리를 사용할 때 딥 링크에 인수를 추가하는 방법과 유사한 방법으로 인수 자리표시자를 경로에 추가해야 합니다.

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") {...}
}

기본적으로 모든 인수는 문자열로 파싱됩니다. composable()arguments 매개변수는 NamedNavArgument 목록을 허용합니다. navArgument 메서드를 사용하여 신속하게 NamedNavArgument를 만든 다음 정확한 type을 지정할 수 있습니다.

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

composable() 함수의 람다에서 사용할 수 있는 NavBackStackEntry에서 인수를 추출해야 합니다.

composable("profile/{userId}") { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

인수를 대상에 전달하려면 navigate 호출 시 경로에 추가해야 합니다.

navController.navigate("profile/user1234")

지원되는 유형 목록은 대상 간 데이터 전달을 참고하세요.

탐색 시 복잡한 데이터 검색

탐색할 때는 복잡한 데이터 객체를 전달하지 않고 탐색 작업을 실행할 때 고유 식별자 또는 다른 형식의 ID와 같은 필요한 최소 정보를 인수로 전달하는 것이 좋습니다.

// Pass only the user ID when navigating to a new destination as argument
navController.navigate("profile/user1234")

복잡한 객체는 단일 정보 소스(예: 데이터 영역)에 데이터로 저장해야 합니다. 탐색 후 도착 페이지에 도달하면 전달된 ID를 사용하여 단일 정보 소스에서 필요한 정보를 로드할 수 있습니다. 데이터 영역 액세스를 담당하는 ViewModel의 인수를 검색하려면 ViewModel’s SavedStateHandle을 사용하면 됩니다.

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

// …

}

이 접근 방식은 구성 변경 중의 데이터 손실 및 해당 객체가 업데이트되거나 변형될 때 발생하는 불일치를 방지하는 데 도움이 됩니다.

복잡한 데이터를 인수로 전달하지 않아야 하는 이유와 지원되는 인수 유형 목록에 대한 더 자세한 설명은 대상 간 데이터 전달을 참고하세요.

선택적 인수 추가

Navigation Compose는 선택적 탐색 인수도 지원합니다. 선택적 인수는 다음과 같은 두 가지 측면에서 필수 인수와 다릅니다.

  • 쿼리 매개변수 문법("?argName={argName}")을 사용하여 포함해야 합니다.
  • defaultValue가 설정되어 있거나 nullability = true(암시적으로 기본값을 null로 설정함)가 있어야 합니다.

즉, 모든 선택적 인수는 composable() 함수에 목록 형태로 명확하게 추가해야 합니다.

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

이제 대상에 전달되는 인수가 없더라도 'user1234' defaultValue가 대신 사용됩니다.

경로를 통해 인수를 처리하는 구조에서는 컴포저블이 Navigation과 완전히 독립적으로 유지되며 테스트 가능성이 훨씬 더 높습니다.

또한 Navigation Compose는 composable() 함수의 일부로 정의할 수 있는 암시적 딥 링크를 지원합니다. deepLinks 매개변수는 navDeepLink 메서드를 사용하여 빠르게 만들 수 있는 NavDeepLink 목록을 허용합니다.

val uri = "https://www.example.com"

composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

이러한 딥 링크를 사용하면 특정 URL, 작업 또는 MIME 유형을 컴포저블과 연결할 수 있습니다. 기본적으로 이러한 딥 링크는 외부 앱에 노출되지 않습니다. 이러한 딥 링크를 외부에서 사용할 수 있도록 하려면 적절한 <intent-filter> 요소를 앱의 manifest.xml 파일에 추가해야 합니다. 위의 딥 링크를 사용 설정하려면 매니페스트의 <activity> 요소 내부에 다음을 추가해야 합니다.

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

다른 앱에서 딥 링크를 트리거하면 Navigation이 딥 링크로 컴포저블과 자동 연결됩니다.

동일한 딥 링크를 사용하면 컴포저블에서 적절한 딥 링크를 통해 PendingIntent를 빌드할 수도 있습니다.

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

그런 다음 deepLinkPendingIntent를 다른 PendingIntent처럼 사용하여 딥 링크 대상에서 앱을 열면 됩니다.

중첩 탐색

대상은 중첩 그래프로 그룹화되어 앱 UI의 특정 흐름을 모듈화할 수 있습니다. 이러한 예는 독립적인 로그인 흐름일 수 있습니다.

중첩 그래프는 대상을 캡슐화합니다. 루트 그래프와 마찬가지로 중첩 그래프에는 경로를 통해 시작 대상으로 식별된 대상이 있어야 합니다. 시작 대상으로 식별된 대상은 사용자가 중첩 그래프와 연결된 경로를 따라 도달하게 되는 곳입니다.

중첩 그래프를 NavHost에 추가하려면 navigation 확장 함수를 사용하면 됩니다.

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

그래프 크기가 커질수록 탐색 그래프를 여러 메서드로 분할하는 것이 좋습니다. 이렇게 하면 여러 모듈이 자체 탐색 그래프에 기여할 수도 있습니다.

fun NavGraphBuilder.loginGraph(navController: NavController) {
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
}

이 메서드를 NavGraphBuilder의 확장 메서드로 만들면 사전 빌드된 navigation, composable, dialog 확장 메서드와 함께 사용할 수 있습니다.

NavHost(navController, startDestination = "home") {
    ...
    loginGraph(navController)
    ...
}

하단 탐색 메뉴와 통합

하단 탐색 구성요소 등의 다른 구성요소와 Navigation을 연결하려면 컴포저블 계층 구조의 상위 수준에서 NavController를 정의하면 됩니다. 이렇게 하면 하단 메뉴에서 아이콘을 선택하여 이동할 수 있습니다.

하단 탐색 메뉴에 있는 항목을 탐색 그래프의 경로와 연결하려면 대상 경로 및 문자열 리소스 ID가 포함된 봉인 클래스(여기서는 Screen)를 정의하는 것이 좋습니다.

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
    object Profile : Screen("profile", R.string.profile)
    object FriendsList : Screen("friendslist", R.string.friends_list)
}

그런 다음 항목을 BottomNavigationItem에서 사용할 수 있는 목록에 배치합니다.

val items = listOf(
   Screen.Profile,
   Screen.FriendsList,
)

BottomNavigation 컴포저블에서 currentBackStackEntryAsState() 함수를 사용하여 현재 NavBackStackEntry를 가져옵니다. 이 항목을 통해 현재 NavDestination에 액세스할 수 있습니다. 그러면 중첩된 탐색을 사용하고 있는 경우를 처리하도록 [NavDestination][13]계층구조'를 통해 항목의 경로와 현재 대상 및 그 상위 대상의 경로를 비교하여 각 BottomNavigationItem의 선택된 상태를 확인할 수 있습니다.

항목의 경로는 항목을 탭하여 해당 항목으로 이동하도록 onClick 람다를 navigate 호출에 연결하는 데도 사용됩니다. saveStaterestoreState 플래그를 사용하면 하단 탐색 항목 간에 전환할 때 항목의 상태와 백 스택이 올바르게 저장되고 복원됩니다.

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

여기서는 NavController.currentBackStackEntryAsState() 메서드를 활용하여 NavHost 함수에서 navController 상태를 호이스트하여 BottomNavigation 구성요소와 공유합니다. 즉, BottomNavigation이 자동으로 최신 상태가 됩니다.

상호 운용성

Compose와 함께 Navigation 구성요소를 사용하려는 경우 다음 두 가지 옵션이 있습니다.

  • 프래그먼트의 Navigation 구성요소로 탐색 그래프를 정의합니다.
  • Compose 대상을 사용하여 Compose에서 NavHost로 탐색 그래프를 정의합니다. 탐색 그래프의 모든 화면이 컴포저블인 경우에만 가능합니다.

따라서, 하이브리드 앱에는 프래그먼트 기반의 Navigation 구성요소를 사용하고 프래그먼트를 사용하여 뷰 기반의 화면, Compose 화면 및 뷰와 Compose를 모두 사용하는 화면을 유지하는 것이 권장됩니다. 앱의 각 화면 프래그먼트가 컴포저블의 래퍼인 경우 다음 단계는 이러한 화면을 모두 Navigation Compose와 연결하고 프래그먼트를 모두 삭제하는 것입니다.

Compose 코드 내의 대상을 변경하려면 계층 구조의 컴포저블에 전달되고 컴포저블에서 트리거할 수 있는 이벤트를 노출합니다.

@Composable
fun MyScreen(onNavigate: (Int) -> ()) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

프래그먼트에서 NavController를 찾고 대상으로 이동하여 Compose와 프래그먼트 기반 Navigation 구성요소가 연결되도록 합니다.

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

또는 Compose 계층 구조 아래에 NavController를 전달해도 됩니다. 그러나 간단한 함수를 노출하면 훨씬 더 쉽게 재사용하고 테스트할 수 있습니다.

테스트

구성 가능한 대상에서 Navigation 코드를 분리하여 NavHost 컴포저블과 별도로 컴포저블을 각각 테스트하는 것을 적극 권장합니다.

즉, navController어떤 컴포저블로도 직접 전달해서는 안 되며 대신 탐색 콜백을 매개변수로 전달해야 합니다. 따라서 테스트에서 navController 인스턴스가 필요하지 않으므로 모든 컴포저블을 개별적으로 테스트할 수 있습니다.

composable 람다에서 제공하는 간접 참조의 수준에 따라 컴포저블 자체에서 Navigation 코드를 분리할 수 있습니다. 이는 다음과 같은 두 방향으로 작동합니다.

  • 파싱된 인수만 컴포저블에 전달함
  • 컴포저블에서 트리거해야 하는 람다를 전달하여 이동함(NavController 자체를 전달하지 않음)

예를 들어 userId를 입력으로 받아들이고 사용자가 친구의 프로필 페이지로 이동할 수 있게 하는 Profile 컴포저블의 경우 서명이 다음과 같을 수 있습니다.

@Composable
fun Profile(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 …
}

이렇게 하면 Profile 컴포저블이 Navigation과 독립적으로 작동하므로 독립적으로 테스트할 수 있습니다. composable 람다는 Navigation API와 컴포저블 간의 격차를 줄이는 데 필요한 최소 논리를 캡슐화합니다.

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
        navController.navigate("profile?userId=$friendUserId")
    }
}

NavHost, 컴포저블에 전달된 탐색 작업, 개별 화면 컴포저블을 테스트하여 앱 탐색 요구사항을 다루는 테스트를 작성하는 것이 좋습니다.

NavHost 테스트

NavHost 테스트를 시작하려면 다음 탐색 테스트 종속 항목을 추가합니다.

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

NavHost 테스트 제목을 설정하고 navController 인스턴스의 인스턴스를 전달할 수 있습니다. 이를 위해 Navigation 테스트 아티팩트는 TestNavHostController를 제공합니다. 앱의 시작 대상과 NavHost를 확인하는 UI 테스트는 다음과 같습니다.

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

탐색 작업 테스트

탐색 구현은 UI 요소를 클릭한 다음 표시되는 대상을 확인하거나 현재 경로를 기준으로 예상 경로를 비교하는 등 여러 가지 방법으로 테스트할 수 있습니다.

앱 구현을 테스트할 때는 UI를 클릭하는 방법을 사용하는 것이 좋습니다. 개별적으로 구성 가능한 함수와 함께 이를 테스트하는 방법을 알아보려면 Jetpack Compose에서 테스트 Codelab을 확인하세요.

또한 navControllercurrentBackStackEntry를 사용하여 현재 문자열 경로를 예상 경로와 비교함으로써 navController를 사용하여 어설션을 확인할 수도 있습니다.

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "profiles")
}

Compose 테스트 기본사항에 관한 자세한 내용은 Compose 테스트 문서 및 Jetpack Compose에서 테스트 Codelab을 참고하세요. 탐색 코드의 고급 테스트에 관한 자세한 내용은 탐색 테스트 가이드를 참고하세요.

자세히 알아보기

Jetpack Navigation에 관해 자세히 알아보려면 Navigation 구성요소 시작하기를 참고하거나 Jetpack Compose Navigation Codelab을 진행하세요.

다양한 화면 크기와 방향, 폼 팩터에 맞게 조정되도록 앱의 탐색을 디자인하는 방법은 반응형 UI용 Navigation을 참고하세요.

중첩된 그래프 및 하단 탐색 메뉴 통합과 같은 개념을 비롯하여 모듈화된 앱의 고급 Navigation Compose 구현에 관한 자세한 내용은 Now in Android 저장소를 참고하세요.