Jetpack Compose 탐색

1. 소개

최종 업데이트: 2022년 7월 25일

필요한 항목

탐색은 앱 내의 한 대상에서 다른 대상으로 이동할 수 있게 하는 Jetpack 라이브러리입니다. 탐색 라이브러리는 Jetpack Compose를 사용하여 일관되고 직관적인 탐색을 가능하게 하는 특정 아티팩트도 제공합니다. 이 아티팩트(navigation-compose)가 이 Codelab의 핵심입니다.

실행할 작업

이 Codelab의 기반으로 Rally Material 연구를 사용하게 됩니다. Jetpack 탐색 구성요소를 구현하고, 구성 가능한 Rally 화면 간에 이동할 수 있도록 설정합니다.

학습할 내용

  • Jetpack Compose와 함께 Jetpack 탐색을 사용하는 기본적인 방법
  • 컴포저블 간 이동
  • 탐색 계층 구조에 맞춤 탭바 컴포저블 통합
  • 인수를 사용하여 이동
  • 딥 링크를 사용하여 이동
  • 탐색 테스트

2. 설정

직접 해보려면 Codelab의 시작점(main 브랜치)을 클론합니다.

$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git

또는 ZIP 파일 두 개를 다운로드해도 됩니다.

코드를 다운로드했으므로 이제 Android 스튜디오에서 NavigationCodelab 프로젝트 폴더를 엽니다. 이제 시작할 준비가 되었습니다.

3. Rally 앱 개요

제일 먼저 Rally 앱과 코드베이스에 익숙해져야 합니다. 앱을 실행하고 살펴봅니다.

Rally에는 컴포저블인 세 가지 기본 화면이 있습니다.

  1. OverviewScreen: 모든 금융 거래 및 알림 개요
  2. AccountsScreen: 기존 계좌의 통계
  3. BillsScreen: 예정된 비용

알림, 계좌, 청구서에 관한 정보가 포함된 개요 화면의 스크린샷 여러 계좌에 관한 정보가 포함된 계좌 화면의 스크린샷 여러 발신 청구서에 관한 정보가 포함된 청구서 화면 스크린샷

Rally는 화면 상단에서 맞춤 탭바 컴포저블(RallyTabRow)을 사용하여 세 화면 간에 이동합니다. 각 아이콘을 탭하면 현재 선택한 항목이 확장되고 해당하는 화면으로 이동합니다.

336ba66858ae3728.png e26281a555c5820d.png

이러한 컴포저블 화면으로 이동할 때는 각 화면에 특정 시점에 도달하고자 하므로 화면을 탐색 대상이라고 생각할 수 있습니다. 대상은 RallyDestinations.kt 파일에 사전 정의되어 있습니다.

이 파일에서는 객체(Overview, Accounts, Bills)로 정의된 3가지 기본 대상과 나중에 앱에 추가할 SingleAccount를 볼 수 있습니다. 각 객체는 RallyDestination 인터페이스에서 확장되며 탐색을 위해 각 대상에 필요한 정보를 포함합니다.

  1. 탑바의 icon
  2. route 문자열(Compose Navigation에 필요한 대상 경로)
  3. 이 대상의 컴포저블을 나타내는 screen

앱을 실행하면 지금도 탑바를 사용하여 대상 간에 이동할 수 있음을 알 수 있습니다. 단, 지금은 Compose Navigation이 사용되는 대신 컴포저블의 수동 전환 및 리컴포지션 트리거를 사용하여 새로운 콘텐츠를 보여주는 탐색 메커니즘이 사용되고 있습니다. 이 Codelab의 목표는 Compose Navigation을 성공적으로 이전하고 구현하는 것입니다.

4. Compose Navigation으로 이전

Jetpack Compose로의 기본적인 이전은 다음과 같은 여러 단계를 따릅니다.

  1. 최신 Compose Navigation 종속 항목 추가하기
  2. NavController 설정하기
  3. NavHost를 추가하고 탐색 그래프 만들기
  4. 여러 앱 대상 간에 이동하기 위한 경로 준비하기
  5. 기존 탐색 메커니즘을 Compose Navigation으로 대체하기

각 단계를 하나씩 자세히 살펴보겠습니다.

탐색 종속 항목 추가하기

app/build.gradle에 있는 앱의 빌드 파일을 엽니다. 종속 항목 섹션에서 navigation-compose 종속 항목을 추가합니다.

dependencies {
  implementation "androidx.navigation:navigation-compose:{latest_version}"
  // ...
}

최신 버전의 navigation-compose는 여기에서 찾을 수 있습니다.

이제 프로젝트를 동기화하면 Compose에서 탐색을 사용할 준비가 된 것입니다.

NavController 설정하기

NavController는 Compose에서 탐색을 사용할 때 중심이 되는 구성요소입니다. 백 스택 컴포저블 항목을 추적하고, 스택을 앞으로 이동하고, 백 스택 조작을 사용 설정하고, 대상 상태 간에 이동합니다. NavController는 탐색의 중심이므로 Compose Navigation을 설정하기 위한 첫 번째 단계로서 만들어야 합니다.

NavControllerrememberNavController() 함수를 호출하여 가져옵니다. 그러면 구성이 변경되어도 유지되는 NavController가 만들어지고 기억됩니다(rememberSaveable 사용).

NavController는 항상 컴포저블 계층 구조의 최상위 수준(일반적으로 App 컴포저블 내)에서 만들고 배치해야 합니다. 이렇게 하면 NavController를 참조해야 하는 모든 컴포저블이 액세스할 수 있습니다. 이는 상태 호이스팅의 원칙을 준수하며, NavController가 컴포저블 화면 간에 이동하고 백 스택을 유지하기 위한 기본 정보 소스가 되도록 해 줍니다.

