【JetpackCompose】TabRowのインジケーターをカスタマイズする

# はじめに
JetpackComposeではデフォルトのTabRowが用意されていますが、インジケーターの形を特殊なものにしたい場合はTabRowで実装するには限界があります。
そのため今回はTabRowを自作して、特殊なインジケーターに対応する方法を紹介します。

# つくるもの
インジケーターをハートにして、選択中のタブの中央に配置します。

![ezgif.com-video-to-gif-converted.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/90f58367-da47-3789-6357-988ed64a2c86.gif)

# 実装

下記順番で作成していきます。
1. Indicator
1. TabItem
1. TabRow

## Indicator
`Modifier.offset(x, y)` で任意の位置に配置できます。
後述しますが、TabRowでインジケーターの幅が必要になるため引数としてもらっています。

```kotlin
@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`を使用して文字色をピンクから白に変えています。

```kotlin
@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を見てみましょう。

```kotlin
@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`で全体を囲みます。

タブの中央にインジケーターを表示したいため、`タブの開始位置+(タブの中央-インジケーターの幅半分)`に配置、タブアイテムとインジケーターの高さを同じにしています。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953466/798d0838-c6e0-47cc-23db-3b770f717fec.png">

ページ切り替え時にタブをアニメーションで動かしたいため、`animateFloatAsState`のtargetValueにインジケーターのoffsetを設定します。この時にDpのままだとインジケーターの位置が微妙にずれてしまったため、Pxの値で設定しました。


```kotlin
@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),
                )
            }
        }
    }
}
```


# おわりに
以上、インジケーターをカスタマイズする方法でした。
少しでも参考になることがあれば幸いです。