티스토리 뷰

RallyDestinations

이번 코드랩에서 배울 내용

  • Basics of using Jetpack Navigation with Jetpack Compose
  • Navigating between composables
  • Integrating a custom tab bar composable into your navigation hierarchy
  • Navigating with arguments
  • Navigating using deep links
  • Testing navigation

샘플 프로젝트는 네비게이션이 되고 있지만, 사실 컴포즈 네비게이션을 사용한 구현이 아니다. 이를 컴포즈 네비게이션으로 구현해보자.

Migrating to Compose Navigation

점진적으로 마이그레이션을 해보자!

의존성 추가 해주기 Add the Navigation dependency

프로젝트 수준 gradle

buildscript {
    ext {
        // ...
        composeNavigationVersion = '2.5.0-rc01' // 현재는 2.5.3 이 최신 인듯.

앱 수준 gradle

dependencies {
  implementation "androidx.navigation:navigation-compose:$rootProject.composeNavigationVersion"
  // ...
}

Set up the NavController

NavController 가 네비게이션의 핵심 컴포넌트이다. 백스택을 기억하고, 이동가능하게 해준다. rememberNavController() 를 통해 얻을 수 있다.

navController 는 항상 최상위 컴포저블에 선언해야한다. (즉 주로, App 컴포저블에) 이는 다음 원칙을 만족한다. (아래)

  • 상태 호이스팅
  • main source of truth

Routes in Compose Navigation

각각의 컴포저블 네비게이션 도착지는 route 와 관련있다. route 는 String 으로 정의하는데, 컴포저블의 경로를 설정하고 navController 에게 알맞은 도착지를 알려주게된다. 절대경로를 갖는 딥링크랑 비슷하다고 생각할 수도 있다. 각 도착지는 유니크한 네임을 가져야 한다.

Calling the NavHost composable with the navigation graph

네비게이션의 3가지 주요 키워드

  • NavController
  • NavGraph
  • NavHost

NavController 는 항상 하나의 NavHost 와 연관되어 진다.

NavHost 는 컨테이너 처럼 작동하며, 현재 목적지 화면을 보여주는 책임이 있다. 여러 컴포저블을 네비게이트 하면서 NavHost 는 자동으로 리컴포즈된다. 또한 NavController 는 네비게이션 그래프인 NavGraph 와 연결된다. NavGraph 는 네비게이션 간에 컴포저블 도착지를 매핑합니다. (본질적으로 가져올 수 있는 대상의 컬렉션.)

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
    }
}

위에서 선언헤주었던 navController 를 NavHost 와 연결해준다. (또한 Scaffold 의 innerPadding 도 받아들여준다.)

Adding destinations to the NavGraph

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

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

빌더인 후행 람다에서 composable 확장 메서드를 사용하여 적절한 화면을 보여준다.

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()
    }
}

도착지가 3개 이므로 더 추가해준다.

5. Integrate RallyTabRow with navigation

앱을 테스트 하기 쉽고 재사용하기 좋게 만들려면 navController 를 직접 전달하는 것은 삼가야한다. 그 대신 콜백을 전달하자.

@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,
                )
            }

onTabSelected 콜백으로 navController 를 사용하여 다른 화면으로 전환될 수 있게 하자.

이제 탭 시 네비게이션이 잘 동작은 하지만, 몇가지 문제점이 있따. (아래 목록)

  1. 같은 탭을 한번 더 탭하면 또 열린다. (백스택에 계속 쌓임)
  2. 아이콘 펼쳐짐 동작이 되지 않는다.

Launching a single copy of a destination

첫 번째 이슈를 해결해보자.

import androidx.navigation.NavHostController
// ...

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

singleTop 으로 실행할 수 있게 헬퍼 확장 함수를 하나 정의하고

@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,
                )
            }

이를 사용하면 간단하게 해결할 수 있다.

Controlling the navigation options and back stack state

