見出し画像

Now in REALITY Tech #89 ScrollableTabRowの横幅の最小値が設定できなくて、ちょっと困った話

こんにちは!REALITYのAndroidエンジニアのhosicoです!
去年のちょうど10月にREALITYにJOINしまして、ちょうど1年ぐらいが経ちました。月日が過ぎるのは早いですね。楽しく、日々過ごせております。
最近、インフルエンザが流行っており、ビクビクしている今日この頃です。

今回は、Jetpack Composeでタブベースのナビゲーションを実装するためのUIコンポーネントの一つである「ScrollableTabRow」を使う際に、少し困ったことがあったので、共有させていただこうかなと思います。

ScrollableTabRowについて

「ScrollableTabRow」は、タブの数が画面内に収まらないようなケースにて、横にスクロール可能なタブのリストを表示するためのUIコンポーネントとして、使用されます。REALITYのAndroidアプリの中でも、実際にいくつかの画面にて、利用されております。

ScrollableTabRowを使って、困ったこと

配信中の配信者の方に対して、視聴者側から送る応援ギフトを選択する画面があるのですが、該当の画面がxmlベースで書かれており、Composeへの移行を進めていました。


ギフト選択画面

そして、移行を進めていく中で、上の画像の赤枠で囲ったタブリストのUIを、「ScrollableTabRow」で置き換えたところ…

上の画像のように、タブ同士のPaddingが不均一に見えてしまうようになりました。

androidx.compose.materialのTabRow.ktにて、ScrollableTabRowのComposable関数が定義されているので、読んでみると、

@Composable
@UiComposable
fun ScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    edgePadding: Dp = TabRowDefaults.ScrollableTabRowPadding,
    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,
        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 = ScrollableTabRowMinimumTabWidth.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)
            }

            // Position the children.
            layout(layoutWidth, layoutHeight) {
                // Place the tabs
                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
                }

                // 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).forEach {
                    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)
                }.forEach {
                    it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
                }

                scrollableTabData.onLaidOut(
                    density = this@SubcomposeLayout,
                    edgeOffset = padding,
                    tabPositions = tabPositions,
                    selectedTab = selectedTabIndex
                )
            }
        }
    }
}

// ...省略

private val ScrollableTabRowMinimumTabWidth = 90.dp

どうやら、ScrollableTabRowMinimumTabWidthという変数の中に、各タブのwidthの最小値が90.dpと設定され、各タブが配置されているようでした。

そのため、タブの中のテキスト幅が90.dpに満たなければ、足りない分の余白が生まれてしまい、タブ同士のpaddingが、見た目上、均一ではないように見えてしまっていました。

これを解決するには、各タブのwidthの最小値を0.dpにしてあげれば良いのですが、どうやら、ScrollableTabRowのインターフェイスには、widthの最小値を設定する術が用意されておらず、インターネット上でも、同じような問題で困っているような例もいくつか見つかりました。

結局どうしたか?

賢い解決方法が思いつかず、androidx.compose.materialのTabRow.ktをREALITYのコードの中にfolkしてきました。取り込んだTabRow.ktの中で、横幅の最小値を定義している「ScrollableTabRowMinimumTabWidth」の値を0.dpに置き換え、取り込んだ「ScrollableTabRow」のComposableをリネームして、それを最小幅が0.dpの「ScrollableTabRow」として、REALITY内で使うようにして、一旦回避してます。


タブの横幅の最小値を0.dpにした時のギフト選択画面

タブの横幅の最小値を0.dpにしたことで、タブ同士のpaddingが均一になり、一画面中に多くのタブが収まるようになりました。

まとめ

今回、自分がcompose移行を進めていく中で、「ScrollableTabRow」に置き換える際に、困ったことを共有させていただきました。もっと良いスマートな解決方法があれば、教えてください。似たような内容の記事は他にもあるのですが、もし同じようなことで困っている方がいらっしゃいましたら、参考になれば幸いです。