compose로 BottomNavigation 만들기

seunghee song·2024년 4월 14일

컴포즈 예제를 하나씩 하던 중 무조건 그 어느 프로젝트를 하던 해야하는 BottomNavigation만들기를 도전해보았다.

1단계
dependency 정의

//navigation
    implementation("androidx.navigation:navigation-compose:2.4.2")

Jetpack Navigation을 사용할때와 비슷하게 app단 gradle에 정의를 해준다.

2단계
이동할 화면들 정의
네비게이션 item으로 이동할 화면은 총 5개이므로 5개의 화면을 정의해준다.

@Composable
fun HomeScreen(){
    Box(modifier = Modifier
        .fillMaxSize(),
        contentAlignment = Alignment.Center){
        Text(text = "HomeScreen",
            fontSize = 22.sp)
    }
}

여기서는 5개중 하나의 화면의 코드만 가지고 왔다. 다른 화면들은 화면이 이동되었다는 것을 시각적으로 보여줄 수 있도록 단순하게 text만 변경하여 정의하였다.

3단계
네비게이션 화면들 enum class로 정의하기

enum class Screens {
    HomeScreen,
    CommunityScreen,
    FundingScreen,
    FamilyScreen,
    MypageScreen
}

네비게이션 라우트들의 이름을 하나의 enum class로 정의하여 한 곳에서 관리할 수 있도록 하였다.

4단계
BottomNaviItem만들어주기

XML방식의 menu와 같은 역할을 하는 것을 컴포즈로 만들어줘야만 해당 item의 text와 icon을 할당해줄 수 있다. 하단 네비게이션에 들어갈 아이템들을 만들어주기 위해 data class를 생성해주도록 하자.

data class NavItem(
    val label:String,
    val icon_unfill: Int,
    val route: String
)

val listOfNavItem : List<NavItem> = listOf(
    NavItem(
        label = "홈",
        icon_unfill = R.drawable.ic_home,
        route = Screens.HomeScreen.name
    ),
    NavItem(
        label = "커뮤니티",
        icon_unfill = R.drawable.ic_community,
        route = Screens.CommunityScreen.name
    ),
    NavItem(
        label = "가족",
        icon_unfill = R.drawable.ic_family,
        route = Screens.FamilyScreen.name
    ),
    NavItem(
        label = "후원",
        icon_unfill = R.drawable.ic_funding,
        route = Screens.FundingScreen.name
    ),
    NavItem(
        label = "마이페이지",
        icon_unfill = R.drawable.ic_my,
        route = Screens.MypageScreen.name
    ),
)

다섯개의 NavItem이 정의되어 있다.

  • label: 네비게이션 icon 밑에 나오는 text이다.
  • icon_unfill: Int 타입으로 네비게이션 아이템의 icon을 나타내고 있으며 vector asset으로 변환해 drawable 폴더 밑에 저장해놓은 icon들을 정의해주었다.
  • route: 해당 아이템이 연결된 대상 화면의 route이름이다. 이는 네비게이션 그래프에서 화면을 식별할때 사용될 것이다.

5단계
이제 가장 중요한 단계이다!
이제 본격적으로 BottomNavigationBar을 디자인하고 로직을 넣어줄 차례이다.
제일 먼저 NavController을 만들어준다. 모든 컴포저블이 접근할 수 있어야하는 곳이어야 하기에 맨위 그리고 맨 처음으로 정의해준다.

val navController : NavHostController = rememberNavController()

위와 같이 정의해주면 된다.

material3에서는 bottomnavibar을 만들기 위해서 Scaffold안에 bottomBar 그안에 NavigationBar 그 안에 NavigationBarItem을 정의해주어야한다.

var selectedIndex by rememberSaveable{ mutableStateOf(0) }

이는 현재 선택된 네비게이션 아이템의 인덱스를 저장하는 상태이다. rememberSaveable로 상태가 유지되도록 하였다.

NavigationBar(
                containerColor = Color.White,
                contentColor = Color.White,
                modifier = Modifier.height(66.dp)
            )

다음으로 하단에 탭을 표시하기 위한 바를 정의한다. 위와 같이 높이와 배경색을 정의해준다.

