Navigation 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 업데이트를 하면서 순서대로 배치한다.
• 인디케이터는 지금까지의 과정을 축약해서 너비 고정하며 측정 후 배치하면 된다.
마치며
추가로 궁금한 점이나 피드백, 오류가 있다면 댓글이나 카톡으로 말씀해 주시면 빠르게 답변해 드리겠습니다.