Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Jetpack Composeのスクロール可能なTabRowにminWidthが設定できるようになります


こんにちは、Androidエンジニアの藤原(@fuji_tech7)です。
Jetpack ComposeのコンポーネントにScrollableTabRowがあります。
TabRowが指定領域にタブを敷き詰めて配置するのに対しScrollableTabRowはスクロール可能にすることでより多くのタブを配置することができます。

ただし、ScrollableTabRowは制限があり期待するレイアウトを作れませんでした。 その一つがScrollableTabRow内の子要素であるTabにminWidthを設定できないことです。

この課題について、最近動きがありましたので紹介します。

本記事内では正式リリース前のalpha版の機能を利用しています。 動作環境によっては正常に動作しない、または将来的に仕様が変更される可能性もあるため、ご留意ください。


ScrollableTabRowを使う上での課題

ScrollableTabRowについてサンプルを交えて説明します。
まず以下のようなシンプルなScrollableTabRowを作ります。

@Composable
fun ScrollableTabRowSample(
    tabLabelList: List<String>,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
) {
    ScrollableTabRow(
        modifier = modifier,
        edgePadding = 0.dp,
        selectedTabIndex = pagerState.currentPage,
    ) {
        tabLabelList.forEachIndexed { index, label ->
            Tab(
                modifier = modifier
                    .fillMaxHeight()
                    .border(
                        width = 1.dp,
                        color = Color.Gray,
                    ),
                selected = pagerState.currentPage == index,
                text = { Text(text = label) },
                onClick = {},
            )
        }
    }
}


このようなタブが描画されます(サンプルとしてわかりやすくなるようボーターを追加しています)。

タブとして問題はありませんが、Tab要素に横幅を指定していないのにラベルと比較して横長に表示されています。適切な幅になるようTabのmodifierにwrapContentSizeを付与してみます。

Tab(
    modifier = modifier
        .wrapContentSize()  // 子要素であるTextの幅に合わせる
        .fillMaxHeight()
        .border(
            width = 1.dp,
            color = Color.Gray,
        ),
        selected = pagerState.currentPage == index,
        text = { Text(text = label) },
        onClick = {},
)


すると

なんとも残念な歯抜けのタブになってしまいました。

ScrollableTabRowがどういう作りになっているのか実際のコードを読んでみます。
androidx.compose.material3のTabRow.ktにScrollableTabRowはあります。

https://cs.android.com/androidx/platform/frameworks/support/+/e05bade6f0d488efebb3e0618e6b34d45303c8c0:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt;l=494

長いので抜粋します。

val minTabWidth = ScrollableTabRowMinimumTabWidth.roundToPx()

// 省略

val tabConstraints =
    constraints.copy(
        minWidth = minTabWidth,
        minHeight = layoutHeight,
        maxHeight = layoutHeight,
    )

ScrollableTabRowは親要素としてSurfaceを配置し、その中にSubcomposeLayoutを使ってTabやIndicatorなどの子要素を配置しています。tabに関しては制約を設けていてScrollableTabRowMinimumTabWidthが固定値として設定されていました。

ScrollableTabRowMinimumTabWidthの値もTabRow.ktに定義されていて

private val ScrollableTabRowMinimumTabWidth = 90.dp

となっています。
よって、ScrollableTabRow利用側でTabの横幅が90.dpを下回った際に、前述のように余白が生まれてしまいます。
minWidthを外部から設定できれば解決できるのですが、ScrollableTabRowにはそういったインターフェイスは提供されておらず、また定数のScrollableTabRowMinimumTabWidth自体も外部からはアクセス不可のため、必然的にScrollableTabRow内のタブはminWidthが90.dp固定となってしまいます。


これを解決するためには、TabRow.ktをフォークしてきてScrollableTabRowMinimumTabWidthを書き換える。またはリフレクションでScrollableTabRowMinimumTabWidthを書き換える。
といった方法が考えられますがどちらも根本的な解決策になっていないのが現状の課題となります。


Material3ライブラリ1.4.0での変更点

この件はもちろんIssueTrackerでも扱われていましたが、最初の報告から3年を経て今年の3月に遂にFixされました。

Google Issue Tracker

androidx.compose.material3:material3:1.4.0-alpha10

でこのバグに対処した。とのことです。
リリースノートも確認しましたが、対処したと記載されています。

developer.android.com

早速コードを見てみます。

https://cs.android.com/androidx/platform/frameworks/support/+/72c1dc3d725a22f7506c600a6552d2df48b627e5:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt;l=1454