사실 launchSingleTop 옵션 말고도 옵션 빌더에서 사용할 수 있는 다양한 옵션들이 있다.

  • launchSingleTop = true
  • 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
}
  • popUpTo 부분은 닫힐 때 어디로 이동될지 정해주는데, 여기서는 그래프의 가장 시작점으로 이동시켜준다. 예를들면,
    • A → B → C → Back key 시 A 로 이동된다. 여기서는 항상 overView(A) 페이지로 이동된다.
    • back key 를 두번 누르면 항상 앱이 내려간다. (A 로 이동되고, 한 번 더 back key 시 백스택이 없기 때문)
  • saveState, restoreState 의 경우에는 스크롤을 내린 상태 등, 상태를 기억하고 탭 이동 후에 다시 복귀할 때 상태(스크롤 등) 를 유지해준다.

Note: If you need more guidance on managing multiple back stacks, take a look at the documentation on supporting multiple backstacks.

Fixing the tab UI

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
        // ...
    }
}

currentBackStackEntryAsState 을 통해서 백스택 을 state 로 가져올 수 있다.

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
        // ...
    }
}

유니크한 route 를 이용해서 현재 화면을 찾는다.

참고: 이 시점에서 탐색 구성 요소를 통해 뒤로 동작 탐색도 무료로 지원됩니다. 추가 설정을 수행할 필요가 없습니다. 목적지 사이를 전환한 다음 뒤로 버튼을 누르면 백 스택이 올바르게 팝업되어 이전 목적지로 이동합니다.

6. Extracting screen composables from RallyDestinations

RallyDestination 과 커플링을 제거하여, 클릭 이벤트 콜백 등을 추가 시 용이하게 한다.

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()
    }
}

Enable clicks on OverviewScreen

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

onClick 콜백을 연결 시켜 화면 이동을 적용한다.

(navController 가 상위에 (app 에) 호이스팅 되어있으므로, 재사용이 용이하다. mock 인스턴스나 직접 생성해줄 필요 없이 콜백 만으로 이벤트 변경을 쉽게 할 수 있다!)

7. Navigating to SingleAccountScreen with arguments : 인자를 추가하여 네비게이션 하기

arguments 를 통해 특정 아이템을 클릭 했는지 정보를 담을 수 있다. 한개 또는 그 이상의 argument 를 통해서 네비게이션의 라우팅을 다이나믹하게 할 수 있다.

{argument} 형식으로 지정하면 된다.

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

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

기존 NavHost 에 새로운 화면의 route 를 추가해준다.

Set up the SingleAccountScreen landing destination

"route/{argument}" 같은 패턴으로 인자를 추가한다.

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

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

arguments 를 지정함으로써 컴포저블이 이를 알게 할 수 있다.

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

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

이때 ****타입을 지정하지 않으면 알아서 추론된다.****

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

현재 샘플 프로젝트 구조상 RallyDestination 에 모든 라우트 정보가 들어가므로, 여기에 인자 리스트도 추가한다.

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

이를 다시 navHost 안의 arguments 가 참조하도록 해주면 코드가 깔끔해진다.

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)
    }
}

navBackStackEntry 에서 인자를 찾아서 가져온다.

object SingleAccount : RallyDestination {
    // Added for simplicity, this icon will not in fact be used, as SingleAccount isn't
    // part of the RallyTabRow selection
    override val icon = Icons.Filled.Money
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

routeWithArgs 를 가지도록 리팩토링 하자.

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

SingleAccount.routeWithArgs 참조로 리팩토링.

인자 활용한 네비게이션 컨트롤 적용 Setup the Accounts and Overview starting destinations

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

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

refactoring

중복을 제거한다.

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")
}

8. Enable deep link support 딥링크를 지원 하는 방법

안드로이드 에서는 딥링크로 특정 앱의 특정 화면으로 이동시킬 수 있는데 이 또한 네비게이션으로 적용할 수 있다. (명시적 딥링크를 지원 한다.)

딥링크는 기본적으로 비활성화 되어 있음. 인텐트 필터를 추가해야 활성화 된다.

  • 메니페스트 안에 액티비티 안에 인텐트 필터를 추가한다.
  • data 태그로 스킴을 추가한다.
<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>

