728x90
안드로이드 컴포즈에서 Tab Bar를 구현하기 위해 고정 TabRow 또는 Scrollable TabRow가 사용됩니다.
아주 기본적인 탭을 구성하기에는 빠르게 적용가능한 컴포저블입니다.
하지만, 원하는 스타일에 알맞은 커스터마이징 작업을 진행하기에는 제한적인 부분들이 있습니다.
아래 기존의 Scrollable TabRow 코드를 확인해 보겠습니다.
(탭의 width를 확장성 있게 가져가기 위해서 Scrollable TabRow를 선호합니다.)
@Composable
fun ScrollableTabRow(
selectedTabIndex: Int,
modifier: Modifier = Modifier,
containerColor: Color = TabRowDefaults.primaryContainerColor,
contentColor: Color = TabRowDefaults.primaryContentColor,
edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
TabRowDefaults.SecondaryIndicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
)
},
divider: @Composable () -> Unit = @Composable {
HorizontalDivider()
},
tabs: @Composable () -> Unit
) {
ScrollableTabRowImp(
selectedTabIndex = selectedTabIndex,
indicator = indicator,
modifier = modifier,
containerColor = containerColor,
contentColor = contentColor,
edgePadding = edgePadding,
divider = divider,
tabs = tabs,
scrollState = rememberScrollState()
)
}
@Composable
private fun ScrollableTabRowImp(
selectedTabIndex: Int,
indicator: @Composable (tabPositions: List<TabPosition>) -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = TabRowDefaults.primaryContainerColor,
contentColor: Color = TabRowDefaults.primaryContentColor,
edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
divider: @Composable () -> Unit = @Composable {
HorizontalDivider()
},
tabs: @Composable () -> Unit,
scrollState: ScrollState,
) {
Surface(
modifier = modifier,
color = containerColor,
contentColor = contentColor
) {
val coroutineScope = rememberCoroutineScope()
val scrollableTabData = remember(scrollState, coroutineScope) {
ScrollableTabData(
scrollState = scrollState,
coroutineScope = coroutineScope
)
}
SubcomposeLayout(
Modifier
.fillMaxWidth()
.wrapContentSize(align = Alignment.CenterStart)
.horizontalScroll(scrollState)
.selectableGroup()
.clipToBounds()
) { constraints ->
val minTabWidth = ScrollableTabRowMinimumTabWidth.roundToPx()
val padding = edgePadding.roundToPx()
val tabMeasurables = subcompose(TabSlots.Tabs, tabs)
val layoutHeight = tabMeasurables.fastFold(initial = 0) { curr, measurable ->
maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity))
}
val tabConstraints = constraints.copy(
minWidth = minTabWidth,
minHeight = layoutHeight,
maxHeight = layoutHeight,
)
val tabPlaceables = mutableListOf<Placeable>()
val tabContentWidths = mutableListOf<Dp>()
tabMeasurables.fastForEach {
val placeable = it.measure(tabConstraints)
var contentWidth =
minOf(
it.maxIntrinsicWidth(placeable.height),
placeable.width
).toDp()
contentWidth -= HorizontalTextPadding * 2
tabPlaceables.add(placeable)
tabContentWidths.add(contentWidth)
}
val layoutWidth = tabPlaceables.fastFold(initial = padding * 2) { curr, measurable ->
curr + measurable.width
}
// Position the children.
layout(layoutWidth, layoutHeight) {
// Place the tabs
val tabPositions = mutableListOf<TabPosition>()
var left = padding
tabPlaceables.fastForEachIndexed { index, placeable ->
placeable.placeRelative(left, 0)
tabPositions.add(
TabPosition(
left = left.toDp(),
width = placeable.width.toDp(),
contentWidth = tabContentWidths[index]
)
)
left += placeable.width
}
// The divider is measured with its own height, and width equal to the total width
// of the tab row, and then placed on top of the tabs.
subcompose(TabSlots.Divider, divider).fastForEach {
val placeable = it.measure(
constraints.copy(
minHeight = 0,
minWidth = layoutWidth,
maxWidth = layoutWidth
)
)
placeable.placeRelative(0, layoutHeight - placeable.height)
}
// The indicator container is measured to fill the entire space occupied by the tab
// row, and then placed on top of the divider.
subcompose(TabSlots.Indicator) {
indicator(tabPositions)
}.fastForEach {
it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
}
scrollableTabData.onLaidOut(
density = this@SubcomposeLayout,
edgeOffset = padding,
tabPositions = tabPositions,
selectedTab = selectedTabIndex
)
}
}
}
}
탭 간격이나 Divider Value를 조정하려 해도 내부적인 사이즈 로직이 고정되어 있기 때문에 유연하지 못한 부분이 존재합니다.
이를 위해 하위 컴포저블 사이즈 속성 값을 파라미터로 받는 CustomTabRow를 만들어주셔야 합니다.
Scrollable TabRow 내부 코드를 대부분 복사해 주시면 되겠습니다... ㅎㅎ;
@Composable
@UiComposable
fun TimeBlocksTabRow(
selectedTabIndex: Int,
modifier: Modifier = Modifier,
minItemWidth: Dp = 90.dp,
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),
edgePadding: Dp = ScrollableTabRowPadding,
indicator: @Composable (tabPositions: List<TabPosition>) -> Unit,
tabs: @Composable @UiComposable () -> Unit
) {
Surface(
modifier = modifier,
color = backgroundColor,
contentColor = contentColor
) {
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val scrollableTabData = remember(scrollState, coroutineScope) {
ScrollableTabData(
scrollState = scrollState,
coroutineScope = coroutineScope
)
}
SubcomposeLayout(
Modifier
.fillMaxWidth()
.wrapContentSize(align = Alignment.CenterStart)
.horizontalScroll(scrollState)
.selectableGroup()
.clipToBounds()
) { constraints ->
val minTabWidth = minItemWidth.roundToPx()
val padding = edgePadding.roundToPx()
val tabConstraints = constraints.copy(minWidth = minTabWidth)
val tabPlaceables = subcompose(TabSlots.Tabs, tabs)
.map { it.measure(tabConstraints) }
var layoutWidth = padding * 2
var layoutHeight = 0
tabPlaceables.forEach {
layoutWidth += it.width
layoutHeight = maxOf(layoutHeight, it.height)
}
layout(layoutWidth, layoutHeight) {
val tabPositions = mutableListOf<TabPosition>()
var left = padding
tabPlaceables.forEach {
it.placeRelative(left, 0)
tabPositions.add(TabPosition(left = left.toDp(), width = it.width.toDp()))
left += it.width
}
subcompose(TabSlots.Indicator) {
indicator(tabPositions)
}.forEach {
it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
}
scrollableTabData.onLaidOut(
density = this@SubcomposeLayout,
edgeOffset = padding,
tabPositions = tabPositions,
selectedTab = selectedTabIndex
)
}
}
}
Divider(modifier = Modifier
.zIndex(1f)
.padding(bottom = (0.5).dp))
}
@Immutable
class TabPosition internal constructor(val left: Dp, val width: Dp) {
val right: Dp get() = left + width
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TabPosition) return false
if (left != other.left) return false
if (width != other.width) return false
return true
}
override fun hashCode(): Int {
var result = left.hashCode()
result = 31 * result + width.hashCode()
return result
}
override fun toString(): String {
return "TabPosition(left=$left, right=$right, width=$width)"
}
}
object TabRowDefaults {
/**
* @param modifier modifier for the divider's layout
* @param thickness thickness of the divider
* @param color color of the divider
*/
@Composable
fun Divider(
modifier: Modifier = Modifier,
thickness: Dp = DividerThickness,
color: Color = LocalContentColor.current.copy(alpha = DividerOpacity)
) {
androidx.compose.material.Divider(modifier = modifier, thickness = thickness, color = color)
}
/**
* @param modifier modifier for the indicator's layout
* @param height height of the indicator
* @param color color of the indicator
*/
@Composable
fun Indicator(
modifier: Modifier = Modifier,
height: Dp = IndicatorHeight,
color: Color = LocalContentColor.current
) {
Box(
modifier
.fillMaxWidth()
.height(height)
.background(color = color)
)
}
private val DividerOpacity = 0.12f
private val DividerThickness = 1.dp
private val IndicatorHeight = 2.dp
}
fun Modifier.tabIndicatorOffset(
currentTabPosition: TabPosition
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "tabIndicatorOffset"
value = currentTabPosition
}
) {
val currentTabWidth by animateDpAsState(
targetValue = currentTabPosition.width,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = ""
)
val indicatorOffset by animateDpAsState(
targetValue = currentTabPosition.left,
animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), label = ""
)
fillMaxWidth()
.wrapContentSize(Alignment.BottomStart)
.offset(x = indicatorOffset)
.width(currentTabWidth)
}
private enum class TabSlots {
Tabs,
Divider,
Indicator
}
private class ScrollableTabData(
private val scrollState: ScrollState,
private val coroutineScope: CoroutineScope
) {
private var selectedTab: Int? = null
fun onLaidOut(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>,
selectedTab: Int
) {
if (this.selectedTab != selectedTab) {
this.selectedTab = selectedTab
tabPositions.getOrNull(selectedTab)?.let {
val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions)
if (scrollState.value != calculatedOffset) {
coroutineScope.launch {
scrollState.animateScrollTo(
calculatedOffset,
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
)
}
}
}
}
}
private fun TabPosition.calculateTabOffset(
density: Density,
edgeOffset: Int,
tabPositions: List<TabPosition>
): Int = with(density) {
val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset
val visibleWidth = totalTabRowWidth - scrollState.maxValue
val tabOffset = left.roundToPx()
val scrollerCenter = visibleWidth / 2
val tabWidth = width.roundToPx()
val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2)
val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0)
return centeredTabOffset.coerceIn(0, availableSpace)
}
}
추가로 저 같은 경우에는 Tab 아이템 상태에 따라 Divider가 종속적이지 않도록 별도로 배치했습니다.
Divider(modifier = Modifier
.zIndex(1f)
.padding(bottom = (0.5).dp))
728x90