[Android Compose] 바텀 네비게이션 바를 사용해서 컴포즈 화면 이동 구현하는 법
2024.09.10 - 의존성, 코드 수정
Compose를 사용하지 않는다면 인텐트와 startActivity()를 써서 다른 액티비티로 이동할 수 있었다.
하지만 Compose에선 startActivity() 대신 네비게이션 라이브러리를 써서 화면 이동을 구현한다. 코드를 보기 전에 왜 Compose에선 이딴이런 식으로 화면 이동을 구현해야 하는 것인가?
이 글을 보는 사람이라면 Compose가 선언형 방식으로 UI를 구성한다는 건 알고 있을 것이다. 이 특징 때문에 네비게이션 라이브러리를 사용하는 것이다.
안드로이드의 전통적인 화면 전환 방법인 startActivity()는 명령형 프로그래밍 패러다임을 따르는 함수다. 그래서 선언형 프로그래밍 패러다임인 Compose와는 잘 맞지 않는다. 안드로이드 공식문서 중에는 아예 Compose에서 이동을 구현하려면 네비게이션을 사용하는 것을 전제에 깔고 설명하는 문서도 있다.
https://developer.android.com/jetpack/compose/navigation?hl=ko
물론 네비게이션을 쓰면 여러 장점이 있긴 하지만 사람에 따라선 그냥 프래그먼트 쓰는 게 낫다는 생각도 할 수 있을 정도로 준비 작업 등 여러가지가 귀찮다.
다행히 Compose에서 화면 이동 시 네비게이션을 구현하는 것은 생각보다 어렵지 않고 간단하기 때문에 방법만 잘 알아두면 확장 함수로든 뭘로든 만들어서 쓰면 될 것이다. 그러나 화면 이동 하나 구현하려면 라이브러리 추가해야 하는 건 매우 귀찮기 때문에 그냥 프로젝트 만들 때 기본으로 추가되게 업데이트 해주면 좋겠다.
이제 시작한다. 먼저 앱 gradle에 의존성을 추가한다.
implementation("androidx.compose.material:material:1.7.0")
implementation("androidx.navigation:navigation-compose:2.8.0")
버전 카탈로그를 쓴다면 libs.versions.toml에 의존성을 알맞게 추가하고 앱 gradle에서 사용한다.
[versions]
material = "1.7.0"
[libraries]
androidx-material = { module = "androidx.compose.material:material", version.ref = "material" }
navigation-compose = { group = "androidx.navigation" , name = "navigation-compose", version = "2.8.0"}
implementation(libs.androidx.material)
implementation(libs.navigation.compose)
그리고 이동할 화면의 이름을 sealed class 형태로 정의한다.
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Home
import androidx.compose.ui.graphics.vector.ImageVector
sealed class Screen(
val route: String,
@StringRes val resourceId: Int,
val icon: ImageVector,
) {
data object Home: Screen(
route = "home",
resourceId = R.string.home,
icon = Icons.Filled.Home,
)
data object Profile: Screen(
route = "profile",
resourceId = R.string.profile,
icon = Icons.Filled.AccountCircle,
)
}
이 sealed class는 URL과 비슷한 것이다. URL이 웹사이트의 다른 페이지에 매핑되는 것처럼 경로는 대상에 매핑되서 고유 식별자 역할을 하게 된다. 추가로 resourceId, icon을 추가해서 바텀 네비게이션 바의 아이콘과 그 밑에 표시할 제목을 설정한다. 추가로 data object를 사용할 수 없다면 그냥 object 라고 쓰면 에러는 해결될 것이다.
아래는 MainActivity의 전체 코드다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.BottomNavigation
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.composepractice.ui.theme.ComposePracticeTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
val items: List<Screen> = listOf(
Screen.Home,
Screen.Profile,
)
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation(
backgroundColor = Color.Black,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEach { screen ->
NavigationBarItem(
label = {
Text(text = stringResource(id = screen.resourceId))
},
selected = currentRoute == screen.route,
colors = NavigationBarItemDefaults.colors(
selectedTextColor = Color.Red,
selectedIconColor = Color.Red,
unselectedTextColor = Color.Blue,
unselectedIconColor = Color.Blue,
indicatorColor = Color.White,
),
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.route
)
},
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Home.route) { HomeScreen() }
composable(route = Screen.Profile.route) { ProfileScreen() }
}
}
}
}
}
}
@Composable
fun HomeScreen() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Home")
}
}
@Composable
fun ProfileScreen() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Profile")
}
}
painterResource() 안의 드로어블이 없어서 컴파일 에러가 날 수 있다. 대충 더하기 기호 모양의 드로어블을 추가하고 그 이름을 넣어주면 된다.
자잘한 코드들 다 쳐내고 화면 이동 관련 코드부터 확인한다.
val navController = rememberNavController()
rememberNavController()를 통해 화면 이동에 필요한 클래스를 얻어 온다. 그리고 Scaffold의 생성자 매개변수 중 bottomBar를 추가하고 관련 코드들을 추가한다. 디벨로퍼에 기본 코드들이 실려 있으니 참고하려면 참고한다.
https://developer.android.com/develop/ui/compose/navigation?hl=ko#bottom-nav
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation(
backgroundColor = Color.Black,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
items.forEach { screen ->
NavigationBarItem(
label = {
Text(text = stringResource(id = screen.resourceId))
},
selected = currentRoute == screen.route,
colors = NavigationBarItemDefaults.colors(
selectedTextColor = Color.Red,
selectedIconColor = Color.Red,
unselectedTextColor = Color.Blue,
unselectedIconColor = Color.Blue,
indicatorColor = Color.White,
),
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = screen.icon,
contentDescription = screen.route
)
},
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Home.route) { HomeScreen() }
composable(route = Screen.Profile.route) { ProfileScreen() }
}
}
배경색 등 여러 색깔들은 임의로 설정했으니 없애거나 변경하면 된다.
currentBackStackEntryAsState()를 써서 백스택에 담긴 화면들의 route를 가져온다. 이 중 현재 핸드폰에 표시되는 화면의 route를 가져온다. 그리고 앞서 선언한 items 리스트를 순회하면서 NavigationBarItem()을 만든다.
이 때 label, selected, colors, onClick, icon 매개변수를 설정할 수 있고 각각에 알맞은 값들을 넣어서 바텀 네비게이션 바를 커스텀할 수 있다. 커스텀된 NavigationBarItem 객체는 바텀 네비게이션 바에 들어가게 되고 items엔 2개의 Screen이 들어있기 때문에 2개의 탭이 생성될 것이다.
그리고 NavHost를 구현해서 navController 상태를 끌어올려서 BottomNavigation 컴포넌트와 공유할 수 있게 한다. 이렇게 하면 탭을 누를 때마다 BottomNavigation이 자동으로 최신화되어 선택된 상태, 안 선택된 상태가 저절로 바뀌게 된다.
NavHost(
navController = navController,
startDestination = Screen.Home.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Home.route) { HomeScreen() }
composable(route = Screen.Profile.route) { ProfileScreen() }
}
NavHost는 지정된 경로 기반으로 다른 컴포저블 대상을 표시하는 컴포저블이다. 위 코드에선 경로가 Write라면 Todo를 작성하는 화면을 표시한다. 아래는 NavHost 문법을 간략화한 그림이다.
navController에는 rememberNavController()의 값을 갖고 있는 navController 참조를 넣으면 된다. startDestination에는 앱에서 NavHost를 처음 표시할 때 보여줄 컴포저블을 정의하는 문자열 경로다.
그리고 그 밑에 composable() {}을 사용해서 route 매개변수에 Start, Write를 각각 대입한 다음, 후행 람다 안에서 컴포저블 함수를 호출하고 있다. 즉 지정된 enum 값을 받을 때마다 특정 컴포저블을 호출해서 화면에 표시하는 것이다. 이렇게 함으로써 기존 화면은 애니메이션 처리되면서 사라지고 새로운 화면이 핸드폰에 표시되는 것이다.
HomeScreen, ProfileScreen의 구현은 Column, Text 뿐이기 때문에 넘어간다.
위 코드를 에뮬레이터에서 실행시키면 아래처럼 작동한다.
이렇게 하면 간단한 화면 이동 앱을 구현할 수 있다.