Compose를 사용한 탐색

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

설정

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

Groovy

dependencies {
    def nav_version = "2.7.7"

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

Kotlin

dependencies {
    val nav_version = "2.7.7"

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

시작하기

앱에서 탐색을 구현할 때는 탐색 호스트, 그래프 및 컨트롤러를 구현합니다. 자세한 내용은 탐색 개요를 참고하세요.

Compose에서 NavController를 만드는 방법에 관한 자세한 내용은 탐색 컨트롤러 만들기의 Compose 섹션을 참고하세요.

NavHost 만들기

Compose에서 NavHost를 만드는 방법에 관한 자세한 내용은 탐색 그래프 디자인의 Compose 섹션을 참고하세요.

컴포저블로 이동하는 방법에 관한 자세한 내용은 아키텍처 문서의 대상으로 이동을 참고하세요.

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의 인수를 검색하려면 ViewModelSavedStateHandle를 사용하세요.

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가 설정되어 있거나 nullable = 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처럼 사용하여 딥 링크 대상에서 앱을 열면 됩니다.

중첩 탐색

중첩된 탐색 그래프를 만드는 방법에 관한 자세한 내용은 중첩 그래프를 참고하세요.

하단 탐색 메뉴와 통합

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

BottomNavigationBottomNavigationItem 구성요소를 사용하려면 Android 애플리케이션에 androidx.compose.material 종속 항목을 추가합니다.

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.6.4"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.11"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.6.4")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.11"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

하단 탐색 메뉴에 있는 항목을 탐색 그래프의 경로와 연결하려면 대상 경로 및 문자열 리소스 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 계층 구조를 사용하여 중첩된 탐색을 사용하는 경우를 처리하기 위해 항목의 경로를 현재 대상 및 그 상위 대상의 경로와 비교하여 각 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이 자동으로 최신 상태가 됩니다.

Navigation Compose의 유형 안전성

이 페이지의 코드는 유형에 안전하지 않습니다. 존재하지 않는 경로 또는 잘못된 인수를 사용하여 navigate() 함수를 호출할 수 있습니다. 그러나 런타임 시 유형에 안전하도록 Navigation 코드를 구성할 수 있습니다. 이렇게 하면 비정상 종료를 피하고 다음을 확인할 수 있습니다.

  • 대상 또는 탐색 그래프로 이동할 때 제공하는 인수가 올바른 유형이며 모든 필수 인수가 있습니다.
  • SavedStateHandle에서 가져오는 인수가 올바른 유형입니다.

이에 관한 자세한 내용은 Kotlin DSL 및 Navigation Compose의 유형 안전성을 참고하세요.

상호 운용성

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

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

따라서 혼합 Compose 및 뷰 앱은 프래그먼트 기반의 Navigation 구성요소를 사용하는 것이 좋습니다. 그러면 프래그먼트는 뷰 기반 화면, Compose 화면, 뷰와 Compose를 모두 사용하는 화면을 보유합니다. 각 프래그먼트의 콘텐츠가 Compose에 포함되면 다음 단계는 이러한 화면을 모두 Navigation Compose와 연결하고 모든 프래그먼트를 삭제하는 것입니다.

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

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

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

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

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

테스트

컴포저블 대상에서 탐색 코드를 분리하여 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 인스턴스의 인스턴스를 전달할 수 있습니다. 이를 위해 탐색 테스트 아티팩트는 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을 참고하세요.

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

샘플