Scaffold(
        bottomBar = {
            var selectedIndex by rememberSaveable{ mutableStateOf(0) }
            NavigationBar(
                containerColor = Color.White,
                contentColor = Color.White,
                modifier = Modifier.height(66.dp)
            ) {
                listOfNavItem.forEachIndexed{index,navItem: NavItem ->
                    NavigationBarItem(
                        selected = selectedIndex == index,
                        onClick = {
                            selectedIndex = index
                            navController.navigate(navItem.route){
                                popUpTo(0){
                                    inclusive = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = {
                            Icon(
                                painter = painterResource(id = navItem.icon_unfill),
                                contentDescription = null,
                                modifier = Modifier
                                    .width(24.dp)
                                    .height(24.dp)
                                    .padding(bottom = 4.dp),
                                tint = if(selectedIndex == index){
                                    Color(ContextCompat.getColor(context, R.color.orange_500))
                                }else{
                                    Color(ContextCompat.getColor(context, R.color.secondary_200))
                                }
                            )
                        },
                        label={
                            Text(
                                text = navItem.label,
                                fontSize = 10.sp,
                                color = if(selectedIndex == index){
                                    Color(ContextCompat.getColor(context, R.color.orange_500))
                                }else{
                                    Color(ContextCompat.getColor(context, R.color.secondary_200))
                                }
                            )
                        },
                        colors = NavigationBarItemDefaults.colors(
                            indicatorColor = Color.White
                        )
                    )

                }
            }
        }
    )
   

이 코드에 대해 하나하나 설명을 해보겠다.

  • listOfNavItem에 있는 각 NavItem을 순회하면서 NavigationBarItem을 생성한다.
  • selected는 선택된 상태를 나타낸다. 현재 인덱스가 selectedIndex와 같은 경우 true가 된다.
  • navigate 내부 코드의 의미는 앱의 최상위까지 모든 대상을 백스택에서 제거하도록 한다는 의미이다. 결과적으로 navigation item을 선택 후 뒤로가기를 누르면 백스택에 어느 화면도 남지 않아 앱이 종료가 된다.
  • inclusive = true는 모든 그래프에 있는 백스택을 지운다는 의미이다.
  • launchSingleTop = true는 현재 대상이 이미 최상위에 있을 경우 여러 인스턴스가 쌓이는 것을 방지하기 위해 현재 화면을 백스택에 추가하지 않는다.
  • restoreState는 launchSingleTop과 함께 사용되면 유용하며 네비게이션 대상으로 이동할때 이전에 저장된 상태를 복원한다. 예를 들면 스크롤 위치같은 것 말이다.
  • Icon 내부의 패딩은 아이콘과 label text 사이의 패딩을 주고자 정의하였다.
  • tint는 아이템이 선택되었을 때와 되지 않았을때 icon의 색상을 변화시키기 위해 정의하였다.
  • label은 icon밑 text이며 선택했을 때와 선택되지 않았을때 색상을 다르게 하기위해 다음과 같이 정의하였다.
  • item 선택시 material3에서 제공하는 기본적 ripple 효과가 있는데 이를 제거하기 위해 배경색과 동일한 white를 indicatorColor로 정의해주었다. (❗️주의❗️처음에는 indicatorColor를 투명으로 정의하니 해당 영역이 검은색으로 변하였다. 같은 실수를 반복하지 않길 바랍니다..!)
paddingValues ->
        NavHost(
            navController = navController,
            startDestination = Screens.HomeScreen.name,
            modifier = Modifier.padding(paddingValues)
        ){
            composable(route = Screens.HomeScreen.name){
                HomeScreen()
            }
            composable(route = Screens.CommunityScreen.name){
                CommunityScreen()
            }
            composable(route = Screens.FamilyScreen.name){
                FamilyScreen()
            }
            composable(route = Screens.FundingScreen.name){
                FundingScreen()
            }
            composable(route = Screens.MypageScreen.name){
                MypageScreen()
            }
        }
    }

paddingValues를 사용해서 Scaffold 내부의 content 영역이 다른 UI요소들과 겹치지 않도록 해주었다. navhosts는 네비게이션 그래프와 NavController를 연결한다.

(참고: navhost를 만들기 위해서는 rememberNavController을 통해 만든 navController가 필요하다. 또한 그래프의 시작 대상의 경로가 필요하다.)

6단계
MainActivity에 만들어준 네비게이션 바와 로직을 포함시켜준다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BottomNavigationTheme {
                AppNavigation()
            }
        }
    }
}

완성!
드디어 이쁜 네비바가 완성되었다.
아래는 이 기능에서 가장 중요한 역할을 하는 AppNavigation의 전체 코드이다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppNavigation(){
    val navController : NavHostController = rememberNavController()
    val context = LocalContext.current
    Scaffold(
        bottomBar = {
            var selectedIndex by rememberSaveable{ mutableStateOf(0) }
            NavigationBar(
                containerColor = Color.White,
                contentColor = Color.White,
                modifier = Modifier.height(66.dp)
            ) {
                listOfNavItem.forEachIndexed{index,navItem: NavItem ->
                    NavigationBarItem(
                        selected = selectedIndex == index,
                        onClick = {
                            selectedIndex = index
                            navController.navigate(navItem.route){
                                popUpTo(0){
                                    inclusive = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = {
                            Icon(
                                painter = painterResource(id = navItem.icon_unfill),
                                contentDescription = null,
                                modifier = Modifier
                                    .width(24.dp)
                                    .height(24.dp)
                                    .padding(bottom = 4.dp),
                                tint = if(selectedIndex == index){
                                    Color(ContextCompat.getColor(context, R.color.orange_500))
                                }else{
                                    Color(ContextCompat.getColor(context, R.color.secondary_200))
                                }
                            )
                        },
                        label={
                            Text(
                                text = navItem.label,
                                fontSize = 10.sp,
                                color = if(selectedIndex == index){
                                    Color(ContextCompat.getColor(context, R.color.orange_500))
                                }else{
                                    Color(ContextCompat.getColor(context, R.color.secondary_200))
                                }
                            )
                        },
                        colors = NavigationBarItemDefaults.colors(
                            indicatorColor = Color.White
                        )
                    )

                }
            }
        }
    ){paddingValues ->
        NavHost(
            navController = navController,
            startDestination = Screens.HomeScreen.name,
            modifier = Modifier.padding(paddingValues)
        ){
            composable(route = Screens.HomeScreen.name){
                HomeScreen()
            }
            composable(route = Screens.CommunityScreen.name){
                CommunityScreen()
            }
            composable(route = Screens.FamilyScreen.name){
                FamilyScreen()
            }
            composable(route = Screens.FundingScreen.name){
                FundingScreen()
            }
            composable(route = Screens.MypageScreen.name){
                MypageScreen()
            }
        }
    }

}

참고자료
https://youtu.be/vtVyAtRYkx8?si=Wsl5wcH9LY8qq1pK

profile
안드로이드 개발자

0개의 댓글