Jetpack Compose
Jetpack Compose는 새롭게 등장한 안드로이드 네이티브 UI 개발 도구다. 기존의 xml을 대체한 선언형(declarative) UI로 UI 개발을 간소화하고 가속화한 도구이다.
맨 위 gif가 Jetpack Compose를 이용해 Bottom Navigation Bar를 만든 결과물입니다.
선언형 UI는 기존에 SwiftUI나 Flutter가 사용하고 있었다고 합니다. 최근 동향이 선언형 UI로 바뀌고 있는 것 같아 새 프로젝트에 적용해보며 포스팅을 해보기로 결심했습니다. 추후에 Jetpack Compose의 장점을 다루도록 하고 오늘은 Jetpack Compose로 Bottom Navigation 만드는 과정을 적어보도록 하겠습니다.
가장 stable한 최신 버전인 Compose UI 버전 1.1.1을 사용했습니다.
앱 이름과 package name은 자유롭게 적으면 됩니다. 보통 package는 주소 거꾸로에 앱이름 조합을 사용하는데, 저는 주소가 없으므로 그냥 제 github 이름과 앱이름 조합으로 만들었습니다.
프로젝트가 생성되면 아래 사진과 같이 ui.theme 폴더와 함께 MainActivity.kt가 만들어진 것을 보실 수 있습니다. 가장 큰 변화는 res 폴더의 layout 폴더가 존재하지 않고, xml layout이 만들어지지 않았다는 것입니다.
setContent가 기존에 익숙했던 setContentView 역할입니다. Greeting() 이 화면에 보여지는 함수입니다. DefaultPreview() 는 미리보기 화면을 보여주는 함수입니다.
nav__version은 구글에 'android navigation compose version'을 검색해서 나온 Navigation의 최신 안정화 버전으로 입력하면 됩니다.
dependencies {
def nav_version = "2.4.2"
// Navigation
implementation "androidx.navigation:navigation-compose:$nav_version"
}
기존 default은 Greeting()을 지워준 후 새롭게 화면을 만들어줍니다. 앞서 보여준 화면처럼 정가운데에 하얀색 글자가 있는 화면을 만들기 위해서 다음과 같은 코드를 짜 화면을 만들었습니다. 화면 만드는 자세한 방법은 추후에 다른 글에서 자세히 다루도록 하겠습니다.
@Composable
fun CalendarScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.primary)
) {
Text(
text = stringResource(id = R.string.text_calendar),
style = MaterialTheme.typography.h1,
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
Compose ui를 만들때는 @Composable을 꼭 먼저 선언한 후 fun 함수로 만들어 사용해야합니다.
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
MyApplicationTheme {
CalendarScreen()
}
}
DefaultPreview() 함수에 CalendarScreen() 함수를 넣고 미리보기로 먼저 화면이 잘 만들어졌나 확인해보도록 하겠습니다. 위와 같이 코드를 적고 split 창 상단의 Build&Refresh 버튼을 눌러주시면 됩니다.
fun TimelineScreen()
fun AnalysisScreen()
fun SettingsScreen()
Bottom Navigation으로 이동할 item을 위한 class를 만듭니다. 기존의 menu
와 같다고 생각하시면 됩니다. BottomNavItem은 sealed class
로 만들었습니다. sealed class는 좀 더 유연한 enum class라 보시면 편합니다. 참고로 sealed class를 사용하려면 무조건 같은 파일 내에 있어야합니다.
상태값이 변하지 않는 서브 클래스 객체를 사용할 것이므로 object 객체로 각 객체를 만들었습니다.
sealed class BottomNavItem(
val title: Int, val icon: Int, val screenRoute: String
) {
object Calendar : BottomNavItem(R.string.text_calendar, R.drawable.ic_calendar, CALENDAR)
object Timeline : BottomNavItem(R.string.text_timeline, R.drawable.ic_timeline, TIMELINE)
object Analysis : BottomNavItem(R.string.text_analysis, R.drawable.ic_clipbord, ANALYSIS)
object Settings : BottomNavItem(R.string.text_settings, R.drawable.ic_settings, SETTINGS)
}
Const 파일을 따로 만들어 CALENDAR, TIMELINE, ANALYSIS, SETTINGS 값을 선언해준 후 import하여 MainActivity에서 사용하게 했습니다.
const val CALENDAR = "CALENDAR"
const val TIMELINE = "TIMELINE"
const val ANALYSIS = "ANALYSIS"
const val SETTINGS = "SETTINGS"
NavController는 대상을 이동시키는 요소입니다. 이는 NavHost내에서 사용됩니다. 이는 사용할 때 넣어주도록 하겠습니다. (뒷부분에 나올 예정)
NavHost는 navigation의 navigation을 관리하는 핵심 구성요소로 여기서 대상이 교체
됩니다. 다시말해, NavHost는 빈 컨테이너에서 어떤 화면으로 교체시킬지 관장하는 역할을 합니다.
@Composable
fun NavigationGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = BottomNavItem.Calendar.screenRoute) {
composable(BottomNavItem.Calendar.screenRoute) {
CalendarScreen()
}
composable(BottomNavItem.Timeline.screenRoute) {
TimelineScreen()
}
composable(BottomNavItem.Analysis.screenRoute) {
AnalysisScreen()
}
composable(BottomNavItem.Settings.screenRoute) {
SettingsScreen()
}
}
}
NavHost함수는 NavGraphBuilder를 만듭니다. 그 안의 composable 함수는 NavGraphBuilder class내에 있는 함수로 어떤 route(위치)에서 어떤 화면을 보여줄 지
결정합니다.
이제 각 item이 각 화면과 연결되었습니다.
앞서 본 것처럼 4개의 화면과 4개의 BottomNavItem이 존재합니다. 이제 Bottom Navigation Bar를 만들어 4개의 아이콘 버튼을 만들어 봅시다.
먼저 bottom navigation에 들어갈 item을 list에 넣어 items list를 만들어줍니다.
val items = listOf<BottomNavItem>(
BottomNavItem.Calendar,
BottomNavItem.Timeline,
BottomNavItem.Analysis,
BottomNavItem.Settings
)
androidx.compose.material 에서 제공하는 BottomNavigation 을 사용하여 Bottom Navigation Bar 틀을 만들어줍니다.
androidx.compose.material.BottomNavigation(
backgroundColor = Color.White,
contentColor = Color(0xFF3F414E)
) {
//todo
}
//todo 에 bottom navigation item에 관한 정보, 즉 각 item의 속성값을 지정해줘야합니다. 속성값은 아래와 같습니다.
BottomNavigationItem(
icon = {
Icon(
painter = painterResource(id = item.icon),
contentDescription = stringResource(id = item.title),
modifier = Modifier.width(26.dp).height(26.dp)
)
},
label = { Text(stringResource(id = item.title), fontSize = 9.sp) },
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = Gray,
selected = currentRoute == item.screenRoute,
alwaysShowLabel = false,
onClick = {
navController.navigate(item.screenRoute) {
navController.graph.startDestinationRoute?.let {
popUpTo(it) { saveState = true }
}
launchSingleTop = true
restoreState = true
}
}
)
onClick
에서 버튼(아이콘)이 클릭되면 어떤 행동을 할지 반드시 지정해줘야합니다. 위는 navController를 통해 navigate
(이동)하게 구현해준 코드입니다.
저는 popUpTo()
를 통해 startDestinationRoute만 스택에 쌓일 수 있게 만들었습니다. 또한, launchSingleTop=true
를 통해 화면 인스턴스 하나만 만들어지게 하였고, restoreState=true
를 통해 버튼을 재클릭했을 때 이전 상태가 남아있게 하였습니다.
그렇다면 selected 속성의 currentRoute, 즉 현재 화면 정보는 어떻게 알 수 있을까요?
이때 어떤 BottomNavigationItem이 선택됐는지
어떻게 알 수 있을까요?
item 경로와 현재 대상 및 상위 대상 경로를 비교하여 선택된 상태
를 알 수 있게 해주는 것이 필요합니다.
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
NavController의 currentBackStackEntryAsState() 을 통해 navBackStackEntry를 가져와 목적지의 route(위치 string)을 가져온 것입니다.
이제 마지막 단계입니다. Bottom Navigation Bar를 보여줄 Main View가 필요합니다. Main View를 만들어 지금까지 만든 Bottom Naviation Bar를 넣어주면 됩니다.
MainView를 만들기 전, 드디어 여기서 NavController
를 만들어줍니다. NavController는 Navigation의 중심 API로 rememberNavController()
함수를 사용해 만들면 됩니다. 이렇게 만들어진 navController가 각 화면을 구성하는 컴포저블의 백스택을 추적합니다.
val navController = rememberNavController()
이제 MainView를 만들 차례입니다. 먼저 Scaffold
에 대한 설명이 필요합니다. Scaffold
는 기본 material design ui를 구현할 수 있게 해주는 요소입니다. 기본적으로 TopAppBar, BottomAppBar, FloatingActionButton, Drawer 등을 제공합니다. 이는 추후에 UI 만드는 편에서 다루도록 하겠습니다.
Scaffold의 BottomAppBar
에 만들어놓은 BottomNavigation
함수를 넣어 주고, 람다 함수 {}에 BottomBar가 하는 일인 NavigationGraph
를 넣어 Scaffold
를 만들어주면 MainView가 완성됩니다!
Scaffold(
bottomBar = { BottomNavigation(navController = navController) }
) {
Box(Modifier.padding(it)){
NavigationGraph(navController = navController)
}
}
이제 onCreate내의 setContent에 MainView를 넣어주면 완성입니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApplicationTheme {
MainScreenView()
}
}
}
}
완전한 코드는 github에 올려놓았습니다. 확인하고 싶으신 분들은 아래를 클릭해주세요 :)
https://developer.android.com/jetpack/compose/navigation?hl=ko
https://developer.android.com/jetpack/androidx/releases/navigation?hl=ko
https://developer.android.com/jetpack/compose/layout?hl=ko
https://developer.android.com/reference/androidx/navigation/NavBackStackEntry
https://medium.com/geekculture/bottom-navigation-in-jetpack-compose-android-9cd232a8b16
https://stackoverflow.com/questions/66573601/bottom-nav-bar-overlaps-screen-content-in-jetpack-compose
훌륭합니다. 저도 BottomNavigation 예제를 만들고 있었는데 참고가 많이 되었습니다.
다만 수정해야할 부분이 있는거 같습니다.
navController.navigate(item.screenRoute) {
navController.graph.startDestinationRoute?.let {
popUpTo(it) { saveState = true }
}
launchSingleTop = true
restoreState = true
}
이 코드에서 launchSingleTop 과 restoreState를 true로 하기도 전에 모든 스택을 날려버리면
이 두 코드는 동작하지도 않는 의미없는 코드가 되어버립니다.
navController.navigate(item.screenRoute) {
launchSingleTop = true
restoreState = true
navController.graph.startDestinationRoute?.let {
popUpTo(it) { saveState = true }
}
}
이렇게 서순을 옮겨야 이미 방문한 스크린을 재사용하고 상태도 저장이 됩니다.
좋은 예시인 것 같습니다~! 감사합니다