RallyActivity.kt를 엽니다. NavController는 루트 컴포저블이자 앱 전체의 진입점이므로 RallyApp 내에서 rememberNavController()를 사용하여 가져옵니다.

import androidx.navigation.compose.rememberNavController
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            // ...
        ) {
            // ...
       }
}

Compose Navigation의 경로

앞서 언급했듯이 Rally 앱에는 세 가지 기본 대상과 나중에 추가할 하나의 대상(SingleAccount)이 있습니다. 모두 RallyDestinations.kt에 정의되어 있습니다. 각 대상에는 정의된 icon, route, screen이 있다는 사실도 앞에서 언급했습니다.

알림, 계좌, 청구서에 관한 정보가 포함된 개요 화면의 스크린샷 여러 계좌에 관한 정보가 포함된 계좌 화면의 스크린샷 여러 발신 청구서에 관한 정보가 포함된 청구서 화면 스크린샷

다음 단계는 앱이 실행될 때 Overview를 시작 대상으로 설정하여 이러한 대상을 탐색 그래프에 추가하는 것입니다.

Compose 내에서 Navigation을 사용할 때는 탐색 그래프의 각 컴포저블 대상에 경로가 연결됩니다. 경로는 컴포저블의 경로를 정의하는 문자열로 표현되며, 올바른 위치로 이동할 수 있도록 navController를 안내합니다. 특정 대상으로 연결되는 암시적 딥 링크라고 생각할 수 있습니다. 각 대상에는 고유 경로가 있어야 합니다.

이를 위해 각 RallyDestination 객체의 route 속성을 사용합니다. 예를 들어 Overview.routeOverview 화면 컴포저블로 이동하는 경로입니다.

탐색 그래프를 사용하여 NavHost 컴포저블 호출

다음 단계는 NavHost를 추가하고 탐색 그래프를 만드는 것입니다.

Navigation의 세 가지 주요 부분은 NavController, NavGraph, NavHost입니다. NavController는 항상 단일 NavHost 컴포저블에 연결됩니다. NavHost는 컨테이너 역할을 하며 그래프의 현재 대상을 표시하는 일을 담당합니다. 여러 컴포저블 간에 이동하는 과정에서 NavHost의 콘텐츠가 자동으로 재구성됩니다. 또한 NavController를 이동 가능한 컴포저블 대상을 매핑하는 탐색 그래프(NavGraph)에 연결합니다. NavHost는 가져올 수 있는 대상의 모음입니다.

RallyActivity.ktRallyApp 컴포저블로 돌아갑니다. 화면을 수동 전환을 위한 기존 화면 콘텐츠를 포함하는 Scaffold 내부의 Box 컴포저블을 아래 코드 예시에 따라 만든 NavHost로 바꿉니다.

앞에서 만든 navController를 전달하여 이 NavHost에 연결합니다. 앞서 언급했듯이 각 NavController는 단일 NavHost와 연결되어야 합니다.

NavHost에는 앱이 시작될 때 어느 대상을 표시할지 알기 위한 startDestination도 필요하므로 startDestination을 Overview.route로 설정합니다. 여기에 더해, 바깥 Scaffold 패딩을 받을 Modifier를 전달하여 NavHost에 적용합니다.

마지막 매개변수 builder: NavGraphBuilder.() -> Unit은 탐색 그래프를 정의하고 빌드하는 일을 담당합니다. Navigation Kotlin DSL의 람다 문법을 사용하므로 함수 본문 안에서 후행 람다로 전달되어 괄호 밖으로 꺼낼 수 있습니다.

import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) {
       // builder parameter will be defined here as the graph
    }
}

NavGraph에 대상 추가하기

이제 탐색 그래프와 NavController가 이동할 수 있는 대상을 정의할 수 있습니다. 앞서 언급했듯이 builder 매개변수는 함수를 예상하므로, Navigation Compose는 탐색 그래프에 개별 컴포저블 대상을 쉽게 추가하고 필요한 탐색 정보를 정의할 수 있도록 NavGraphBuilder.composable 확장 함수를 제공합니다.

첫 번째 대상은 Overview입니다. Overview를 composable 확장 함수를 통해 추가하고 고유 문자열 route를 설정해야 합니다. 이렇게 하면 탐색 그래프에 대상이 추가되므로 이 대상으로 이동하면 표시될 UI도 정의해야 합니다. 이 작업 또한 composable 함수 본문 안에서 후행 람다를 통해 이루어지는데, 이는 Compose에서 자주 사용되는 패턴입니다.

import androidx.navigation.compose.composable
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
}

이 패턴에 따라 3개의 기본 화면 컴포저블을 3개의 대상으로 추가하겠습니다.

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    composable(route = Accounts.route) {
        Accounts.screen()
    }
    composable(route = Bills.route) {
        Bills.screen()
    }
}

이제 앱을 실행합니다. 시작 대상인 Overview와 UI가 표시되는 것을 볼 수 있습니다.

기존에 화면 간의 수동 탐색을 처리하던 맞춤 탑바인 RallyTabRow 컴포저블을 앞서 언급했습니다. 이 시점에서는 RallyTabRow가 아직 새 탐색에 연결되지 않았으므로, 탭을 클릭해도 표시된 화면 컴포저블의 대상이 변경되지 않는 것을 확인할 수 있습니다. 이제 이 문제를 해결해 보겠습니다.

5. 탐색에 RallyTabRow 통합

