본문 바로가기

Android/Jetpack Compose

[android] BottomNavigation의 구현 및 방법에 따른 차이

728x90

개발을 하다 보면 Scaffold의 bottomBar를 사용하여 BottomNavigationBar를 많이 구현하곤 한다.

필자도 업무를 진행하면서 BottomNavigationBar가 필요하면 compose에서 제공해 주는 BottomNavigation 컴포넌트를 이용하여 구현을 하였는데, 최근에 해당 컴포넌트를 사용하지 않고 구현해야 하는 상황이 발생하였다.

 

별도로 구현을 하면서 제공해 주는 Component를 사용하지 않을 때와 사용했을 때의 구현 방법과 차이점에 대해 간단하게 알아보도록 하자.


우선,

BottomNavigation Component를 사용할 때의 구현 방법이다.

val navController = rememberNavController()

Scaffold(
    modifier = Modifier.fillMaxSize(),
    bottomBar = {
        BottomNavigationBar(navController = navController)
    }
) { ... }

 

BottomNavigation Component를 사용하려면 navController가 필요하므로 선언해 주고,

Scaffold의 bottomBar위치에 해당 component를 선언해 주어 사용하도록 한다.

 

해당 Component를 보기 앞서, 필요한 navigation Item 객체를 만들어보자.

sealed class BottomNavItem(
    var title: String,
    var icon: androidx.compose.ui.graphics.vector.ImageVector,
    var route: String
) {
    data object Home :
        BottomNavItem(NavigationType.HOME, Icons.Filled.Home, NavigationType.HOME)

    data object Search :
        BottomNavItem(NavigationType.SEARCH, Icons.Filled.Search, NavigationType.SEARCH)

    data object Profile :
        BottomNavItem(NavigationType.PROFILE, Icons.Filled.Person, NavigationType.PROFILE)

    data object Settings :
        BottomNavItem(NavigationType.SETTINGS, Icons.Filled.Settings, NavigationType.SETTINGS)
}

class NavigationType {
    companion object {
        const val HOME = "home"
        const val SEARCH = "search"
        const val PROFILE = "profile"
        const val SETTINGS = "settings"
    }
}

 

BottomNavItem이라는 sealed class를 선언해 두고 사용하였으며, 그 내부에 선언된 data object만 사용 가능하게끔 제한해 두었다.

title, icon, route로 명명하여 사용하였지만, 필요에 의해서 파라미터는 변경해도 상관없다.

@Composable
fun BottomNavigationBar(navController: NavController) {
    /**
     * Navigation 가능한 tab List
     */
    val navigationTabList = listOf(
        BottomNavItem.Home,
        BottomNavItem.Search,
        BottomNavItem.Profile,
        BottomNavItem.Settings
    )
    var selectedItem by remember { mutableStateOf(0) }

    BottomNavigation(
        backgroundColor = Color.Gray,
        elevation = 8.dp
    ) {
        navigationTabList.forEachIndexed { index, item ->
            BottomNavigationItem(
                icon = {
                    Icon(
                        imageVector = item.icon,
                        contentDescription = item.title
                    )
                },
                label = {
                    Text(text = item.title)
                },
                selected = selectedItem == index,
                onClick = {
                    selectedItem = index

                    // Tab에 따라서 UI를 변경하기 위해 사용
                    navController.navigate(item.route)
                }
            )
        }
    }
}

 

그리고 이처럼 내부 UI를 그려주면 되는데, 위의 코드가 가장 기본적인 UI로 다음과 같이 표현되는 부분이다.

 

navigationTabList에 선언한 navItem을 지정해 주고, 해당 list를 반복하면서 navItem을 생성할 때 지정해 준 아이콘과 title을 통해 UI를 그려주도록 하였다.

 

여기서 중요한 부분은 BottomNavigation, BottomNavigationItem Component이다. 

이 두 가지 컴포넌트가 compose에서 BottomNavigationBar를 보다 쉽게 구현하기 위해 만들어준 Component로 아이콘, 이름, 클릭되는 index 등 기본적인 것들만 설정해 주어도 편하게 구현이 가능하다.

 

이와 같이 BottomNavigationBar를 만들어 주었으면, 각 tab을 클릭할 때마다 보여줄 UI를 간단하게 구현하도록 하자.

Scaffold(
    ...
) { innerPadding ->
    ...

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding)
            .background(color = Color.Gray)
    ) {
        Column(
            modifier = Modifier.background(color = Color.Red)
        ) {
            MainContentComponent(
                navController = navController,
            )
        }
    }
}

 

Scaffold의 Content 영역에 작성해 주면 되는 부분이고, modifier의 padding부분에 innerPadding 값을 넣어줌으로써 BottomBar, TopBar 등 Scaffold의 Content 영역 밖의 Component들을 구현했을 때 그것 때문에 메인 Content 영역의 UI가 원하지 않는 형태로 보이는 것을 방지할 수 있다.

 

