8
1

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

Posted at

はじめに

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

つくるもの

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

ezgif.com-video-to-gif-converted.gif

実装

下記順番で作成していきます。

  1. Indicator
  2. TabItem
  3. 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),
                )
            }
        }
    }
}

おわりに

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

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1