이 단계에서는 RallyTabRow가 올바른 대상으로 이동할 수 있도록 navController 및 탐색 그래프에 연결합니다.

이렇게 하려면 새 navController를 사용하여 RallyTabRowonTabSelected 콜백을 위한 관한 올바른 탐색 동작을 정의해야 합니다. 이 콜백은 특정 탭 아이콘을 선택하면 어떻게 되는지 정의하고 navController.navigate(route).를 통해 탐색 동작을 실행합니다.

RallyActivity에서 RallyTabRow 컴포저블과 콜백 매개변수 onTabSelected를 찾습니다.

탭을 탭하면 특정 대상으로 이동하기를 원하므로 정확히 어느 탭 아이콘이 선택되었는지도 알아야 합니다. 다행히 onTabSelected: (RallyDestination) -> Unit 매개변수가 이 기능을 제공합니다. 이 정보와 RallyDestination 경로를 사용하여 navController를 안내하고 탭이 선택되면 navController.navigate(newScreen.route)를 호출합니다.

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    // Pass the callback like this,
                    // defining the navigation action when a tab is selected:
                    onTabSelected = { newScreen ->
                        navController.navigate(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

이 시점에서 앱을 실행하면 RallyTabRow의 개별 탭을 탭하면 올바른 컴포저블 대상으로 이동하는 것을 확인할 수 있습니다. 단, 여기에는 두 가지 문제가 있습니다.

  1. 같은 탭을 연속해서 다시 탭하면 동일한 대상의 여러 개의 사본이 실행됩니다.
  2. 탭의 UI가 표시된 올바른 대상과 일치하지 않습니다. 즉, 선택한 탭의 펼치기와 접기가 제대로 작동하지 않습니다.

336ba66858ae3728.png e26281a555c5820d.png

두 가지 문제를 수정해 보겠습니다.

대상의 단일 사본 실행하기

첫 번째 문제를 수정하여 백 스택 위에 대상의 사본이 최대 1개만 있도록 하려면 다음과 같이 Compose Navigation API의 launchSingleTop 플래그를 navController.navigate() 동작으로 전달하면 됩니다.

navController.navigate(route) { launchSingleTop = true }

앱 전체에서 모든 대상에 이 동작을 적용하려면 이 플래그를 모든navigate(...) 호출에 복사하여 붙여넣는 대신 RallyActivity 하단에 있는 도우미 확장으로 추출하면 됩니다.

import androidx.navigation.NavHostController
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

이제 navController.navigate(newScreen.route) 호출을 .navigateSingleTopTo(...)로 바꿀 수 있습니다. 앱을 다시 실행하고 탑바에서 대상 아이콘을 여러 번 클릭하면 이제 대상의 사본이 하나만 실행되는 것을 볼 수 있습니다.

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    onTabSelected = { newScreen ->
                        navController
                            .navigateSingleTopTo(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

탐색 옵션 및 백 스택 상태 제어하기

NavOptionsBuilder에는 launchSingleTop 외에도 탐색 동작을 세밀하게 제어하고 맞춤설정하는 데 사용할 수 있는 여러 플래그가 있습니다. RallyTabRowBottomNavigation과 유사하게 동작하므로, RallyTabRow를 오갈 때 대상 상태를 저장하고 복원하길 원하는지 생각해 보아야 합니다. 예를 들어 '개요'의 하단으로 스크롤한 다음 '계좌'로 이동하고 다시 '개요'로 돌아갔을 때 스크롤 위치를 그대로 유지하고 싶은가요? RallyTabRow에서 같은 대상을 다시 탭했을 때 화면 상태를 새로고침하고 싶은가요? 모두 생각해 보아야 할 질문이며, 답은 앱 디자인의 요구 사항에 따라 달라집니다.

동일한 navigateSingleTopTo 확장 함수 내에서 사용할 수 있는 몇 가지 추가 옵션을 살펴보겠습니다.

  • launchSingleTop = true - 앞서 언급했듯이, 백 스택 위에 대상의 사본이 최대 1개가 되도록 해 줍니다.
  • Rally 앱의 경우에는 동일한 탭을 여러 번 탭해도 동일한 대상의 사본이 여러 개 실행되지 않습니다.
  • popUpTo(startDestination) { saveState = true } - 탭을 선택했을 때 백 스택에 대규모 대상 스택이 빌드되지 않도록 그래프의 시작 대상을 팝업으로 만듭니다.
  • Rally의 경우에는 대상에서 뒤로 화살표를 누르면 백 스택 전체가 '개요'로 팝업됩니다.
  • restoreState = true: 이 탐색 동작이 이전에 PopUpToBuilder.saveState 또는 popUpToSaveState 속성에 의해 저장된 상태를 복원하는지 여부를 정합니다. 이동할 대상 ID를 사용하여 이전에 저장된 상태가 없다면 이 옵션은 효과가 없습니다.
  • Rally의 경우에는 동일한 탭을 다시 탭하면 이전 데이터와 사용자 상태가 다시 로드되지 않고 화면에 그대로 유지됩니다.

위의 모든 옵션은 코드에 하나씩 추가하고 추가한 후에는 매번 앱을 실행하여 각 플래그를 추가한 후의 정확한 동작을 확인할 수 있습니다. 이렇게 하면 각 플래그가 탐색 및 백 스택 상태를 어떻게 바꾸는지 실제로 알아볼 수 있습니다.

import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) {
        popUpTo(
            this@navigateSingleTopTo.graph.findStartDestination().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

탭 UI 수정하기

이 Codelab을 시작한 시점에는 수동 탐색 메커니즘이 사용되고 있었지만 RallyTabRowcurrentScreen 변수를 사용하여 각 탭을 펼치거나 접을지 확인했습니다.

그러나 지금까지의 변경사항을 적용한 결과 currentScreen이 더 이상 업데이트되지 않습니다. 따라서 RallyTabRow 내에서 선택한 탭을 펼치고 접는 것이 더 이상 작동하지 않습니다.

Compose Navigation을 사용하여 이 동작을 다시 설정하려면 각 시점에 현재 표시된 대상이 무엇인지를 알아야 합니다. (탐색 분야의 용어를 사용하면 현재 백 스택 항목의 위에 무엇이 있는지를 알아야 합니다.) 그런 다음 대상이 변경될 때마다 RallyTabRow를 업데이트해야 합니다.

백 스택에서 현재 대상의 실시간 업데이트를 State의 형식으로 받아보려면 navController.currentBackStackEntryAsState()를 사용하여 현재 destination:을 살펴보면 됩니다.

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        // Fetch your currentDestination:
        val currentDestination = currentBackStack?.destination
        // ...
    }
}

currentBackStack?.destinationNavDestination.을 반환합니다. currentScreen을 다시 올바르게 업데이트하려면 반환된 NavDestination을 Rally의 3가지 기본 화면 컴포저블에 매칭할 방법이 있어야 합니다. 현재 어느 컴포저블이 표시되어 있는지 확인하여 이 정보를 RallyTabRow.에 전달해야 합니다. 앞서 언급했듯이 각 대상에는 고유 경로가 있으므로 이 문자열 경로를 일종의 ID로 사용하여 비교해 보고 고유한 일치 항목을 찾을 수 있습니다.

currentScreen을 업데이트하려면 rallyTabRowScreens 목록을 순환하여 일치하는 경로를 찾은 다음 그 RallyDestination을 반환해야 합니다. Kotlin에는 이를 위한 유용한 .find() 함수가 있습니다.

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        val currentDestination = currentBackStack?.destination

        // Change the variable to this and use Overview as a backup screen if this returns null
        val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
        // ...
    }
}

currentScreen이 이미 RallyTabRow에 전달되고 있으므로 이제 앱을 실행하면 탭바 UI가 올바르게 업데이트되는 것을 볼 수 있습니다.

6. RallyDestinations에서 화면 컴포저블 추출

지금까지는 RallyDestination 인터페이스의 screen 속성과 그로부터 확장된 화면 객체를 사용하여 NavHost (RallyActivity.kt에서 컴포저블 UI를 추가했습니다.

import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        Overview.screen()
    }
    // ...
}

