컴포즈 안에서의 내비게이션에 관해 살펴본다. 경로, 내비게이션 그래프, 내비게이션 백 스택, 인자 전달, NavHostController와 NavHost 클래스를 학습한다.
홈 화면을 포함해 앱을 구성하는 각 화면은 목적지라 불리며, 일반적으로 하나의 컴포저블 또는 액티비티다. 안드로이드 내비게이션 아키텍처에서는 내비게이션 백 스택을 이용해 앱 안에서 목적지에 이르는 사용자의 경로를 추적한다. 앱이 처음 실행되면 홈 화면이 현재 목적지가 되고 이후 다른 화면으로 이동할 때마다 해당 화면들은 스택에 쌓인다. 사용자가 시스템의 뒤로 가기 버튼을 이용해 화면을 거꾸로 이동하면, 스택의 목적지에 홈 화면만 남을 때까지 스택에서 화면을 하나씩 꺼낸다.
목적지 사이의 이동과 내비게이션 스택 관리와 관련된 모든 작업은 하나의 내비게이션 컨트롤러에 의해 처리되며, 이 컨트롤러는 NavHostController 클래스에서 제공된다. 이를 이용하면 사용자가 뒤로 가기를 눌렀을 때 스택의 낮은 위치 화면을 반환하도록 할 수 있다.
앱 프로젝트에서 내비게이션을 추가할 때는 가장 먼저 NavHostController 인스턴스를 만든다. 이 인스턴스는 백 스택을 관리하고 현재 목적지가 어떤 컴포저블인지 추적한다. NavHostController는 상태 객체이며 다음과 같이 선언한다.
val navController = rememberNavController()
내비게이션 호스트(NavHost)는 액티비티의 사용자 레이아웃에 추가되는 특별한 컴포넌트로, 사용자가 이동할 목적지의 플레이스홀더 역할을 한다. NavHost를 호출할 때는 NavHostController 인스턴스를 전달해야 한다. NavHostController 인스턴스는 시작 목적지와 내비게이션 그래프 역할을 하는 컴포저블이다. 내비게이션 그래프는 내비게이션 컨트롤러의 컨텍스트 안에서 이동 가능한 목적지로 이용할 수 있는 모든 컴포저블로 구성된다. 이 목적지들은 경로 형태로 선언된다.
NavHost(nacController = navController, startDestination = <시작 경로>) {
// 내비게이션 그래프 목적지
}
composable() 메서드를 호출하고 경로와 목적지를 전달해 내비게이션에 목적지를 추가할 수 있다. 경로는 고유한 목적지를 식별할 수 있는 문자열 값이며, 목적지는 내비게이션을 수행할 때 호출되는 컴포저블이다. 다음 예시는 시작 목적지가 "home"으로 설정된 3개의 목적지로 구성된 내비게이션 그래프를 포함한다.
NavHost(navController = navController, startDestination = "home") {
composable("home") {
Home()
}
composable("customers") {
Customers()
}
composable("purchases") {
Purchases()
}
}
위처럼 경로 문자열을 composable() 메서드 호출에 직접 입력하는 대신 sealed 클래스를 이용해 좀 더 유연하게 경로를 정의할 수 있다.
sealed class Routes(val route: String) {
object Home: Routes("home")
object Customers: Routes("customers")
object Purchases: Routes("purchases")
}
위의 클래스를 선언한 뒤 NavHost는 다음과 같이 경로를 참조할 수 있다.
NavHost(navController = navController, startDestination = Routes.Home.route) {
composable(Routes.Home.route) {
Home()
}
composable(Routes.Customers.route) {
Customers()
}
composable(Routes.Purchases.route) {
Purchases()
}
}
sealed 클래스를 이용하면 단일 위치를 이용해 경로를 변경할 수 있고 구문 검증이 포함되므로 NavHost를 만들거나 내비게이션을 수행할 때 경로의 오타를 피할 수 있다는 장점이 존재한다.
내비게이션 컨트롤러 인스턴스의 navigate() 메서드를 호출하고 목적지 컴포저블의 경로를 지정하면 내비게이션이 시작된다. 다음은 Button 컴포넌트를 클릭했을 때 고객 화면으로 이동하도록 설정하는 코드이다.
Button(
onClick = {
navController.navigate(Routes.Customers.route)
}
) {
Text(text = "Navigate to Customers")
}
내비게이션 옵션 중에는 popUpTo() 함수가 포함된다. 이 옵션을 이용하면 아이템을 스택에서 꺼내고 특정한 목적지로 돌아갈 수 있다. 예를 들어 홈 화면 -> 고객 화면 -> 구매 화면으로 이동하는 시나리오에서 구매 화면으로 이동하기 전에 모든 목적지를 스택에서 꺼내, 백 스택에 홈 목적지만 남아 있도록 할 수 있다.
Button(
onClick = {
navController.navigate(Routes.Customers.route) {
popUpTo(Routes.Home.route)
}
}
) {
Text(text = "Navigate to Customers")
}
위의 코드를 사용하면 구매화면에서 뒤로 가기 버튼으로 홈 화면으로 곧바로 이동한다. popUpTo() 메서드도 옵션을 받는다. 다음은 inclusive 옵션을 이용해 내비게이션을 수행하기 전에 홈 목적지를 스택에서 꺼내는 코드이다.
Button(
onClick = {
navController.navigate(Routes.Customers.route) {
popUpTo(Routes.Home.route) {
inclusive = true
}
}
}
) {
Text(text = "Navigate to Customers")
}
현재 목적지에서 자기 자신으로 이동하고자 하면 기본적으로 자신을 목적지로 하는 추가 인스턴스를 넣는다. 그러나 이는 대부분의 상황에서 바람직하지 않다. 따라서 이를 방지하기 위해 navigate() 메서드 호출 시 launchSingleTop 옵션을 전달한다.
Button(
onClick = {
navController.navigate(Routes.Customers.route) {
launchSingleTop = true
}
}
) {
Text(text = "Navigate to Customers")
}
이것 외에도 saveState, restoreState 옵션을 true로 설정하면 사용자가 이전에 선택했던 목적지를 다시 선택하는 경우 백 스택 항목 상태를 자동으로 저장하고 복원한다.
한 화면에서 다른 화면으로 이동할 때는 목적지에 인수를 전달한다. 예를 들어, 선택된 고객의 이름을 고객 화면에서 구매 화면으로 전달해, 올바를 구매 이력이 표시되게 할 수 있다.
인수를 포함한 내비게이션을 구현하기 위해 가장 먼저 목적지 경로에 인수 이름을 추가해야 한다.
NavHost(navController = navController, startDestination = Routes.Home.route) {
composable(Routes.Purchases.route + "/{customerName}") { backStackEntry ->
val custromerName = backStackEntry.arguments?.getString("customerName")
Purchases(customerName)
}
}
앱이 고객 목적지로의 이동을 트리거하면 해당 인수에 할당된 값은 해당하는 백 스택 항목 안에 저장된다. 현재 내비게이션에 대한 백 스택 항목을 파라미터로 composable() 메서드의 후행 람다에 전달되고, 람다에서 추출된 파라미터는 Customer 컴포저블로 전달될 수 있다.
기본적으로 내비게이션 인수는 String 타입이라 가정한다. 다른 타입의 인수를 전달할 때는 composable() 메서드의 arguments 파라미터를 통해 NavType 열거형을 이용해 타입을 지정해야 한다.
composable(Routes.Purchases.route + "/{customerId}",
arguments = listOf(navArgument("customerId") {type = NavType.IntType})) { navBackStack ->
Customers(navBackStack.arguments?.getInt("customerId"))
}
위 예시는 파라미터 타입을 Int 타입으로 선언한 예시다.
문자열 인수 예시에서 Purchases 컴포저블이 String 파라미터를 받는 코드는 다음과 같다
@Composable
fun Customers(customerName: String?) {
.
.
.
}
마지막으로, navigate() 메서드를 호출할 때 인숫값을 전달해야 한다. 인수를 전달하는 방법은 목적지의 끝에 인숫값을 추가하면 된다. 구매 화면에 전달해야 하는 값은 selectedCustomer라는 이름의 상태 변수에 저장되어 있다고 가정한다.
var selectedCustomer by remember { mutableStateOf("") }
Button(
onClick = {
navController.navigate(Routes.Customers.route + "/selectedCustomer")
}
) {
Text(text = "Navigate to Customer")
}
위의 버튼을 클릭하면 다음과 같은 순서로 이벤트가 발생한다.
1. 현재 목적지에 대한 백 스택 항목이 만들어진다.
2. 현재 selectedCustomer 상탯값이 백 스택 항목에 저장된다.
3. 백 스택 항목이 백 스택에 추가된다.
4. NavHost 선언 안의 구매 경로에 대한 composable() 메서드가 호출된다.
5. composable() 메서드의 후행 람다가 백 스택 항목에서 인수를 추출해 Purchases 컴포저블에 전달한다.
하단 내비게이션 바의 핵심 컴포넌트는 컴포즈의 BottomNavigation과 BottomNavigationItem 컴포넌트다. 이들을 구현할 때는 전형적으로 하나의 부모 BottomNavigationBar가 forEach 루프를 포함하고, 이 루프를 통해 BottomNavigationItem 자식들을 만든다. 각 자식에 표시할 라벨과 아이콘이 설정되면 onClick 핸들러를 이용해 해당하는 목적지로 이동을 수행한다.
BottomNavigation {
<아이템 목록>.forEach { navItem ->
BottomNavigationItem (
selected = <true | false>,
onClick = {
navController.navigate(navItem.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
<아이콘>
},
label = {
<텍스트>
}
)
}
}
popUpTo() 메서드를 호출해서 사용자가 뒤로 가기 버튼을 클릭하면 시작 목적지로 이동하도록 설정하였다. findStartDestination() 메서드를 호출해 시작 목적지를 식별할 수 있다.
하단 내비게이션 바를 이용하려면 launchSingleTop, saveState, restoreState를 활성화해야 한다.
각 BottomNavigationItem은 selected 프로퍼티를 통해 현재 선택되어 있는 아이템인지 전달해야 한다. 하단 내비게이션 바를 다룰 때는 해당 아이템과 연결된 경로와 현재 경로 선택을 비교하는 코드를 작성해야 한다. 현재 경로 선택은 내비게이션 컨트롤러의 currentBackStackEntryAsState() 메서드를 통해 백 스택에 접근하고 목적지 경로 프로퍼티에 접근해서 얻을 수 있다.
BottomNavigation {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
NavBarItems.BarItems.forEach { navItem ->
BottomNavigationItem(
selected = currentRoute == navItem.route
.
.
.
}
}
두 경로를 비교한 결과는 selected 프로퍼티에 할당된다.