@Composable
@Deprecated(
    level = DeprecationLevel.WARNING,
    message = "Replaced with PrimaryScrollableTabRow and SecondaryScrollableTabRow tab variants.",
    replaceWith =
        ReplaceWith(
            "SecondaryScrollableTabRow(selectedTabIndex, modifier, containerColor, contentColor, edgePadding, indicator, divider, tabs)"
        )
)
@Suppress("DEPRECATION")
fun ScrollableTabRow(
    // 省略
)


ScrollableTabRow自体がDeprecatedされており、 PrimaryScrollableTabRowとSecondaryScrollableTabRowに置き換えられたとあります。
PrimaryScrollableTabRow及びSecondaryScrollableTabRowはもとからあったコンポーネントでminWidthを設定できない同じ問題を抱えていましたが、こちらに変更が入ったようです。

余談ですが、PrimaryScrollableTabRowとSecondaryScrollableTabRowはメインコンテンツ切り替え用のPrimaryとメインコンテンツ内の関連コンテンツ用のSecondaryで用途わけされたものです。 詳しくはマテリアルデザインのTabsのページで説明されています。

m3.material.io

話を戻してPrimaryScrollableTabRowを読んでみます。

https://cs.android.com/androidx/platform/frameworks/support/+/c1ca7e200b4e3c0ae14d4c59ac954ed1a0fd03f5:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TabRow.kt;l=351


@Composable
fun PrimaryScrollableTabRow(
    selectedTabIndex: Int,
    modifier: Modifier = Modifier,
    scrollState: ScrollState = rememberScrollState(),
    containerColor: Color = TabRowDefaults.primaryContainerColor,
    contentColor: Color = TabRowDefaults.primaryContentColor,
    edgePadding: Dp = TabRowDefaults.ScrollableTabRowEdgeStartPadding,
    indicator: @Composable TabIndicatorScope.() -> Unit =
        @Composable {
            TabRowDefaults.PrimaryIndicator(
                Modifier.tabIndicatorOffset(selectedTabIndex, matchContentSize = true),
                width = Dp.Unspecified,
            )
        },
    divider: @Composable () -> Unit = @Composable { HorizontalDivider() },
    minTabWidth: Dp = TabRowDefaults.ScrollableTabRowMinTabWidth,
    tabs: @Composable () -> Unit
) {
    ScrollableTabRowImpl(...)
}


引数にminTabWidthが追加されています。
もともとScrollableTabRowMinimumTabWidthで制約のminWidthを初期化していたところが引数のminTabWidthで初期化するように変更されています。
minTabWidthを設定しない場合は今まで通りScrollableTabRowMinimumTabWidthで初期化され同じ挙動となります。

では、先ほどの歯抜け状態だった私のScrollableTabRowSampleを改修後のPrimaryScrollableTabRowに置き換えてさらにminTabWidthを設定してみます。

@Composable
fun PrimaryScrollableTabRow(
    modifier = modifier,
    edgePadding = 0.dp,
    selectedTabIndex = pagerState.currentPage,
    minTabWidth = 0.dp,
) {
    tabLabelList.forEachIndexed { index, label ->
        Tab(
            modifier = modifier
                .wrapContentSize()
                .fillMaxHeight()
                .border(
                    width = 1.dp,
                    color = Color.Gray,
                ),
            selected = pagerState.currentPage == index,
            text = { Text(text = label) },
            onClick = {},
        )
    }
}


期待値通りのラベルに応じた横幅を持つタブが描画されました!
ミラティブではAndroid版のギフトパネルにてScrollableTabRowを利用しており(本件以外でも)苦労したので感動しました!!

ScrollableTabRowである証明として動いているところも載せておきます。


なお、こちらはリリース前の1.4.0-alpha10で動作検証しています。 リリース時には異なる構造になる可能性があるためバージョンアップ時の挙動に注意しましょう。


まとめ

スクロール可能なTabRowの個々のタブにminWidthを設定できない問題は、ComposeのMaterial3ライブラリで将来的に解消される見込みです。
ScrollableTabRowはDeprecatedされる可能性が高いため、今からスクロール可能なタブを実装する場合はPrimaryScrollableTabRowを使って実装する方が安全です。


We are hiring!

ミラティブでは一緒に開発してくれるエンジニアを募集しています!

hrmos.co

www.mirrativ.co.jp

mirrativ.notion.site

speakerdeck.com