그러나 이 Codelab의 다음 단계(예: 클릭 이벤트)에서는 컴포저블 화면에 추가 정보를 직접 전달해야 합니다. 프로덕션 환경에서는 전달해야 할 데이터가 훨씬 많아집니다.

이를 달성하는 더욱 정확하고 깔끔한 방법은 컴포저블을 NavHost 탐색 그래프에 직접 추가하고 RallyDestination에서 추출하는 것입니다. 그러면 RallyDestination과 화면 객체는 icon, route와 같은 탐색 관련 정보만 저장하며 Compose UI와 분리됩니다.

RallyDestinations.kt를 엽니다. 다음과 같이 RallyDestination 객체의 screen 매개변수에서 NavHost의 상응하는 composable 함수로 각 화면의 컴포저블을 추출하여 이전 .screen() 호출을 대체합니다:

import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        OverviewScreen()
    }
    composable(route = Accounts.route) {
        AccountsScreen()
    }
    composable(route = Bills.route) {
        BillsScreen()
    }
}

이 시점에서는 RallyDestination 및 객체에서 screen 매개변수를 안전하게 삭제할 수 있습니다.

interface RallyDestination {
    val icon: ImageVector
    val route: String
}

/**
 * Rally app navigation destinations
 */
object Overview : RallyDestination {
    override val icon = Icons.Filled.PieChart
    override val route = "overview"
}
// ...

앱을 다시 실행하고 모든 것이 전과 동일하게 작동하는지 확인합니다. 이 단계를 완료했으니 이제 컴포저블 화면 내에서 클릭 이벤트를 설정할 수 있습니다.

OverviewScreen에서 클릭 사용 설정하기

지금은 OverviewScreen에서 모든 클릭 이벤트가 무시됩니다. 즉, '계좌' 및 '청구서' 하위 섹션의 '모두 보기' 버튼을 클릭할 수는 있지만 실제로는 아무 동작도 발생하지 않습니다. 이 단계의 목표는 이러한 클릭 이벤트에서 탐색을 사용 설정하는 것입니다.

개요 화면에서 클릭 대상으로 스크롤하고 클릭을 시도하는 모습의 화면 녹화. 클릭은 아직 구현되지 않았으므로 작동하지 않습니다.

OverviewScreen 컴포저블은 여러 함수를 콜백으로 받아서 클릭 이벤트로 설정할 수 있습니다. 여기서는 이 클릭 이벤트가 AccountsScreen 또는 BillsScreen로 이동하는 탐색 동작이 되어야 합니다. 관련 대상으로 이동할 수 있도록 탐색 콜백을 onClickSeeAllAccountsonClickSeeAllBills에 전달해 보겠습니다.

RallyActivity.kt를 열고 NavHost에서 OverviewScreen를 찾은 다음 두 탐색 콜백에 navController.navigateSingleTopTo(...)를 상응하는 경로와 함께 전달합니다.

OverviewScreen(
    onClickSeeAllAccounts = {
        navController.navigateSingleTopTo(Accounts.route)
    },
    onClickSeeAllBills = {
        navController.navigateSingleTopTo(Bills.route)
    }
)