Trigger and verify the deep link

  • 이제 딥링크에 반응할 수 있게 되었다!
  • 하나의 컴포저블 목적지에 여러 딥링크를 연관지어줄 수도 있다
  • uri 패턴으로 전달해주면 된다.
import androidx.navigation.navDeepLink
// ...

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

refactoring

물론 리팩토링도 잊지 말자. ㅋㅋ

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

adb 로 딥링크 테스트 하기 : Test the deep link using adb

adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking }

앱 을 pc에 물리고, 커맨드에 위 커맨드를 입력해주면 딥링크가 잘 실행된다!

Last login: Mon Nov 28 10:41:57 on console

The default interactive shell is now zsh.
To update your account to use zsh, please run `chsh -s /bin/zsh`.
For more details, please visit https://support.apple.com/kb/HT208050.
LM-046905-00:~ sangsulee$ adb
-bash: adb: command not found
LM-046905-00:~ sangsulee$ export PATH=$PATH:/Users/
.localized     Guest/         Shared/        administrator/ sangsulee/
LM-046905-00:~ sangsulee$ export PATH=$PATH:/Users/sangsulee/Library/Android/sdk/platform-tools/
LM-046905-00:~ sangsulee$ adb
Android Debug Bridge version 1.0.41
Version 33.0.3-8952118
Installed as /Users/sangsulee/Library/Android/sdk/platform-tools/adb

global options:
 -a                       listen on all network interfaces, not just localhost
 -d                       use USB device (error if multiple devices connected)
 -e                       use TCP/IP device (error if multiple TCP/IP devices available)
 -s SERIAL                use device with given serial (overrides $ANDROID_SERIAL)
 -t ID                    use device with given transport id
 -H                       name of adb server host [default=localhost]
 -P                       port of adb server [default=5037]
 -L SOCKET                listen on given socket for adb server [default=tcp:localhost:5037]
 --one-device SERIAL|USB  only allowed with 'start-server' or 'server nodaemon', server will only connect to one USB device, specified by a serial number or USB device address.
 --exit-on-write-error    exit if stdout is closed

general commands:
 devices [-l]             list connected devices (-l for long output)
 help                     show this help message
 version                  show version num

networking:
 connect HOST[:PORT]      connect to a device via TCP/IP [default port=5555]
 disconnect [HOST[:PORT]]
     disconnect from given TCP/IP device [default port=5555], or all
 pair HOST[:PORT] [PAIRING CODE]
     pair with a device for secure TCP/IP communication
 forward --list           list all forward socket connections
 forward [--no-rebind] LOCAL REMOTE
     forward socket connection using:
       tcp:<port> (<local> may be "tcp:0" to pick any open port)
       localabstract:<unix domain socket name>
       localreserved:<unix domain socket name>
       localfilesystem:<unix domain socket name>
       dev:<character device name>
       jdwp:<process pid> (remote only)
       vsock:<CID>:<port> (remote only)
       acceptfd:<fd> (listen only)
 forward --remove LOCAL   remove specific forward socket connection
 forward --remove-all     remove all forward socket connections
 reverse --list           list all reverse socket connections from device
 reverse [--no-rebind] REMOTE LOCAL
     reverse socket connection using:
       tcp:<port> (<remote> may be "tcp:0" to pick any open port)
       localabstract:<unix domain socket name>
       localreserved:<unix domain socket name>
       localfilesystem:<unix domain socket name>
 reverse --remove REMOTE  remove specific reverse socket connection
 reverse --remove-all     remove all reverse socket connections from device
 mdns check               check if mdns discovery is available
 mdns services            list all discovered services

