はじめに
JetpackComposeではデフォルトのTabRowが用意されていますが、インジケーターの形を特殊なものにしたい場合はTabRowで実装するには限界があります。
そのため今回はTabRowを自作して、特殊なインジケーターに対応する方法を紹介します。
つくるもの
インジケーターをハートにして、選択中のタブの中央に配置します。
実装
下記順番で作成していきます。
- Indicator
- TabItem
- TabRow
Indicator
Modifier.offset(x, y)
で任意の位置に配置できます。
後述しますが、TabRowでインジケーターの幅が必要になるため引数としてもらっています。
@Composable
private fun SeriesTabIndicator(
indicatorOffsetPxState: State<Float>,
indicatorWidth: Dp,
) {
Icon(
Icons.Filled.Favorite,
contentDescription = "",
tint = Color(0xFFFFB2D8),
modifier = Modifier
.size(indicatorWidth)
.offset { IntOffset(indicatorOffsetPxState.value.roundToInt(), 0) },
)
}
TabItem
Boxの中央にテキストを配置しています。
切り替わりのタイミングで文字色を変えたいので、animateColorAsState
を使用して文字色をピンクから白に変えています。
@Composable
private fun SeriesTabItem(
isSelected: Boolean,
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tabTextColor: Color by animateColorAsState(
targetValue = if (isSelected) {
Color.White
} else {
Color(0xFFFFB2D8)
},
animationSpec = tween(300),
label = "tabTextColor",
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
onClick()
},
) {
Text(
text = text,
color = tabTextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
}
TabRow
最後に大枠の作成します。
実装に入る前に、androidx.compose.materialのTabRowを見てみましょう。
@Composable
@UiComposable
fun TabRow(
selectedTabIndex: Int,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),
indicator: @Composable @UiComposable
(tabPositions: List<TabPosition>) -> Unit = @Composable { tabPositions ->
TabRowDefaults.Indicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
)
},
divider: @Composable @UiComposable () -> Unit =
@Composable {
TabRowDefaults.Divider()
},
tabs: @Composable @UiComposable () -> Unit
) {
Surface(
modifier = modifier.selectableGroup(),
color = backgroundColor,
contentColor = contentColor
) {
SubcomposeLayout(Modifier.fillMaxWidth()) { constraints ->
val tabRowWidth = constraints.maxWidth
val tabMeasurables = subcompose(TabSlots.Tabs, tabs)
val tabCount = tabMeasurables.size
val tabWidth = (tabRowWidth / tabCount)
val tabPlaceables = tabMeasurables.map {
it.measure(constraints.copy(minWidth = tabWidth, maxWidth = tabWidth))
}
val tabRowHeight = tabPlaceables.maxByOrNull { it.height }?.height ?: 0
val tabPositions = List(tabCount) { index ->
TabPosition(tabWidth.toDp() * index, tabWidth.toDp())
}
layout(tabRowWidth, tabRowHeight) {
tabPlaceables.forEachIndexed { index, placeable ->
placeable.placeRelative(index * tabWidth, 0)
}
subcompose(TabSlots.Divider, divider).forEach {
val placeable = it.measure(constraints.copy(minHeight = 0))
placeable.placeRelative(0, tabRowHeight - placeable.height)
}
subcompose(TabSlots.Indicator) {
indicator(tabPositions)
}.forEach {
it.measure(Constraints.fixed(tabRowWidth, tabRowHeight)).placeRelative(0, 0)
}
}
}
}
}
SubcomposeLayoutの中で下記を取得しています。
A. タブ全体の幅
B. タブアイテムの数
C. 各タブアイテムの幅(A / B)
D. 各タブアイテムの開始位置(C * index)
今回はタブアイテムとインジケーターを重ねて表示したい、かつ上記で挙げた値を取得したいため、BoxWithConstraint
で全体を囲みます。
タブの中央にインジケーターを表示したいため、タブの開始位置+(タブの中央-インジケーターの幅半分)
に配置、タブアイテムとインジケーターの高さを同じにしています。
ページ切り替え時にタブをアニメーションで動かしたいため、animateFloatAsState
のtargetValueにインジケーターのoffsetを設定します。この時にDpのままだとインジケーターの位置が微妙にずれてしまったため、Pxの値で設定しました。
@Composable
fun SampleTabRow(
pagerState: PagerState,
onClickTab: (tabIndex: Int) -> Unit,
modifier: Modifier = Modifier,
) {
val currentPageIndex = pagerState.currentPage
// インジケーターのサイズ
val density = LocalDensity.current
val indicatorSize = 30.dp
val indicatorSizePx = with(density) { indicatorSize.toPx() }
BoxWithConstraints(
modifier = modifier
.background(MangaParkTheme.colors.bgDefault)
.fillMaxWidth()
.padding(
horizontal = 20.dp,
vertical = 12.dp,
),
) {
val tabRowWidth = constraints.maxWidth.toFloat()
val tabWidth = tabRowWidth / items.size.toFloat()
val indicatorOffsetPxState = animateFloatAsState(
// タブの開始位置+(タブの幅半分-インジケーターの幅半分)
// dpだと小数点以下の値の場合にずれるためpxで計算する
targetValue = tabWidth * currentPageIndex + (tabWidth / 2f) - (indicatorSizePx / 2f),
animationSpec = tween(200),
label = "indicatorOffsetPx",
)
SeriesTabIndicator(
indicatorOffsetPxState = indicatorOffsetPxState,
indicatorWidth = indicatorSize,
)
Row(
modifier = Modifier.fillMaxWidth(),
) {
items.forEachIndexed { index, text ->
SeriesTabItem(
isSelected = index == currentPageIndex,
text = text,
onClick = {
onClickTab(index)
},
modifier = Modifier
.weight(1f)
.height(indicatorSize),
)
}
}
}
}
おわりに
以上、インジケーターをカスタマイズする方法でした。
少しでも参考になることがあれば幸いです。