MainContentComponent는 navHostController로 선언해 둔 BottomNavItem를 사용하여 이전 xml에서 navigation Graph를 사용한 것과 같이 tab을 통해 이동 가능한 UI를 연결해 주는 역할을 하게 된다.

@Composable
fun MainContentComponent(navController: NavHostController) {
    NavHost(navController = navController, startDestination = NavigationType.HOME) {
        composable(NavigationType.HOME) { NavigationView1("BottomNavi API 1") }
        composable(NavigationType.SEARCH) { NavigationView2("BottomNavi API 2") }
        composable(NavigationType.PROFILE) { NavigationView3("BottomNavi API 3") }
        composable(NavigationType.SETTINGS) { NavigationView4("BottomNavi API 4") }
    }
}

 

startDestination을 통해 맨 처음 접근 시 보여줄 route를 지정해 주고,

NavHost 내부에 선언한 composable를 통해 이동할 수 있는 위치의 route 값과 그에 따른 UI를 선언해 주도록 한다.

route값은 string 형태로 지정할 수 있으며, BottomNavItem을 만들 때 route 파라미터를 사용한 이유는 이때 사용하기 위해서이다.

 

navController의 navigate 함수를 통해 보여줄 페이지의 route 값을 설정할 수 있고, 그 설정된 값에 해당하는 UI를 보여주게 된다.

즉,

onClick = {
    selectedItem = index

    // Tab에 따라서 UI를 변경하기 위해 사용
    navController.navigate(item.route)
}

 

해당 부분에서 item.route 값이 NavigationTytpe.PROFILE이면 NavigationView3가 호출되게 되는 것이다.

 

이와 같이 구현하면 가장 기본적인 BottomNavigationBar를 사용할 수 있게 된다.


다음으로는,

compose가 제공해 주는 Component를 사용하지 않고 구현하는 방법에 대해 알아보자.

val bottomNavigationBarIndex = remember { mutableStateOf(0) }

Scaffold(
    modifier = Modifier.fillMaxSize(),
    bottomBar = {
        CustomBottomNavigationComponent(
            clickTabIndex = bottomNavigationBarIndex,
            onClickHomeTab = {
                bottomNavigationBarIndex.value = 0
            },
            onClickAccountTab = {
                bottomNavigationBarIndex.value = 1
            },
            onClickSettingTab = {
                bottomNavigationBarIndex.value = 2
            }
        )
    }
) { ... }

 

기존과 동일하게 Scaffold의 bottomBar 영역에 그려주되, 별도의 remember 변수를 선언하여 현재 클릭된 탭의 index를 저장할 수 있도록 구현해 준다.

@Composable
fun CustomBottomNavigationComponent(
    clickTabIndex: MutableState<Int> = mutableStateOf(0),
    onClickHomeTab: () -> Unit = { },
    onClickAccountTab: () -> Unit = { },
    onClickSettingTab: () -> Unit = { },
) {
    Column(
        modifier = Modifier
            .height(54.dp)
            .fillMaxWidth()
            .background(color = Color.DarkGray),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Row(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 20.dp, vertical = 4.dp)
        ) {
            CustomNavigationComponent(
                clickTabIndex = clickTabIndex,
                icon = Icons.Default.Home,
                text = "홈",
                onClickEvent = {
                    onClickHomeTab.invoke()
                }
            )

            Spacer(modifier = Modifier.width(68.dp))

            CustomNavigationComponent(
                clickTabIndex = clickTabIndex,
                icon = Icons.Default.AccountBox,
                text = "계정",
                onClickEvent = {
                    onClickAccountTab.invoke()
                }
            )

            Spacer(modifier = Modifier.width(68.dp))

            CustomNavigationComponent(
                clickTabIndex = clickTabIndex,
                icon = Icons.Default.Settings,
                text = "설정",
                onClickEvent = {
                    onClickSettingTab.invoke()
                }
            )
        }
    }
}

 

그리고 NavigationBar에 들어갈 tab UI를 직접 구현해 준 후, 마지막으로 Scaffold의 content 영역에 click 된 index에 따른 UI를 보여줄 수 있도록 설정을 해주면 끝이다.

when (bottomNavigationBarIndex.value) {
    0 -> {
        NavigationView1(
            text = "CustomBottomNavi 1"
        )
    }

    1 -> {
        NavigationView2(
            text = "CustomBottomNavi 2"
        )
    }

    2 -> {
        NavigationView3(
            text = "CustomBottomNavi 3"
        )
    }
}

 

일반적으로 BottomNavigation Component를 사용하는 것은 기본적으로 설정해줘야 하는 값들이 많은데, 해당 Component를 사용하지 않고 구현하게 되면 설정해야 할 값들이 없는 대신 그려줄 UI와 그 상태 값과 같은 것들을 모두 직접 구현해주어야 한다.

 

어떻게 보면 Component를 사용하지 않고 직접 Custom 하여 구현하는 것이 더 간단해 보이는데 Component를 사용하여 구현하는 경우 어떠한 장점들이 있는지 확인해 보자.

 