이제 navController는 버튼 클릭 시, 올바른 대상으로 이동하기 위한 정확한 대상의 경로와 같은 충분한 정보를 갖습니다. OverviewScreen의 구현을 살펴보면 이러한 콜백이 이미 상응하는 onClick 매개변수로 설정되어 있는 것을 확인할 수 있습니다:

@Composable
fun OverviewScreen(...) {
    // ...
    AccountsCard(
        onClickSeeAll = onClickSeeAllAccounts,
        onAccountClick = onAccountClick
    )
    // ...
    BillsCard(
        onClickSeeAll = onClickSeeAllBills
    )
}

앞서 언급했듯이 navController 탐색 계층 구조의 최상위 수준에 유지하고, (OverviewScreen) 등에 직접 전달하는 대신) App 컴포저블의 수준으로 호이스팅하면 실제 또는 모의 navController 인스턴스를 사용하지 않고도 OverviewScreen 컴포저블을 단독으로 미리 보고 재사용 및 테스트할 수 있습니다. 콜백을 전달하는 방식에서는 클릭 이벤트를 빠르게 변경할 수도 있습니다.

7. 인수를 사용하여 SingleAccountScreen으로 이동

AccountsOverview 화면에 새로운 기능을 추가해 보겠습니다. 현재 이러한 화면에는 '당좌 예금', '주택 예금' 등 여러 유형의 계좌가 표시되어 있습니다.

2f335ceab09e449a.png 2e78a5e090e3fccb.png

하지만 계좌 유형을 클릭해도 아직 아무런 동작이 수행되지 않습니다. 이 문제를 해결해 보겠습니다. 각 계좌 유형을 탭하면 계좌 세부정보를 보여주는 새 화면이 표시되도록 하려고 합니다. 이렇게 하려면 navController에 사용자가 클릭한 정확한 계좌 유형에 관한 추가 정보를 제공해야 합니다. 이 작업은 인수를 통해 할 수 있습니다.

인수는 경로에 하나 이상의 인수를 전달하여 탐색 라우팅을 동적으로 만들어 주는 매우 강력한 도구입니다. 인수를 사용하면 제공된 인수에 따라 서로 다른 정보를 표시할 수 있습니다.

RallyApp에서, 새 composable 함수를 기존 NavHost:에 추가하여 개별 계좌의 표시를 처리할 새 대상 SingleAccountScreen을 그래프에 추가합니다.

import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    ...
    composable(route = SingleAccount.route) {
        SingleAccountScreen()
    }
}

SingleAccountScreen 도착 대상 설정하기

SingleAccountScreen에 도착하면 이 대상이 열렸을 때 정확히 어떤 계좌 유형을 표시해야 하는지 알 수 있도록 추가 정보가 필요합니다. 이때 인수를 사용하여 이러한 종류의 정보를 전달할 수 있습니다. 경로에 추가로 {account_type} 인수가 필요하다는 것을 지정해야 합니다. RallyDestinationSingleAccount 객체를 살펴보면 이 인수를 곧바로 사용할 수 있도록 accountTypeArg 문자열로 이미 정의되어 있는 것을 볼 수 있습니다.

탐색 시에 인수를 경로와 함께 전달하려면 "route/{argument}" 패턴에 따라 경로에 인수를 추가해야 합니다. 여기서는 "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"와 같이 추가하면 됩니다. $ 기호는 변수를 이스케이프하는 데 사용됩니다.

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
    SingleAccountScreen()
}

이렇게 하면 작업이 SingleAccountScreen으로 이동하도록 동작이 트리거되면 accountTypeArg 인수도 전달되게 됩니다. 이렇게 하지 않으면 탐색이 실패합니다. 이 방식은 SingleAccountScreen으로 이동하려면 다른 대상들도 따라야 하는 서명 또는 계약이라고 생각할 수 있습니다.

두 번째 단계는 이 composable이 인수를 받아야 한다는 사실을 알려주는 것입니다. 이렇게 하려면 arguments 매개변수를 정의합니다. composable 함수는 기본적으로 인수 목록을 받기 때문에 인수는 원하는 개수만큼 정의할 수 있습니다. 여기서는 accountTypeArg라는 단일 인수를 추가하고 안전하게 String 유형으로 지정하면 됩니다. 유형을 명시적으로 설정하지 않으면 인수의 기본값에서 유형이 추론됩니다.

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments = listOf(
        navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
    )
) {
    SingleAccountScreen()
}

이 코드만으로도 제대로 작동할 것입니다. 하지만 모든 대상 관련 정보가 RallyDestinations.kt 및 그 객체에 있으므로, 위에서 Overview, Accounts,, Bills에 사용한 접근 방식을 사용하여 인수 목록을 SingleAccount:로 이동하겠습니다.

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

NavHost의 상응하는 composable에서 기존 인수를 SingleAccount.arguments로 바꿉니다. 이렇게 하면 NavHost를 깔끔하고 가독성 높게 유지할 수 있습니다.

composable(
    route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments =  SingleAccount.arguments
) {
    SingleAccountScreen()
}

지금까지 SingleAccountScreen의 인수로 전체 경로를 정의했으니 이번에는 이 accountTypeArgSingleAccountScreen 컴포저블로 전달되어 어느 계좌 유형을 표시해야 할지 알 수 있도록 해야 합니다. SingleAccountScreen의 구현을 살펴보면 이미 설정되어 accountType 매개변수를 받으려고 기다리고 있는 것을 볼 수 있습니다.

fun SingleAccountScreen(
    accountType: String? = UserData.accounts.first().name
) {
   // ...
}

