프로젝트 내에서 각 화면은 컴포저블로 구현되고 각 화면은 목적지(destination)라고 불립니다.
안드로이드 네비게이션 아키텍처에서는 네비게이션 백 스택을 이용해서 화면인 목적지가 이동될 때마다 백 스택에 쌓이게 됩니다. 앱이 처음 실행되면 홈 화면이 현재 목적지(current destination)가 되면서 백 스택에 쌓입니다. 사용자가 다른 목적지로 이동하게 되면 해당 화면이 현재 목적지가 되고 해당 화면도 백 스택에 쌓이게 됩니다.
위 그림은 채팅방에서 채팅 설정 화면으로 화면이 전환되는 경우의 네비게이션 백 스택의 상태입니다.
사용자가 시스템의 뒤로 가기 버튼으로 화면을 거꾸로 이동한다면 스택의 목적지에 홈 화면만 남을 때까지 백 스택에서 화면을 하나씩 꺼냅니다.
위 그림은 채팅 설정 화면에서 채팅방으로 돌아갈 때 네비게이션 백 스택의 상태입니다.
기본적으로 목적지 간 이동과 네비게이션 백 스택에 관련된 작업은 navController
에 의해서 처리됩니다.
NavController
은 NavHostController
클래스의 인스턴스로 rememberNavHostConroller()
함수를 호출해서 만들 수 있습니다.
NavHostController
인스턴스를 통해 백 스택을 관리하고 현재 목적지가 어떤 컴포저블인지 추적합니다. 이를 통해서 재구성하는 동안에도 백 스택의 무결성을 보장합니다.
val navConroller = rememberNavController()
NavController
를 만들었다면 NavHost
인스턴스에 할당해야 합니다. NavHost
는 현재 목적지를 표시하는 빈 컨테이너 역할을 하는 컴포저블입니다.
NavHost(
navController,
startDestination,
modifier,
) {
content
}
navController
: NavHostController
클래스의 인스턴스입니다. navigate()
함수를 호출해서 다른 목적지로 이동하는데, 이 객체를 활용할 수 있습니다. rememberNavHostController()
함수를 호출해서 NavHostController
를 가져올 수 있습니다.
startDestination
: 앱에서 NavHost
를 처음 표시할 때 기본적으로 표시되는 목적지를 정의하는 문자열 경로입니다.
NavGraph
는 NavController
의 컨텍스트 안에서 이동할 수 있는 목적지로 이용할 수 있는 모든 컴포저블로 구성됩니다. 이 목적지를 경로(route) 형태로 선언됩니다.
경로는 고유한 목적지를 식별할 수 있는 문자열입니다. (이 문자열은 URL 개념과 유사합니다.)
object MyRoute {
const val HOME = "home"
const val CHAT = "chat"
const val PROFILE = "profile"
const val SETTINGS = "settings"
}
Object를 사용해서 이동할 수 있는 모든 목적지에 대해서 경로를 정의합니다.
NavHost
에서 경로를 정의할 때는 composable()
함수를 호출합니다. composable()
함수에는 필수 매개변수가 두 개 있습니다.
composable(route) {
content
}
route
: 경로 이름에 해당하는 문자열입니다. 정의했던 상수 이름 속성을 사용합니다.content
: 특정 경로에 표시할 컴포저블을 호출할 수 있습니다.@Composable
fun MyNavHost(
navController: NavHostController
) {
NavHost(navController = navController, startDestination = MyRoute.HOME) {
composable(MyRoute.HOME) {
Home()
}
composable(MyRoute.CHAT) {
Chat()
}
composable(MyRoute.PROFILE + "/{userName}") { backStackEntry ->
val userName = backStackEntry.arguments?.getString("userName")
Profile(userName = userName)
}
composable(MyRoute.SETTINGS) {
Settings()
}
}
}
navController
의 navigate()
함수를 호출하고 인자로 경로를 지정하면 화면이 전환됩니다.
Button(onClick = {
navController.navigate(MyRoute.CHAT)
}) {
Text(text = "Navigate to Chat")
}
버튼을 눌렀을 때 채팅 화면으로 이동하는 코드입니다.
navigate()
함수는 네비게이션 옵션을 포함한 후행 람다를 받습니다. 네비게이션 옵션 중에는 popUpTo()
함수가 포함됩니다. popUpTo()
네비게이션 옵션을 사용하면 아이템을 스택에서 꺼내고 특정한 목적지로 돌아갈 수 있습니다. 예를 들어 A → B → C → D 순으로 화면을 이동하고, D로 이동할 때 B, C를 제거해서 홈 목적지인 A만 백 스택에 남아 있도록 할 수 있습니다.
Button(onClick = {
navController.navigate("D"){
popUpTo("A")
}){
Text(text = "Navigate to D")
}
popUpTo()
함수의 옵션으로 inclusive
옵션이 있습니다. inclusive
옵션을 true
로 설정하면 popUpTo()
함수의 인자로 전달했던 경로까지 백 스택에서 제거됩니다.
Button(onClick = {
navController.navigate("E"){
popUpTo("B"){
inclusive = true
}
}){
Text(text = "Navigate to E")
}
목적지가 이미 스택의 맨 위에 있는 경우 새로운 인스턴스를 생성하지 않도록 launchSingleTop
옵션을 사용할 수 있습니다.
Button(onClick = {
navController.navigate("E"){
launchSingleTop = true
}){
Text(text = "Navigate to E")
}
saveState
, restoreState
옵션을 true
로 설정하면 사용자가 이전에 선택했던 목적지를 다시 선택하는 경우 백 스택 항목 상태를 자동으로 저장하고 복원할 수 있습니다.
Compose에서는 다양한 타입의 인수를 한 화면에서 다른 화면으로 전달하는 기능을 지원합니다.
인수를 전달하기 위해서 목적지 경로에 인수 이름을 추가해야 합니다.
NavHost(navController = navController, startDestination = MyRoute.HOME) {
...
composable(MyRoute.PROFILE + "/{userName}") {
backStackEntry - >
val userName = backStackEntry.arguments?.getString("userName")
Profile(userName = userName)
}
...
}
프로필 화면으로 이동을 트리거하면 해당 파라미터로 할당된 값은 백 스택 항목 안에 저장됩니다.
기본적으로 네비게이션의 파라티터는 String
타입이라고 가정합니다. 다른 타입의 인수를 전달할 때는 composable()
함수의 arguments
파라미터를 통해 NavType
열거형을 이용해 타입을 지정해야합니다.
NavHost(navController = navController, startDestination = Screen.HOME.name) {
...
composable(
MyRoute.PROFILE + "/{userId}",
arguments = listOf(navArgument("userId") {
type = NavType.IntType
})
) { backStackEntry - >
val userId = backStackEntry.arguments?.getInt("userId")
Profile(userId)
}
...
}
위 코드는 파라미터 타입을 Int
타입인 경우입니다. arguments
파라미터를 통해 NavType
열거형을 이용해 타입을 지정할 뿐만 아니라 getInt()
함수로 백 스택 항목에서 인수를 추출해야 합니다.
인수를 전달받을 수 있도록 설정했다면 navigate()
메서드를 호출할 때 인수 값을 전달해야 합니다. 목적지 경로의 끝에 인수 값을 추가하여 전달할 수 있습니다.
var selectedUserName by remember { mutableStateOf("") }
Button(onClick = {
navController.navigate(MyRoute.PROFILE + "/$selectedUserName")
}) {
Text(text = "Navigate to Profile")
}
Navigation bar는 화면의 가장 아래쪽에 배치되어 네비게이션 아이템(아이콘/텍스트) 리스트를 표시합니다. 각 아이템을 클릭하여 화면 간 전환할 수 있습니다.
navigation/NavigationActions.kt
object MyRoute {
const val HOME = "home"
const val CHAT = "chat"
const val SETTINGS = "settings"
}
object로 목적지 경로를 정의합니다.
navigation/NavigationActions.kt
data class MyTopLevelDestination(
val route: String,
val icon: ImageVector,
val iconTextId: Int
)
Navigation Bar에 표시되는 각 아이템은 제목 문자열, 아이콘 이미지, 아이템이 클릭 되었을 때 앱이 이동할 경로 정보를 가져야 합니다. 이 정보들을 담을 수 있는 data class를 정의합니다.
navigation/NavigationActions.kt
val TOP_LEVEL_DESTINATIONS = listOf(
MyTopLevelDestination(
route = MyRoute.HOME,
icon = Icons.Filled.Home,
iconTextId = R.string.home
),
MyTopLevelDestination(
route = MyRoute.CHAT,
icon = Icons.Filled.Edit,
iconTextId = R.string.chat
),
MyTopLevelDestination(
route = MyRoute.SETTINGS,
icon = Icons.Filled.Settings,
iconTextId = R.string.settings
)
)
Bar Item에 대한 데이터 클래스를 작성 후, Navigation Bar에 표시할 아이템의 목록을 작성합니다.
navigation/NavigationActions.kt
class MyNavigationActions(private val navController: NavController) {
fun navigateTo(destination: MyTopLevelDestination) {
navController.navigate(destination.route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
Navigation Bar 아이템을 클릭했을 때, 실제로 화면이 전환될 수 있도록 navController.navigate()
함수를 이용해서 정의합니다.
screens/Home.kt
@Composable
fun Home() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Home")
}
}
screens/Chat.kt
@Composable
fun Chat() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Chat")
}
}
screens/Settings.kt
@Composable
fun Settings() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Settings")
}
}
NavigationBar
컴포저블 함수를 이용해서 구현할 수 있습니다. NavigationBar
는 각각 하나의 목적지를 나타내는 세 개에서 다섯 개의 NavigationBarItem
을 포함해야 합니다. 위에서 정의한 Bar Item 리스트에 대해서 forEach
함수를 사용해서 NavigationBarItem
을 정의합니다.
navigation/NavigationComponents.kt
@Composable
fun MyBottomNavigationBar(
selectedDestination: String,
navigateToTopLevelDestination: (MyTopLevelDestination) -> Unit
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationBarItem(
selected = selectedDestination == replyDestination.route,
onClick = { navigateToTopLevelDestination(replyDestination) },
icon = {
Icon(
imageVector = replyDestination.icon,
contentDescription = stringResource(id = replyDestination.iconTextId)
)
}
)
}
}
}
selectedDestination
: 현재 선택된 아이템의 정보를 가지고 있는 상태변수를 파라미터로 받습니다.이 상태값은navController.currentBackStackEntryAsState() 함수를 호출해서 네비게이션 컨트롤러의 현재 백 스택 엔트리를 상태로 가져옵니다.
백 스택 엔트리에 대한 상태 변수인 navBackStackEntry에 destination?.route로 현재 경로 정보를 가져옵니다. 만약 현재 경로가 없다면 기본값으로 MyRoute.HOME을 사용합니다.
val navBackStackEntry by navController.currentBackStackEntryAsState()
val selectedDestination
= navBackStackEntry?.destination?.route ?: MyRoute.HOME
navigateToTopLevelDestination
: Navigation 아이템 클릭 시 화면이 전환될 수 있도록 Navigation Bar 클릭 액션 정의하기에서 작성한 navigateTo()
함수를 전달합니다.마지막으로 MainScreen 컴포저블 함수 안의 레이아웃을 완성합니다. Scaffold 컴포넌트를 사용하면 템플릿 레이아웃 구조를 제공합니다. 이 템플릿 레이아웃을 사용해서 표준 Material 화면 레이아웃을 만들 수 있습니다.
@Composable
fun Scaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
floatingActionButtonPosition: FabPosition = FabPosition.End,
containerColor: Color = MaterialTheme.colorScheme.background,
contentColor: Color = contentColorFor(containerColor),
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit
){
...
}
Scaffold는 상단 바(Top Bar), 하단 바(Bottom Bar), Floating Action Button (FAB), 스낵바(Snackbar), 네비게이션 서랍(Navigation Drawer) 등을 포함하는 표준 레이아웃 요소를 위한 슬롯을 제공합니다.
Scaffold를 사용하여 Navigation Bar를 설정하고, content 파라미터로 화면에 나타날 컴포저블을 정의할 수 있습니다.
@Composable
fun MainScreen(
modifier: Modifier = Modifier
) {
val navController = rememberNavController()
val navigationActions = remember(navController) {
MyNavigationActions(navController)
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val selectedDestination = navBackStackEntry?.destination?.route ?: MyRoute.HOME
Scaffold(bottomBar = {
MyBottomNavigationBar(
selectedDestination = selectedDestination,
navigateToTopLevelDestination = navigationActions::navigateTo
)
}) { innerPadding ->
Column(
modifier = modifier
.padding(innerPadding)
.fillMaxSize()
) {
MyNavHost(
navController = navController
)
}
}
}
Scaffold의 bottomBar 파라미터로 이전에 작성한 MyBottomNavigationBar를 필수 파라미터와 함께 전달합니다.
또한, Scaffold의 content 파라미터로 화면에 나타날 컴포저블을 정의할 수 있습니다. 네비게이션 바 아이템을 클릭할 때마다 화면이 전환될 수 있도록, 빈 컨테이너 역할을 하는 MyNavHost를 필수 파라미터와 함께 전달합니다.
XML에서 구현하던 방식이 간단하고 Composable보다 좋은 점은 시각적으로 관리할 수 있는 UI를 제공했던 점이었던 것 같습니다. 쉽게 arrow를 드래그로 만들고 argument도 쉽게 설정할 수 있었습니다. Composable에서 아직은 시각적 UI를 제공하지 않지만, 사용법이 간단하고 XML에서 작성했던 코드와 유사성이 있었기 때문에 코드로 네비게이션을 구현하는 것에 어려움은 없었습니다.
MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NavStudyTheme {
MainScreen()
}
}
}
}
@Composable
fun MainScreen(
modifier: Modifier = Modifier
) {
val navController = rememberNavController()
val navigationActions = remember(navController) {
MyNavigationActions(navController)
}
val navBackStackEntry by navController.currentBackStackEntryAsState()
val selectedDestination =
navBackStackEntry?.destination?.route ?: MyRoute.HOME
Scaffold(bottomBar = {
MyBottomNavigationBar(
selectedDestination = selectedDestination,
navigateToTopLevelDestination = navigationActions::navigateTo
)
}) { innerPadding ->
Column(
modifier = modifier
.padding(innerPadding)
.fillMaxSize()
) {
MyNavHost(
navController = navController
)
}
}
}
@Composable
fun MyNavHost(
navController: NavHostController
) {
NavHost(navController = navController, startDestination = MyRoute.HOME) {
composable(MyRoute.HOME) {
Home()
}
composable(MyRoute.CHAT) {
Chat()
}
composable(MyRoute.PROFILE + "/{userName}") { backStackEntry ->
val userName = backStackEntry.arguments?.getString("userName")
Profile(userName = userName)
}
composable(MyRoute.SETTINGS) {
Settings()
}
}
}
screens/Home.kt
@Composable
fun Home() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Home")
}
}
screens/Chat.kt
@Composable
fun Chat() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Chat")
}
}
screens/Settings.kt
@Composable
fun Settings() {
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Settings")
}
}
navigation/NavigationActions.kt
object MyRoute {
const val HOME = "home"
const val CHAT = "chat"
const val PROFILE = "profile"
const val SETTINGS = "settings"
}
data class MyTopLevelDestination(
val route: String,
val icon: ImageVector,
val iconTextId: Int
)
val TOP_LEVEL_DESTINATIONS = listOf(
MyTopLevelDestination(
route = MyRoute.HOME,
icon = Icons.Filled.Home,
iconTextId = R.string.home
),
MyTopLevelDestination(
route = MyRoute.CHAT,
icon = Icons.Filled.Edit,
iconTextId = R.string.chat
),
MyTopLevelDestination(
route = MyRoute.SETTINGS,
icon = Icons.Filled.Settings,
iconTextId = R.string.settings
)
)
class MyNavigationActions(private val navController: NavController) {
fun navigateTo(destination: MyTopLevelDestination) {
navController.navigate(destination.route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
navigation/NavigationComponents.kt
@Composable
fun MyBottomNavigationBar(
selectedDestination: String,
navigateToTopLevelDestination: (MyTopLevelDestination) -> Unit
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TOP_LEVEL_DESTINATIONS.forEach { replyDestination ->
NavigationBarItem(selected =
selectedDestination == replyDestination.route,
onClick = { navigateToTopLevelDestination(replyDestination) },
icon = {
Icon(
imageVector = replyDestination.icon,
contentDescription = stringResource(id = replyDestination.iconTextId)
)
}
)
}
}
}
Compose Navigation을 구현하는 다양한 코드가 있었습니다.
저는 compose-sample의 Rely 프로젝트와 Compose 관련 도서를 바탕으로 코드 및 글을 작성했습니다.