첫 번째로, 아주 간단하게 BottomNavigation을 구현할 수 있다는 점이다.

바로 위에서 Custom 하여 구현하는 것이 더 간단해 보인다고 하였는데, 왜 갑자기 아주 간단하게 구현할 수 있다는 것을 장점으로 가져왔는지 의아할 수 있다.

당연히 다양한 기능이 없는 상태에서, 아주 간단하게 UI만 그리는 경우에는 직접 Custom 하는 것이 더 간단하게 구현할 수 있다.

하지만 우리는 지속적으로 유지보수를 해야 할뿐더러 확장성 있는 UI를 구현해야 한다.

Component를 그대로 사용한다고 생각했을 때 탭의 추가 제거가 일어날 때 BottomNaviItem을 추가, 제거하고 NavHost에서 composable 함수만 추가 제거해 주면 간단하게 수정이 가능하다.

또한, 상태 관리, 이벤트 처리 등의 기능을 내장하고 있어 유지보수에 용의 하게 된다.

@Composable
fun RowScope.BottomNavigationItem(
    selected: Boolean,
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    label: @Composable (() -> Unit)? = null,
    alwaysShowLabel: Boolean = true,
    interactionSource: MutableInteractionSource? = null,
    selectedContentColor: Color = LocalContentColor.current,
    unselectedContentColor: Color = selectedContentColor.copy(alpha = ContentAlpha.medium)
)

 

BottomNavigationItem Component를 보면 다음과 같이 제공해주고 있다.

클릭 여부, 클릭 이벤트, 사용 가능 여부, 라벨을 보여줄지 여부 등 BottomNavigation에서 필요한 기능들을 모두 제공하고 커스텀이 가능하도록 되어있기 때문에 손쉽게 유지보수가 가능하게 되는 것이다.

 

두 번째로는 가이드라인을 준수하는 UI/UX를 제공해 준다는 것이다.

Component를 사용하는 경우 클릭했을 때 탭이나 변경되는 Content 영역의 UI/UX가 안드로이드 가이드라인을 따르게 구현이 되어있기 때문에 별다른 커스텀 애니메이션을 넣지 않더라도 사용자에게 익숙한 인터페이스를 제공할 수 있게 된다.

하지만, 직접 Custom 하여 구현하는 경우 이와 같은 이벤트나 애니메이션을 직접 구현하여 추가해주어야 하기 때문에 개발 시간이 보다 오래 걸리게 된다.

이게 무슨 소린가 싶으면 다음 2가지의 영상을 비교해 보면 될 것이다.

 

Custom UI 사용

 

Component 사용

 

CustomView의 위아래로 움직이는 애니메이션은 필자가 임의로 넣은 것이고 그것 외에는 별다른 애니메이션이 없는 것을 볼 수 있다.

하지만, Component를 사용한 부분을 보면 클릭 시 ripple animation과 빨간색 박스의 최상단 text field를 보면 fadeIn, Out 되는 것을 볼 수 있다.

이처럼 별다른 작업을 해주지 않아도 가이드라인을 준수하는 UI/UX를 제공해 주어 아주 간단하게 사용자에게 높은 사용성을 제공해 줄 수 있게 되는 것이다.

 

마지막으로, compose에서 제공해 주는 component이기 때문에 안정성이 상당히 높다.

위에 언급한 것처럼 안드로이드 가이드라인을 준수하고 있으며, jetpack compose에서 제공해 주는 stable 한 component이기 때문에 최적화된 성능과 안정성을 제공하므로, 직접 구현할 때보다 좋은 사용자 경험을 제공할 수 있다.


이것으로 Component를 사용할 때와 사용하지 않을 때의 BottomNavigation에 대하여 알아보았다.

필자는 지금까지 Component만 자연스럽게 사용하다가, 이번에 특정한 케이스가 필요하여 직접 커스텀하여 사용하였는데 각 탭에 사용되는 컴포넌트들의 생명주기라던지, 애니메이션이라던지 잘 신경 쓰지 않고 사용해도 되는 것들에 대해 문제가 발생하여 여러모로 머리를 쓰면서 일을 했었다.

 

물론, 커스텀해서 사용하기 때문에 자유도가 상당히 높아 원하는 UI를 만들 수 있다는 가장 큰 장점이 있기 때문에 필요할 때는 이 방법을 사용하겠지만, 별 다른일이 없으면 제공해 주는 Component에서도 커스텀할 수 있는 데이터들이 많아 높은 자유도를 가지고 있어 Component를 사용하는 형태로 진행하는 것이 좋은 것 같다.

복잡하면 복잡할수록 고려해야하는 부분의 차이가 크다는 것을 다시 한번 체감했다.

 

이번 글에서는 해당 부분을 구현하면서 겸사겸사 애니메이션을 추가하여 텍스트 필드를 변경시켜 보았는데,

Navigation 부분만 보고자 한다면 첨부한 Github에서 본 글에 작성한 부분만 골라서 확인하면 될 것이다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

728x90