지금까지 한 내용은 다음과 같습니다.

  • 인수를 요청하는 경로를 이전 대상에 대한 신호로 정의했습니다.
  • composable이 인수를 받아야 한다는 사실을 알 수 있도록 했습니다.

마지막 단계는 전달된 인수 값을 가져오는 것입니다.

Compose Navigation에서 각 NavHost 구성 가능한 함수는 백 스택에 있는 항목의 현재 경로 및 전달된 인수에 관한 정보를 저장하는 클래스인 현재 NavBackStackEntry에 액세스할 수 있습니다. 이를 사용하여 navBackStackEntry에서 arguments목록을 가져온 다음 필요한 인수를 검색하고 가져와서 컴포저블 화면으로 전달할 수 있습니다.

여기서는 navBackStackEntry에서 accountTypeArg를 요청합니다. 그런 다음 이것을 SingleAccountScreen'accountType 매개변수에 전달해야 합니다.

또한 인수의 값이 제공되지 않을 경우에 사용할 기본값을 자리표시자로 제공해야 합니다. 이렇게 하면 예외 케이스도 처리되어 코드가 더 안전해집니다.

이제 코드가 다음과 같이 표시됩니다.

NavHost(...) {
    // ...
    composable(
        route =
          "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = SingleAccount.arguments
    ) { navBackStackEntry ->
        // Retrieve the passed argument
        val accountType =
            navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)

        // Pass accountType to SingleAccountScreen
        SingleAccountScreen(accountType)
    }
}

이제 SingleAccountScreen으로 이동하면 계좌 유형을 표시하는 데 필요한 정보를 갖게 되었습니다. SingleAccountScreen,의 구현을 살펴보면 이미 상응하는 계좌 세부정보를 가져오기 전달된 accountTypeUserData 소스와 매칭하고 있는 것을 볼 수 있습니다.

한 가지 사소한 최적화 작업을 해 보겠습니다. "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" 경로도 RallyDestinations.kt와 그 SingleAccount 객체:로 이동하겠습니다.

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

이번에도 상응하는 NavHost composable:에서 바꿉니다.

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments
) {...}

계좌 및 개요 시작 대상 설정하기

SingleAccountScreen으로 이동하는 데 필요한 SingleAccountScreen 경로와 여기에 필요한 인수를 정의했으니 이번에는 이전 대상에서(즉, 이전 대상이 어디였든) 동일한 accountTypeArg 인수가 전달되고 있는지 확인해야 합니다.

여기에는 두 가지 측면이 있습니다. 바로 인수를 제공하고 전달하는 시작 대상과, 인수를 받아서 올바른 정보를 표시하는 데 사용하는 도착 대상입니다. 두 가지 모두 명시적으로 정의해야 합니다.

예를 들어 Accounts 대상에서 '당좌' 계좌 유형을 탭하면 계좌 대상이 'Checking' 문자열을 인수로 전달하고 'single_account' 문자열 경로에 인수를 추가해야 상응하는 SingleAccountScreen을 성공적으로 열 수 있습니다. 문자열 경로는 "single_account/Checking"과 같습니다.

navController.navigateSingleTopTo(...),를 사용할 때도 다음과 같이 동일한 경로와 전달된 인수를 사용합니다.

navController.navigateSingleTopTo("${SingleAccount.route}/$accountType").

이 탐색 동작 콜백을 OverviewScreenAccountsScreenonAccountClick 매개변수에 전달합니다. 이러한 매개변수는 onAccountClick: (String) -> Unit으로 사전 정의되어 있고 입력으로 문자열을 받는 것을 볼 수 있습니다. 즉, 사용자가 OverviewAccount에서 특정 계좌 유형을 탭하면 그 즉시 이 계좌 유형 문자열을 사용하여 탐색 인수로 쉽게 전달할 수 있습니다.

OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)
// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)

가독성을 높이려면 이 탐색 동작을 비공개 도우미 확장 함수로 추출하면 됩니다.

import androidx.navigation.NavHostController
// ...
OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

이 시점에서 앱을 실행하면 각 계좌 유형을 클릭할 수 있고, 클릭하면 이 계좌의 데이터를 보여주는 상응하는 SingleAccountScreen이 표시됩니다.

개요 화면에서 클릭 대상으로 스크롤하고 클릭을 시도하는 모습의 화면 녹화. 이제 클릭하면 대상으로 이동합니다.

8. 딥 링크 지원 사용 설정

인수를 추가하는 것 외에도 딥 링크를 추가하여 특정 URL, 동작, MIME 유형을 컴포저블에 연결할 수 있습니다. Android에서 딥 링크란 앱 내의 특정 대상으로 직접 이동할 수 있는 링크입니다. Navigation Compose는 암시적 딥 링크를 지원합니다. 암시적 딥 링크가 호출되면(예: 사용자가 링크를 클릭할 때) Android는 앱의 상응하는 대상을 열 수 있습니다.

이 섹션에서는 상응하는 계좌 유형이 표시되는 SingleAccountScreen 컴포저블로 이동하기 위해 새로운 딥 링크를 추가하고 이 딥 링크가 외부 앱에도 노출되도록 사용 설정합니다. 이 컴포저블의 경로는 "single_account/{account_type}"이었습니다. 이 경로에 사소한 딥 링크 관련 변경사항을 적용하여 딥 링크로 사용할 것입니다.

외부 앱에 딥 링크를 노출하는 것은 기본적으로 사용 설정되지 않으므로, 이 섹션의 첫 번째 단계는 앱의 manifest.xml 파일에 <intent-filter> 요소를 추가하는 것입니다.

