Jetpack Compose Navigation Bar Indicator 만들기

2025. 3. 17. 16:31·Jetpack Compose

Navigation Bar Indicator

 

 

사용화면

참고로 필자가 사용한 방법은 내비게이션 아이템의 너비가 모두 동일할 때만 사용할 수 있다. 각 아이템의 너비가 달라도 사용할 수 있는 방법은 추후에 별도의 글을 작성 후 추가할 예정이다.

 

 

코드 설명

- 클래스

sealed class Screen(
    val route: String,
    val label: String,
    val selectedIcon: Int,
    val unselectedIcon: Int
) {
    object HomeScreen: Screen("home_screen", "홈", R.drawable.ic_home_filled, R.drawable.ic_home)
    object ListScreen: Screen("stash_screen", "목록", R.drawable.ic_book_filled, R.drawable.ic_book)
    object AlbumScreen: Screen("album_screen", "앨범", R.drawable.ic_album_filled, R.drawable.ic_album)
    object MyPageScreen: Screen("myPage_screen", "마이", R.drawable.ic_person_filled, R.drawable.ic_person)
}

내비게이션 아이템을 저장할 클래스이다. 해당 공식 문서를 참고하면 된다. 공식 문서처럼 data class를 만들어 listOf()에 담아 사용해도 되고, sealed class로 만들어 인스턴스화해도 된다.

 

- 내비게이션 아이템

네 개의 Screen별로 다른 동작이 필요하지 않아 내비게이션 아이템을 별도 함수로 분리하였다. 커스텀할 부분이 있다면 분리하지 않고 바로 작성해도 괜찮다.

@Composable
fun NavItem(
    navController: NavHostController,
    screen: Screen,
    currentRoute: String?
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier
            .height(56.dp)
            .noRippleClickable {
                navController.navigate(screen.route) {
                    popUpTo(0) { saveState = true }
                    launchSingleTop = true
                    restoreState = true
                }
            }
    ) {
        Icon(
            imageVector = ImageVector.vectorResource(screen.selectedIcon.takeIf { currentRoute == screen.route } ?: screen.unselectedIcon),
            contentDescription = screen.label,
            tint = Color.Black.takeIf { currentRoute == screen.route } ?: Color(0xFFC0C0C0)
        )
        Text(
            text = screen.label,
            color = Color.Black.takeIf { currentRoute == screen.route } ?: Color(0xFFC0C0C0)
        )
    }
}

• noRippleClickable은 이전 글을 참고하면 된다.

 

• navigate()의 popUpTo(0)는 이동할 때 백스택에 남아있는 스택을 모두 지워 이동 후 뒤로가기를 눌렀을 때 바로 앱이 종료되고 공식 문서처럼 findStartDestination()을 사용하면 시작 화면이 백스택에 남아있어 앱이 종료되는 게 아닌 시작 화면으로 이동한다.

 

• takeIf는 필자의 취향이며 if문으로 변경해서 사용해도 된다.

 

- Composable

로직을 간단히 설명하자면 subcomposeLayout으로 내비게이션 아이템의 위치(x값)를 저장하고 현재 루트에 따라 인디케이터 위치를 변경하는 원리이다.

※ 참고로 필자는 중앙의 플러스 아이콘은 floatingActionButton으로 구현하였기 때문에 공간을 Box로 비워주었다.

Box(
    modifier = Modifier
        .navigationBarsPadding()
) {
    val currentRoute = bottomNavController.currentBackStackEntryAsState().value?.destination?.route
    var selectedIndex by remember { mutableIntStateOf(0) }
    val tabPositions = remember { mutableStateListOf<Dp>() }
    val indicatorOffset by animateDpAsState(tabPositions.getOrNull(selectedIndex) ?: 0.dp)

    LaunchedEffect(currentRoute) {
        selectedIndex = when (currentRoute) {
            Screen.HomeScreen.route -> 0
            Screen.ListScreen.route -> 1
            Screen.AlbumScreen.route -> 3
            Screen.MyPageScreen.route -> 4
            else -> 0
        }
    }

    SubcomposeLayout(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.White)
    ) { constraints ->
        val itemWidth = constraints.maxWidth.toDp() / 5
        val tabMeasurables = subcompose("Items") {
            listOf(Screen.HomeScreen, Screen.ListScreen).forEach { screen ->
                NavItem(
                    navController = bottomNavController,
                    screen = screen,
                    currentRoute = currentRoute
                )
            }
            Box { }
            listOf(Screen.AlbumScreen, Screen.MyPageScreen).forEach { screen ->
                NavItem(
                    navController = bottomNavController,
                    screen = screen,
                    currentRoute = currentRoute
                )
            }
        }
        val tabPlaceables = tabMeasurables.map { it.measure(Constraints.fixedWidth(constraints.maxWidth / 5)) }
        val layoutWidth = constraints.maxWidth
        val layoutHeight = tabPlaceables.first().height

        layout(layoutWidth, layoutHeight) {
            var xPos = 0
            tabPlaceables.forEach { placeable ->
                placeable.placeRelative(xPos, 0)
                tabPositions.add(xPos.toDp())
                xPos += placeable.width
            }

            subcompose("Indicator") {
                Box(
                    modifier = Modifier
                        .background(Color.Black)
                )
            }.forEach {
                val constraint = Constraints.fixed((constraints.maxWidth.toDp() / 5).roundToPx(), 2.dp.roundToPx())
                it.measure(constraint).placeRelative(indicatorOffset.roundToPx(), 0)
            }
        }
    }
}

 

• currentRoute와 LaunchedEffect로 selectedIndex를 계산한다.

 

• tabPositions는 내비게이션 아이템 4개의 x좌표 dp 값을 저장하고 indicatorOffset은 그중에서 selectedIndex로 이동할 위치를 선택한다.

 

• tabMeasurables는 List<Measurable> 타입으로 이를 각각 측정하면 List<Placeable> 타입이 되는데 이때 너비를 최대 너비 1/5로 고정하면서 측정한다. 따라서 내비게이션 아이템 코드에서 너비를 지정할 필요가 없다.

 

• layout에 너비, 높이 값으로 constraints의 최대 너비와 내비게이션 아이템 첫 번째 항목 높이(NavItem에서 설정한 56.dp)를 넣어준다.

 

• xPos를 사용해 배치/탭 포지션에 저장/xPos 업데이트를 하면서 순서대로 배치한다.

 

• 인디케이터는 지금까지의 과정을 축약해서 너비 고정하며 측정 후 배치하면 된다.

 

 

마치며

추가로 궁금한 점이나 피드백, 오류가 있다면 댓글이나 카톡으로 말씀해 주시면 빠르게 답변해 드리겠습니다.

https://open.kakao.com/o/syg9ngUf

'Jetpack Compose' 카테고리의 다른 글
  • Jetpack Compose로 Reorderable List 만들기
  • Jetpack Compose Navigation Bar FAB 만들기
  • Jetpack Compose로 Color Picker 만들기
  • Jetpack Compose로 Scroll Picker 만들기
브애애앳
브애애앳
  • 브애애앳
    디벨로퍼즐
    브애애앳
  • 전체
    오늘
    어제
    • 분류 전체보기 (24) N
      • Android App (3) N
      • Android Studio (2)
      • Figma (0)
      • Jetpack Compose (15)
      • Kotlin (3)
      • Tistory (1)
  • 인기 글

  • hELLO· Designed By정상우.v4.10.0
브애애앳
Jetpack Compose Navigation Bar Indicator 만들기
상단으로

티스토리툴바