file transfer:
 push [--sync] [-z ALGORITHM] [-Z] LOCAL... REMOTE
     copy local files/directories to device
     --sync: only push files that are newer on the host than the device
     -n: dry run: push files to device without storing to the filesystem
     -z: enable compression with a specified algorithm (any/none/brotli/lz4/zstd)
     -Z: disable compression
 pull [-a] [-z ALGORITHM] [-Z] REMOTE... LOCAL
     copy files/dirs from device
     -a: preserve file timestamp and mode
     -z: enable compression with a specified algorithm (any/none/brotli/lz4/zstd)
     -Z: disable compression
 sync [-l] [-z ALGORITHM] [-Z] [all|data|odm|oem|product|system|system_ext|vendor]
     sync a local build from $ANDROID_PRODUCT_OUT to the device (default all)
     -n: dry run: push files to device without storing to the filesystem
     -l: list files that would be copied, but don't copy them
     -z: enable compression with a specified algorithm (any/none/brotli/lz4/zstd)
     -Z: disable compression

shell:
 shell [-e ESCAPE] [-n] [-Tt] [-x] [COMMAND...]
     run remote shell command (interactive shell if no command given)
     -e: choose escape character, or "none"; default '~'
     -n: don't read from stdin
     -T: disable pty allocation
     -t: allocate a pty if on a tty (-tt: force pty allocation)
     -x: disable remote exit codes and stdout/stderr separation
 emu COMMAND              run emulator console command

app installation (see also `adb shell cmd package help`):
 install [-lrtsdg] [--instant] PACKAGE
     push a single package to the device and install it
 install-multiple [-lrtsdpg] [--instant] PACKAGE...
     push multiple APKs to the device for a single package and install them
 install-multi-package [-lrtsdpg] [--instant] PACKAGE...
     push one or more packages to the device and install them atomically
     -r: replace existing application
     -t: allow test packages
     -d: allow version code downgrade (debuggable packages only)
     -p: partial application install (install-multiple only)
     -g: grant all runtime permissions
     --abi ABI: override platform's default ABI
     --instant: cause the app to be installed as an ephemeral install app
     --no-streaming: always push APK to device and invoke Package Manager as separate steps
     --streaming: force streaming APK directly into Package Manager
     --fastdeploy: use fast deploy
     --no-fastdeploy: prevent use of fast deploy
     --force-agent: force update of deployment agent when using fast deploy
     --date-check-agent: update deployment agent when local version is newer and using fast deploy
     --version-check-agent: update deployment agent when local version has different version code and using fast deploy
     --local-agent: locate agent files from local source build (instead of SDK location)
     (See also `adb shell pm help` for more options.)
 uninstall [-k] PACKAGE
     remove this app package from the device
     '-k': keep the data and cache directories

debugging:
 bugreport [PATH]
     write bugreport to given PATH [default=bugreport.zip];
     if PATH is a directory, the bug report is saved in that directory.
     devices that don't support zipped bug reports output to stdout.
 jdwp                     list pids of processes hosting a JDWP transport
 logcat                   show device log (logcat --help for more)

security:
 disable-verity           disable dm-verity checking on userdebug builds
 enable-verity            re-enable dm-verity checking on userdebug builds
 keygen FILE
     generate adb public/private key; private key stored in FILE,

scripting:
 wait-for[-TRANSPORT]-STATE...
     wait for device to be in a given state
     STATE: device, recovery, rescue, sideload, bootloader, or disconnect
     TRANSPORT: usb, local, or any [default=any]
 get-state                print offline | bootloader | device
 get-serialno             print <serial-number>
 get-devpath              print <device-path>
 remount [-R]
      remount partitions read-write. if a reboot is required, -R will
      will automatically reboot the device.
 reboot [bootloader|recovery|sideload|sideload-auto-reboot]
     reboot the device; defaults to booting system image but
     supports bootloader and recovery too. sideload reboots
     into recovery and automatically starts sideload mode,
     sideload-auto-reboot is the same but reboots after sideloading.
 sideload OTAPACKAGE      sideload the given full OTA package
 root                     restart adbd with root permissions
 unroot                   restart adbd without root permissions
 usb                      restart adbd listening on USB
 tcpip PORT               restart adbd listening on TCP on PORT

internal debugging:
 start-server             ensure that there is a server running
 kill-server              kill the server if it is running
 reconnect                kick connection from host side to force reconnect
 reconnect device         kick connection from device side to force reconnect
 reconnect offline        reset offline/unauthorized devices to force reconnect