먼저 앱의 AndroidManifest.xml에 딥 링크를 추가합니다. <activity> 내에서 동작 VIEW와 카테고리 BROWSABLEDEFAULT를 사용하여 <intent-filter>를 통해 새 인텐트 필터를 만들어야 합니다.

그런 다음 필터 내에서 data 태그를 사용하여 scheme(rally - 앱의 이름) 및 host(single_account - 컴포저블의 경로)를 추가해야 구체적인 딥 링크가 정의됩니다. 이제 딥 링크 URL이 rally://single_account가 되었습니다.

AndroidManifest에서 account_type 인수를 선언할 필요는 없습니다. 이 인수는 나중에 NavHost 구성 가능한 함수 내에 추가됩니다.

<activity
    android:name=".RallyActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/app_name"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </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="rally" android:host="single_account" />
    </intent-filter>
</activity>

RallyActivity 내에서 이제 수신되는 인텐트에 반응할 수 있습니다.

컴포저블 SingleAccountScreen은 이미 인수를 받지만, 이에 더해 딥 링크가 트리거되면 대상을 실행할 수 있도록 새로 만든 딥 링크를 받아야 합니다.

SingleAccountScreen의 구성 가능한 함수 내에 deepLinks 매개변수를 추가합니다. 함수는 arguments,의 경우와 마찬가지로 navDeepLink의 목록도 받습니다. 따라서 같은 대상으로 연결되는 여러 개의 딥 링크를 정의할 수 있습니다. 매니페스트 rally://singleaccountintent-filter에 정의된 것과 일치하는 uriPattern을 전달합니다. 단 이번에는 accountTypeArg 인수도 추가해야 합니다.

import androidx.navigation.navDeepLink
// ...

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)

다음 단계는 무엇인지 아마 알고 계실 텐데요. 이 목록을 RallyDestinations SingleAccount:로 이동하면 됩니다.

object SingleAccount : RallyDestination {
    // ...
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
    val deepLinks = listOf(
       navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
    )
}

이번에도 역시, 상응하는 NavHost 컴포저블에서 바꿉니다.

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments,
    deepLinks = SingleAccount.deepLinks
) {...}

이제 앱과 SingleAccountScreen이 딥 링크를 처리할 준비가 되었습니다. 올바르게 작동하는지 테스트하려면 연결된 에뮬레이터나 기기에 Rally를 새로 설치하고 명령줄을 연 후 다음 명령어를 실행하여 딥 링크 실행을 시뮬레이션합니다.

adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW

그러면 '당좌 예금' 계좌로 곧바로 이동됩니다. 다른 모든 계좌 유형도 올바르게 작동하는지 확인해 보세요.

9. RallyNavHost에 NavHost 추출

이제 NavHost가 완성되었습니다. 여기서 추가로 RallyActivity를 테스트 가능하게 만들고 깔끔하게 유지하기 위해, 현재 NavHost와 도우미 함수(예: navigateToSingleAccount)를 RallyApp 컴포저블에서 자체 컴포저블 함수로 추출하여 이름을 RallyNavHost 라고 지정하겠습니다.

RallyAppnavController로 직접 사용할 수 있는 유일한 컴포저블입니다. 앞서 언급했듯이 다른 모든 중첩된 컴포저블 화면은 navController 자체가 아닌 탐색 콜백만 받아야 합니다.

따라서 새 RallyNavHostRallyApp에서 navControllermodifier를 매개변수로 받습니다.

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = modifier
    ) {
        composable(route = Overview.route) {
            OverviewScreen(
                onClickSeeAllAccounts = {
                    navController.navigateSingleTopTo(Accounts.route)
                },
                onClickSeeAllBills = {
                    navController.navigateSingleTopTo(Bills.route)
                },
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Accounts.route) {
            AccountsScreen(
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Bills.route) {
            BillsScreen()
        }
        composable(
            route = SingleAccount.routeWithArgs,
            arguments = SingleAccount.arguments,
            deepLinks = SingleAccount.deepLinks
        ) { navBackStackEntry ->
            val accountType =
              navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
            SingleAccountScreen(accountType)
        }
    }
}

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

이제 새 RallyNavHostRallyApp에 추가하고 앱을 다시 실행하여 모든 것이 전처럼 작동하는지 확인합니다.

fun RallyApp() {
    RallyTheme {
    ...
        Scaffold(
        ...
        ) { innerPadding ->
            RallyNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding)
            )
        }
     }
}

10. Compose Navigation 테스트

이 Codelab을 시작할 때부터 우리는 navController를 최상위 수준 앱을 제외한 어떤 컴포저블로도 직접 전달하지 않고 대신 탐색 콜백을 매개변수로 전달했습니다. 따라서 테스트에서 navController 인스턴스가 필요하지 않기 때문에 모든 컴포저블을 개별적으로 테스트할 수 있습니다.

항상 컴포저블로 전달된 RallyNavHost 및 탐색 동작을 테스트하여 Compose Navigation 메커니즘 전체가 앱에서 의도대로 작동하는지 테스트해 보아야 합니다. 이것이 이 섹션의 주요 목표입니다. 각 구성 가능한 함수를 개별적으로 테스트하려면 Jetpack Compose에서 테스트 Codelab을 확인하세요.

먼저 필요한 테스트 종속 항목을 추가해야 하니 app/build.gradle에 있는 앱의 빌드 파일로 돌아가겠습니다. 종속 항목 테스트 섹션에서 navigation-testing 종속 항목을 추가합니다.

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

NavigationTest 클래스 준비하기

