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
를 사용하여 다른 화면으로 전환될 수 있게 하자.
이제 탭 시 네비게이션이 잘 동작은 하지만, 몇가지 문제점이 있따. (아래 목록)
- 같은 탭을 한번 더 탭하면 또 열린다. (백스택에 계속 쌓임)
- 아이콘 펼쳐짐 동작이 되지 않는다.
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
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 기반에서 컴포즈로 마이그레이션
'Android > Jetpack Compose' 카테고리의 다른 글
[Jetpack Compose] 상태 및 사이드이팩트 고급 내용 정리 (State, SideEffects) (0) | 2022.12.18 |
---|---|
[Jetpack Compose] 애니메이션 활용 및 기본 내용 정리 (0) | 2022.12.17 |
[Jetpack Compose] view + xml 기반에서 컴포즈로 마이그레이션 (1) | 2022.12.16 |
[Jetpack Compose] 테마 Theming 기본 정리 (0) | 2022.12.15 |
[Jetpack Compose] compose basic state (상태의 기본 내용 정리) (1) | 2022.12.14 |