본문 바로가기

Jetpack Compose

[Jetpack Compose] Bottom Navbar를 통해 배우는 Compose Navigation

Bottom Navigation (완성)

 

Navigating을 통한 페이지 탐색은 웹의 SPA는 물론이고 앱에서도 굉장히 중요하다. 안드로이드, 그리고 Jetpack Compose에서도 이는 굉장히 중요하고 그에 따라 지원하는 라이브러리도 존재한다.

Navigation은 UI 상 우리가 흔히 봐온 Navbar가 아니더라도 라우팅을 통한 페이지 이동을 처리하는 자체가 Navigation이라 할 수 있다. (페이지 탐색, 뒤로가기 등등)

진행중인 프로젝트에선 위의 이미지와 같이 Bottom Navbar를 통해 페이지 전환을 수행하도록 하였고, 이번 포스팅에선 해당 Navbar의 구현과정을 통해 Jetpack Compose의 Navigation API들에 가까워지는 시간을 가지면 좋을 것 같다.

 

Compose Navigation을 이루는 구성요소

dependencies {
    implementation ("androidx.navigation:navigation-compose:2.7.7")
}

Compose 수준의 Navigation을 활용하기 위해 gradle 파일에 위의 설정을 추가해준다.

이제 우리가 마주하게 될 요소는 크게 아래와 같이 3가지로 나타낼 수 있다.

  • NavHost: NavController를 통한 탐색을 위한 단일 컨텍스트 또는 컨테이너
  • NavGraph: NavDestination 노드의 모음 (이동하게 될 컴포저블들의 래퍼 역할)
  • NavController: NavHost 내에서 앱 navigation을 관리, 화면간 이동을 담당

 

위 3가지 요소들을 중심으로 Bottom Navbar가 어떻게 구현되고 동작하게 되는지 단계별로 알아보자. 3가지 요소를 제외하고도 짚어야 될 포인트들이 별개로 존재한다.

NavController

val navController = rememberNavController()

개발자문서에선 아래와 같이 언급한다.

컴포저블 계층 구조에서 NavController를 만드는 위치는 이를 참조해야 하는 모든 컴포저블이 액세스할 수 있는 곳이어야 합니다. 이는 상태 호이스팅의 원칙을 준수하며, 이렇게 하면 NavController와 상태를 사용할 수 있습니다.

이처럼 rememberNavController() api를 통해 NavController를 얻기 위해선 가장 상위 레벨의 화면에 이를 선언해야 한다. 

// MainScreen.kt

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    val navController = rememberNavController()
    Scaffold(
        bottomBar = {
            BottomBar(navController = navController)
        }
    ) {
        BottomNavGraph(navController = navController)
    }
}

현재 앱을 구성하는 메인 네비게이션은 Bottom Navbar이고, 해당 구조를 빠르게 조합하기 위해 최상위 수준의 MainScreen 내부에 Scaffold 구조를 사용하도록 한다. (Scaffold의 bottomBar 활용)

Scaffold의 bottomBar 매개변수의 인자로써 BottomBar 자체를 호출하게 되며 내부 Unit 함수로 실제 화면을 구성하게 되는 Screen 함수들이 불러오게 된다. 결국 BottomNavGraph는 인자로 받게 된 navController를 통해 화면 호출(라우팅)을 담당하게 된다 볼 수 있다.

 

Screen State Machine

아래는 Navbar에 사용될 Screen의 상태를 정의하는 클래스이다. 정적으로 사용되며, 외부 변경이 없게끔 Sealed Class로 정의해준다 (data class를 사용한다해도 문제가 없다). 현재는 프로젝트 진행상 Navbar는 물론 앱 전체의 유일한 라우트라 볼 수 있다.

// BottomBarScreen.kt

sealed class BottomBarScreen(
    val route: String,
    val title: String,
    val icon: ImageVector,
) {
    object Home: BottomBarScreen(
        route = "home",
        title = "Home",
        icon = Icons.Default.Home
    )
    object Feed: BottomBarScreen(
        route = "feed",
        title = "Feed",
        icon = Icons.Default.Star
    )
    object Category: BottomBarScreen(
        route = "category",
        title = "Category",
        icon = Icons.Default.List
    )
}

 

BottomBar (Bottom Navbar)

Bottom Navbar의 형체와 상태를 통한 페이지 전환등의 작업이 이루어질 곳이다. BottomBar 클래스에서 일일히 NavigationBarItem을 지정해 줄 수도 있지만, 아래와 같이 RowScope 수준의 확장 함수 AddItem을 구현하여 각 아이템에 대한 로직을 분리시켜 동적으로 받아오게 끔 한다.

@Composable
fun BottomBar(navController: NavHostController) {
    val screens = listOf<BottomBarScreen>(
        BottomBarScreen.Home,
        BottomBarScreen.Feed,
        BottomBarScreen.Category,
    )
    // currentBackStackEntryAsState() 함수를 사용하여 현재 NavBackStaclEntry를 가져온다.
    // 이 항목을 통해 현재 NavDestination에 액세스할 수 있다.
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    NavigationBar {
        screens.forEach { screen ->
            AddItem(
                screen = screen,
                currentDestination = currentDestination,
                navController = navController
            )
        }
    }
}