RallyNavHostActivity와 별개로 테스트할 수 있습니다.

이 테스트는 Android 기기에서 실행되므로 테스트 디렉터리 /app/src/androidTest/java/com/example/compose/rally를 만든 다음 새로운 테스트 파일 테스트 클래스를 만들고 이름을 NavigationTest로 지정해야 합니다.

Compose 테스트 API를 사용하고 Compose를 사용하여 컴포저블과 애플리케이션을 테스트 및 제어하기 위한 첫 번째 단계로, Compose 테스트 규칙을 추가합니다.

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

첫 번째 테스트 작성하기

public rallyNavHost 테스트 함수를 만들고 @Test 주석을 추가합니다. 먼저 이 함수에서 테스트하려는 Compose 콘텐츠를 설정해야 합니다. composeTestRulesetContent를 사용하면 됩니다. setContent는 컴포저블 매개변수를 본문으로 받습니다. setContent를 사용하면 테스트 환경에서 일반적인 프로덕션 환경 앱에서처럼 Compose 코드를 작성하고 컴포저블을 추가할 수 있습니다.

setContent, 내에서 현재 테스트 제목인 RallyNavHost를 설정하고 여기에 새 navController의 인스턴스를 전달합니다. 탐색 테스트 아티팩트는 편리한 TestNavHostController를 제공합니다. 이 단계를 추가해 보겠습니다.

import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            // Creates a TestNavHostController
            navController =
                TestNavHostController(LocalContext.current)
            // Sets a ComposeNavigator to the navController so it can navigate through composables
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

위 코드를 복사했다면 fail() 호출은 실제 어설션이 만들어질 때까지 테스트가 실패하도록 합니다. 테스트 구현을 완료하라는 알림 역할을 합니다.

올바른 화면 컴포저블이 표시되는지 확인하려면 contentDescription을 사용하여 컴포저블이 표시되었는지 어설션하면 됩니다. 이 Codelab에서는 계좌 및 개요 대상의 contentDescription을 앞에서 설정했으므로 테스트 확인에 사용할 수 있습니다.

첫 번째 확인에서는 RallyNavHost가 처음으로 초기화될 때 첫 번째 대상으로 개요 화면이 표시되는지 확인해야 합니다. 이를 반영하기 위해 테스트의 이름을 rallyNavHost_verifyOverviewStartDestination으로 지정합니다. fail() 호출을 다음으로 바꿉니다.

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule.setContent {
            navController =
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }

        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

테스트를 다시 실행하고 통과했는지 확인합니다.

앞으로 만들 각 테스트에서도 RallyNavHost를 동일한 방식으로 설정해야 하므로, 불필요한 반복을 방지하고 테스트를 간결하게 유지하기 위해 초기화를 주석 처리된 @Before 함수로 추출합니다.

import org.junit.Before
// ...

class NavigationTest {

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

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

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

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

UI 클릭 및 화면 contentDescription을 통해 테스트하기

앱 구현을 테스트할 때는 UI를 클릭하는 방법을 사용하는 것이 좋습니다. 다음 텍스트는 개요 화면에서 계좌 하위 섹션의 '모두 보기' 버튼을 클릭하면 계좌 대상으로 이동한다는 사실을 확인할 수 있습니다.

5a9e82acf7efdd5b.png

이번에도 OverviewScreenCard 컴포저블에서 이 특정 버튼의 contentDescription을 사용하여 performClick()을 통해 클릭을 시뮬레이션하고 계좌 대상이 표시되는지 확인합니다.

import androidx.compose.ui.test.performClick
// ...

@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

이 패턴에 따라 앱의 다른 모든 클릭 탐색 동작을 테스트할 수 있습니다.

UI 클릭 및 경로 비교를 통해 테스트하기

navController를 사용하여 현재 문자열 경로를 예상 경로와 비교함으로써 어설션을 확인할 수도 있습니다. 이렇게 하려면 앞 섹션에서처럼 UI를 클릭한 다음 navController.currentBackStackEntry?.destination?.route를 사용하여 현재 경로를 예상 경로와 비교합니다.

한 가지 추가적인 단계로, 먼저 개요 화면에서 청구서 하위 섹션으로 스크롤해야 합니다. 그러지 않으면 contentDescription이 'All Bills'인 노드를 찾을 수 없어 테스트가 실패합니다.

import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...

@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
    composeTestRule.onNodeWithContentDescription("All Bills")
        .performScrollTo()
        .performClick()

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

이 패턴에 따라 추가 탐색 경로, 대상, 클릭 동작을 포함하는 클래스를 테스트할 수 있습니다. 모든 테스트를 실행하여 통과하는지 확인하세요.

11. 축하합니다

축하합니다. 이 Codelab을 완료했습니다. 여기에서 솔루션 코드를 찾아서 직접 만든 코드와 비교해 보세요.

Rally 앱에 Jetpack Compose 탐색을 추가해 보며 주요 개념을 익혔습니다. 컴포저블 대상의 탐색 그래프를 설정하고, 탐색 경로와 동작을 정의하고, 인수를 통해 경로에 추가 정보를 전달하고, 딥 링크를 설정하고, 탐색을 테스트하는 방법을 알아보았습니다.

하단 탐색 바 통합, 다중 모드 탐색, 중첩된 그래프와 같은 더 많은 주제와 정보를 살펴보려면 Now in Android GitHub 저장소에서 어떻게 구현되었는지 확인해 보세요.

다음 단계

다음과 같은 자료를 참고하여 Jetpack Compose 학습 개발자 과정을 이어가세요.

Jetpack 탐색에 관한 추가 정보:

참고 문서