Compose를 사용한 탐색

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

설정

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

Groovy

dependencies {
    def nav_version = "2.8.0"

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

Kotlin

dependencies {
    val nav_version = "2.8.0"

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

시작하기

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

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

NavHost 만들기

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

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

컴포저블 대상 간에 인수를 전달하는 방법에 관한 자세한 내용은 탐색 그래프 설계의 Compose 섹션을 참고하세요.

탐색 시 복잡한 데이터 검색

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

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

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

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

    private val profile = savedStateHandle.toRoute<Profile>()

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

// …

}

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

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

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

@Serializable data class Profile(val id: String)
val uri = "https://www.example.com"

composable<Profile>(
  deepLinks = listOf(
    navDeepLink<Profile>(basePath = "$uri/profile")
  )
) { backStackEntry ->
  ProfileScreen(id = backStackEntry.toRoute<Profile>().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/profile/$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.7.5"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

하단 탐색 메뉴에 있는 항목을 탐색 그래프의 경로와 연결하려면 경로 클래스와 아이콘이 있는 클래스(여기서는 TopLevelRoute)를 정의하는 것이 좋습니다.

data class TopLevelRoute<T : Any>(val name: String, val route: T, val icon: ImageVector)

그런 다음 해당 경로를 BottomNavigationItem에서 사용할 수 있는 목록에 배치합니다.

val topLevelRoutes = listOf(
   TopLevelRoute("Profile", Profile, Icons.Profile),
   TopLevelRoute("Friends", Friends, Icons.Friends)
)

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

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

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      topLevelRoutes.forEach { topLevelRoute ->
        BottomNavigationItem(
          icon = { Icon(topLevelRoute.icon, contentDescription = topLevelRoute.name) },
          label = { Text(topLevelRoute.name) },
          selected = currentDestination?.hierarchy?.any { it.hasRoute(topLevelRoute.route::class) } == true,
          onClick = {
            navController.navigate(topLevelRoute.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 = Profile, Modifier.padding(innerPadding)) {
    composable<Profile> { ProfileScreen(...) }
    composable<Friends> { FriendsScreen(...) }
  }
}

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

상호 운용성

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를 전달해도 됩니다. 그러나 간단한 함수를 노출하면 훨씬 더 쉽게 재사용하고 테스트할 수 있습니다.

테스트

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

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

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

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

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

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

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

@Serializable data class Profile(id: String)

composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.id) { friendUserId ->
        navController.navigate(route = Profile(id = friendUserId))
    }
}

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

NavHost 테스트

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

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

NavHostController를 매개변수로 허용하는 컴포저블에 앱의 NavHost를 래핑합니다.

@Composable
fun AppNavHost(navController: NavHostController){
  NavHost(navController = navController){ ... }
}

이제 탐색 테스트 아티팩트 TestNavHostController의 인스턴스를 전달하여 AppNavHostNavHost 내에 정의된 모든 탐색 로직을 테스트할 수 있습니다. 앱의 시작 대상과 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()

    assertTrue(navController.currentBackStackEntry?.destination?.hasRoute<Profile>() ?: false)
}

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

자세히 알아보기

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

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

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

샘플