[Android] Jetpack Compose - Bottom Navigation 만들기

김민주·2022년 6월 1일
6
post-thumbnail

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을 사용했습니다.

1. New Project에서 Empty Compose Activity를 선택하여 새 안드로이드 프로젝트를 만듭니다.

앱 이름과 package name은 자유롭게 적으면 됩니다. 보통 package는 주소 거꾸로에 앱이름 조합을 사용하는데, 저는 주소가 없으므로 그냥 제 github 이름과 앱이름 조합으로 만들었습니다.

프로젝트가 생성되면 아래 사진과 같이 ui.theme 폴더와 함께 MainActivity.kt가 만들어진 것을 보실 수 있습니다. 가장 큰 변화는 res 폴더의 layout 폴더가 존재하지 않고, xml layout이 만들어지지 않았다는 것입니다.

setContent가 기존에 익숙했던 setContentView 역할입니다. Greeting() 이 화면에 보여지는 함수입니다. DefaultPreview() 는 미리보기 화면을 보여주는 함수입니다.

2. 환경설정을 위해 gradle에 compose와 navigation compose 라이브러리를 추가해줍니다.

nav__version은 구글에 'android navigation compose version'을 검색해서 나온 Navigation의 최신 안정화 버전으로 입력하면 됩니다.

dependencies {
	def nav_version = "2.4.2"
    // Navigation
    implementation "androidx.navigation:navigation-compose:$nav_version"
}

3. Bottom Navigation으로 이동할 화면 4개를 먼저 만들어 줍니다.

기존 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 버튼을 눌러주시면 됩니다.


같은 방식으로 나머지 3개의 화면을 만들어줍니다. 저는 text값과 background의 색상값을 바꿔 만들었습니다. Bottom Navigation에 집중하기 위해 다음 코드는 생략하도록 하겠습니다.
fun TimelineScreen()
fun AnalysisScreen()
fun SettingsScreen()

4. BottomNavItem 만들기(Bottom Navigation 요소)

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"

5. NavigationGraph 만들기 (화면과 BottomNavItem 연결)

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이 각 화면과 연결되었습니다.

6. Bottom Navigation Bar 만들기

앞서 본 것처럼 4개의 화면과 4개의 BottomNavItem이 존재합니다. 이제 Bottom Navigation Bar를 만들어 4개의 아이콘 버튼을 만들어 봅시다.

6.1 Bottom Navigation item list 만들기

먼저 bottom navigation에 들어갈 item을 list에 넣어 items list를 만들어줍니다.

val items = listOf<BottomNavItem>(
        BottomNavItem.Calendar,
        BottomNavItem.Timeline,
        BottomNavItem.Analysis,
        BottomNavItem.Settings
    )

6.2 Bottom Navigation 틀 만들기 (+속성값 지정)

androidx.compose.material 에서 제공하는 BottomNavigation 을 사용하여 Bottom Navigation Bar 틀을 만들어줍니다.

androidx.compose.material.BottomNavigation(
        backgroundColor = Color.White,
        contentColor = Color(0xFF3F414E)
    ) {
       //todo 
    }

//todo 에 bottom navigation item에 관한 정보, 즉 각 item의 속성값을 지정해줘야합니다. 속성값은 아래와 같습니다.

  • icon image
  • label text
  • 언제 selected 상태가 될 지
  • 선택됐을 때 icon 색상
  • 선택되지 않았을때 icon 색상
  • click되면 어떤 행동을 취할지
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, 즉 현재 화면 정보는 어떻게 알 수 있을까요?

6.3 NavDestination 접근하여 선택된 위치 확인

이때 어떤 BottomNavigationItem이 선택됐는지 어떻게 알 수 있을까요?
item 경로와 현재 대상 및 상위 대상 경로를 비교하여 선택된 상태를 알 수 있게 해주는 것이 필요합니다.

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

NavControllercurrentBackStackEntryAsState() 을 통해 navBackStackEntry를 가져와 목적지의 route(위치 string)을 가져온 것입니다.

7. Bottom Navigation Bar를 보여줄 Main 화면

이제 마지막 단계입니다. 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 만드는 편에서 다루도록 하겠습니다.

ScaffoldBottomAppBar에 만들어놓은 BottomNavigation함수를 넣어 주고, 람다 함수 {}에 BottomBar가 하는 일인 NavigationGraph를 넣어 Scaffold를 만들어주면 MainView가 완성됩니다!

Scaffold(
        bottomBar = { BottomNavigation(navController = navController) }
    ) {
        Box(Modifier.padding(it)){
            NavigationGraph(navController = navController)
        }
    }

8. 완성

이제 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

profile
즐거운 개발자 김민주입니다🙂

3개의 댓글

comment-user-thumbnail
2023년 2월 27일

좋은 예시인 것 같습니다~! 감사합니다

1개의 답글
comment-user-thumbnail
2024년 2월 16일

훌륭합니다. 저도 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 }
}
}
이렇게 서순을 옮겨야 이미 방문한 스크린을 재사용하고 상태도 저장이 됩니다.

답글 달기