@Composable
fun RowScope.AddItem(
    screen: BottomBarScreen,
    currentDestination: NavDestination?,
    navController: NavHostController
) {
    NavigationBarItem(
        label = {
            Text(text = screen.title)
        },
        icon = {
            Icon(
                imageVector = screen.icon,
                contentDescription = "Navigation Icon"
            )
        },
        selected = currentDestination?.hierarchy?.any {
            it.route == screen.route
        } == true,
        
        /* 중요 --> 각 아이템으로 이동하도록 해주는 람다식 */
        onClick = {
            navController.navigate(screen.route) {
                popUpTo(navController.graph.findStartDestination().id) {
                    saveState = true
                }
                launchSingleTop = true
                restoreState = true
            }
        }
    )
}

 

자, 그럼 위의 코드에서 중요한 부분으론 무엇이 있을까?

 

NavBackStackEntry

첫 번째론 NavBackStackEntry다.  개발자 문서에서 해당 클래스를 아래와 같이 정의한다.

Representation of an entry in the back stack of a androidx.navigation.NavController. 

NavController 즉, 우리가 계속 사용하게 되는 navController가 타입으로받는 NavHostController는 "Back stack"을 관리하고 현재 destination이 어떤 composable인지 추척하는 인스턴스이다. 이에 따라 NavBackStackEntry는 이름에 걸맞게 NavController의 back stack에 있는 항목들을 표현하는 녀석이라 볼 수 있다.

위의 코드에서 현재 목적지 (current destination)를 통해 selected한 nav item을 표시하는 행위에 currentBackStackEntryAsState()를 사용한 것을 볼 수 있다. 이는 역시 NavHostController에서 접근한 함수이며 NavBackStackEntry 타입을 가지게 된다. 이 과정을 통해 (위에서 언급한 selected 인자를 포함하여) NavigationBarItem 컴포저블 함수의 구성요소와 공유를 이룰 수 있게 되는 것이다.

만약, Compose의  Navigation에 대해 처음이라면 나 역시 위의 설명만으로 진행되는 원리및 동작에 이해가 어려울 수도 있다.

조금만 더 쉽게 다가가보자. 

"Back Stack" 이녀석이 composable 함수 그리고 navigate 동작과 어떤식의 연관 관계를 맺는지에 포인트를 맞춰보는 것이 좋다.

 

Back Stack with Composable

back stack은 사용자가 앱 내에서 이전에 방문한 화면들을 추적하는 스택(Stack)구조이다. 사용자가 앱 내의 다른 화면으로 이동할 때마다 현재 화면이 back stack에 push되며, 사용자가 이전 화면으로 돌아가면 해당 화면이 back stack에서 pop되어 제거된다.

자 그렇다면, 언제 새 대상(화면)이 back stack에 추가될까? 

위의 코드에서 바로 찾아볼 수 있다.

onClick = {
    navController.navigate(screen.route) {
        popUpTo(navController.graph.findStartDestination().id) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
    }
}

바로 navigate() 메서드의 호출이다. 일단 popUpTo와 같은 추가 탐색 옵션에 대한 설명은 제외하고, navController.navigate(route)가 현재 이동할 화면에 대한 back stack 등록을 진행하게 된다. 진행될 아래의 내용에서 추가적으로 언급하겠지만 위와 같은 선행 작업을 토대로 NavHost 내부 composable() 함수에서 람다의 인자로써 NavBackStackEntry 객체를 사용할 수 있게 된다. 

Navigation Compose의 composable 대상 간의 인수 전달도 지원

 

 

NavGraph & NavHost

Bottom Nav bar에서 onclick 람다 식 구현을 통해 페이지간 라우팅 (일단 옵션 설명은 제외)을 가능케 하였으면 이젠 경로에 맞게 화면을 띄워주는 과정을 수행해야 한다. 잠깐 위의 이미지를 통해 볼 수 있듯이 composable 함수 내부에서 Profile이란 Composable 함수를 불러오는 것이 이를 뜻한다.

그럼 프로젝트 코드를 확인해보자. 

@Composable
fun BottomNavGraph(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = BottomBarScreen.Home.route,
    ) {
        composable(route = BottomBarScreen.Home.route) {
            HomeScreen()
        }
        composable(route = BottomBarScreen.Feed.route) {
            FeedScreen()
        }
        composable(route = BottomBarScreen.Category.route) {
            CategoryScreen()
        }
    }
}

NavHost를 생성하기 위해선 상위 레벨에서 rememberNavController()로 만든 navController 뿐 아니라 시작할 경로에 해당하는 startDestination 또한 필요로 한다. 위는 Home 화면이 시작 경로가 된다는 것을 의미한다.

String 타입의 경로가 composable 함수의 인자로 받아올 수 있고 람다 식 내부에선 해당 경로에 호출할 Composable 함수를 뿌려준다. 이러한 화면 이동에 관한 전체 코드를 우린 "Graph"란 수준에서 관리하도록 하고 가장 상위레벨로 다시 돌아와, Scaffold 람다에서 이를 호출할 수 있게 된다.

@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    val navController = rememberNavController()
    Scaffold(
        bottomBar = {
            BottomBar(navController = navController)
        }
    ) {
        BottomNavGraph(navController = navController)
    }
}

 

이렇게 진행된 과정을 통해 글 최상단의 동작(화면 이동) 구현이 가능하게 된다. 

하지만 Compose의 Navigation에 대해 조금 더 다가갈 필요가 있다.

이번 블로그에선 네비게이션이 어떻게 동작하게 되고 그 원리는 무엇인가에 대해 초점을 맞추었다면 추후 진행될 블로그에선 조금 더 세부적 기능에 대해 알아보고자 한다. 

추가로 이번 예시와 같은 동등한 레벨에서의 (중첩되지 않은) 네비게이션만을 고려한 것이 아닌, 중첩된 상황에서의 코드 레벨에 대해서도 고민해보는 시간을 가져볼 생각이다.