usb:
 attach                   attach a detached USB device
 detach                   detach from a USB device to allow use by other processes
environment variables:
 $ADB_TRACE
     comma-separated list of debug info to log:
     all,adb,sockets,packets,rwx,usb,sync,sysdeps,transport,jdwp
 $ADB_VENDOR_KEYS         colon-separated list of keys (files or directories)
 $ANDROID_SERIAL          serial number to connect to (see -s)
 $ANDROID_LOG_TAGS        tags to be used by logcat (see logcat --help)
 $ADB_LOCAL_TRANSPORT_MAX_PORT max emulator scan port (default 5585, 16 emus)
 $ADB_MDNS_AUTO_CONNECT   comma-separated list of mdns services to allow auto-connect (default adb-tls-connect)
LM-046905-00:~ sangsulee$ -bash: adb: command not found
-bash: -bash:: command not found
LM-046905-00:~ sangsulee$ export PATH=$PATH:/Users/sangsulee/Library/Android/sdk/platform-tools/
LM-046905-00:~ sangsulee$ adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking }
Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking flg=0x10000000 }
LM-046905-00:~ sangsulee$ 
LM-046905-00:~ sangsulee$ adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking }
Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking flg=0x10000000 }
LM-046905-00:~ sangsulee$ adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking }
Error: Activity not started, unable to resolve Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking flg=0x10000000 }
LM-046905-00:~ sangsulee$ adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Starting: Intent { act=android.intent.action.VIEW dat=rally://single_account/Checking }
LM-046905-00:~ sangsulee$

띄어 쓰기 포함된 String 은 불가능 한듯,,,?

→ %20 으로 가능

9. Extract the NavHost into RallyNavHost 코드를 좀 더 깔끔하게 만들어 보자!

@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")
}

RallyNavHost 를 Rally app 에서 분리

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

10. Testing Compose Navigation

테스트를 위해서 의존성 추가

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

Prepare the NavigationTest class

컴포즈를 테스트 하기위해 컴포즈 rule 를 적용한다.

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

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

Write your first test

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()
    }
}
  • composeTestRule 의 setContent 를 사용하면 실제 환경 처럼 테스트 환경에서도 컴포즈 코드를 구성할 수 있다.
  • TestNavHostController 로 테스트 가능한 NavController 를 생성해줄 수 있다.
  • fail() 은 테스트를 실패시키는데, 구현 전에 실패되어야 하기 때문에 임시적으로 추가해둔 것.
  • 검증 하는 테스트 코드는 setContent 밖에서 해주어야 한다.
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()
    }
}
  • 첫 화면이 Overview Screen 인 것을 테스트 하는 코드
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()
    }
}
  • @Before 를 통해서 사전 준비 코드(공통된 부분) 을 작성하여 보일러 플레이트를 제거할 수 있다.

Navigating in tests

  • 클릭 하는 테스트도 작성할 수 있다.

Testing via UI clicks and screen contentDescription

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

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

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}
  • 특정 버튼을 클릭하고 나서
  • 다음 화면으로 이동되는 것을 검증하는 테스트

Testing via UI clicks and routes comparison

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")
}
  • 다음은 조금 다른 방법으로 확인하는 테스트 코드인데
  • Bills 의 SEE ALL 을 누르기 위해서는 스크롤을 해주어야 하는데 이때는 performScrollTo 를 사용할 수 있고
  • navController.currentBackStackEntry?.destination?.route 를 통해 bills 로 이동하였는지를 검증한다.

Reference

 

Jetpack Compose 탐색  |  Android Developers

이 Codelab에서는 Compose의 탐색 기본사항을 알아봅니다.

developer.android.com

2022.12.18 - [Android/Jetpack Compose] - [Jetpack Compose] 상태 및 사이드이팩트 고급 내용 정리 (State, SideEffects)

2022.12.17 - [Android/Jetpack Compose] - [Jetpack Compose] 애니메이션 활용 및 기본 내용 정리

2022.12.16 - [Android/Jetpack Compose] - [Jetpack Compose] view + xml 기반에서 컴포즈로 마이그레이션

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함