はじめに
こんにちは。ウォンテッドリーでAndroidエンジニアをしている長尾です。
先日、WantedlyのAndroidアプリに、ミートアップの募集を探せる機能を追加しました。ミートアップは、企業とカジュアルに接点を持てる勉強会や交流会などのイベントです。Wantedlyではタイムライン機能を公開して以降、ミートアップを一覧で見られる動線がなくなっていました。今回、さがす画面にシゴトと並べてミートアップの募集も置き、一覧から探せるようにしました。
今回の記事で扱うのは、この画面上部にある「シゴト」と「ミートアップ」を切り替えるタブUIです。一見すると、Jetpack Composeが用意するタブ用のコンポーネントを並べるだけで、すぐ作れそうなUIでした。ところが実際に実装を進めると、そのコンポーネントが持つ制約にぶつかり、狙いどおりの見た目にならず苦労しました。
本記事では、制約の正体を突き止め、解決策を比較してタブUIを自作するまでのプロセスを紹介します。同じくタブUIのデザイン調整に悩むAndroidエンジニアの手がかりになれば幸いです。
ぶつかった課題: タブが不自然に間延びする
WantedlyのAndroidアプリは、UIにMaterial2を使っています。このMaterial2には、タブを横スクロールで並べるScrollableTabRowが用意されています。最初はこれで実装したのですが、シゴトタブの幅が文字量に対して明らかに広く、タブの間に不自然な余白ができていました。テキストだけなら58dp程度で収まるはずが、実際はそれを大きく上回っていたのです。
原因を探るため、ScrollableTabRowのソースコードを読みました。すると、各タブを測定するときに最小幅を強制している箇所が見つかりました。
// androidx.compose.material の TabRow.kt より抜粋
private val ScrollableTabRowMinimumTabWidth = 90.dp
// ...
val minTabWidth = ScrollableTabRowMinimumTabWidth.roundToPx()
val tabConstraints = constraints.copy(minWidth = minTabWidth)
ScrollableTabRowMinimumTabWidthは、90.dpでハードコードされ、各タブの minWidth として渡されます。そのため中身が90dpより小さくても幅は90dp以上に広がり、シゴトタブが間延びしていました。定数はprivateなので、外部から変更する手段もありません。
解決策の選定: 制約をどう回避するか
90dp制約を回避する現実的な選択肢は2つでした。Material3に移行するか、制約のないタブ行を自作するかです。
Material3ではScrollableTabRowが非推奨になり、後継のPrimaryScrollableTabRowにminTabWidth引数が追加されました。minTabWidth = 0.dpを渡せば制約を解除でき、コード変更は最小で済みます。
それでもこの案は見送りました。Material2とMaterial3は併用できるものの、TextやIconなど同名コンポーネントのimportが混在しやすく、Material3のコンポーネントはM3のテーマ体系を前提とするため、Material2ベースの既存デザインシステムとは別にテーマ層を用意する必要があります。タブUI1箇所のためにこの負担をチーム全体で背負うのは割に合いませんでした。
採用したのは、90dp制約のないタブ行を自作する案です。依存を増やさず、この1箇所で問題を解決できます。
自作した WrapContentScrollableTabRow の中身
今回は、選択中のタブの幅に合わせた下線(インジケーター)も表示したいという要件がありました。インジケーターをタブ幅に揃えるには、タブを先に測って位置と幅を把握する必要があります。そこで、測定してから配置できるSubcomposeLayoutを選びました。ScrollableTabRowがSubcomposeLayoutを採用しているのも同じ理由です。実装したWrapContentScrollableTabRowは次のとおりです。
@Composable
internal fun WrapContentScrollableTabRow(
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
indicator: @Composable (tabPositions: List<WrapTabPosition>) -> Unit,
tabs: @Composable () -> Unit,
) {
SubcomposeLayout(
modifier = modifier
.horizontalScroll(scrollState)
.selectableGroup()
.clipToBounds(),
) { constraints ->
val tabMeasurables = subcompose(Slots.Tabs, tabs)
val tabPlaceables = tabMeasurables.map { measurable ->
// 90dp 制約を外し、コンテンツ幅で測る
measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val tabWidths = tabPlaceables.map { it.width }
val totalWidth = tabWidths.sum().coerceAtLeast(constraints.minWidth)
val layoutHeight = tabPlaceables.maxOfOrNull { it.height } ?: 0
// 測定したタブ幅から各タブの位置を求め、インジケーターの配置に渡す
val tabPositions = mutableListOf<WrapTabPosition>()
var leftPx = 0
tabWidths.forEach { width ->
tabPositions.add(WrapTabPosition(left = leftPx.toDp(), width = width.toDp()))
leftPx += width
}
layout(totalWidth, layoutHeight) {
var x = 0
tabPlaceables.forEach { placeable ->
placeable.placeRelative(x, 0)
x += placeable.width
}
subcompose(Slots.Indicator) {
indicator(tabPositions)
}.forEach { measurable ->
measurable.measure(Constraints.fixed(totalWidth, layoutHeight))
.placeRelative(0, 0)
}
}
}
}
private enum class Slots { Tabs, Indicator }
ポイントとなるのはmeasurable.measure(constraints.copy(minWidth = 0, minHeight = 0))の1行です。ScrollableTabRowが90dpを渡していた箇所を0に置き換えるだけで、タブはコンテンツ幅ちょうどに収まります。測定した各タブの左位置と幅はWrapTabPositionにまとめ、インジケーターの配置に渡します。
@Immutable
internal data class WrapTabPosition(
val left: Dp,
val width: Dp,
)
※leftはLTR前提の座標です。Wantedlyアプリの対応ロケールはen/jaのみでRTLは対象外のため、この実装のままにしています。
インジケーターは選択中タブの位置と幅に合わせて表示します。animateDpAsStateで左位置と幅を補間し、タブを切り替えたときに滑らかに移動させます。
indicator = { tabPositions ->
if (tabPositions.isNotEmpty()) {
val currentTab = tabPositions[selectedTab.ordinal]
val animatedLeft by animateDpAsState(
targetValue = currentTab.left,
animationSpec = tween(durationMillis = 250),
label = "tabIndicatorLeft",
)
val animatedWidth by animateDpAsState(
targetValue = currentTab.width,
animationSpec = tween(durationMillis = 250),
label = "tabIndicatorWidth",
)
Box(
modifier = Modifier.wrapTabIndicatorOffset(
WrapTabPosition(left = animatedLeft, width = animatedWidth),
),
contentAlignment = Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.width(16.dp)
.height(2.dp)
.background(MaterialTheme.colors.primary),
)
}
}
},
ここまでのWrapContentScrollableTabRowは、タブの中身を問わない汎用のレイアウト部品です。実際のシゴト・ミートアップのタブは、この部品を呼び出す側で組み立てます。シゴトとミートアップを表すSearchTab enumの各要素をループしてタブを生成し、選択中のタブだけ文字色を濃くしています。呼び出し側のコードは次のとおりです。
WrapContentScrollableTabRow(
modifier = modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
indicator = { /* 上記のインジケーター */ },
) {
SearchTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = {
if (selectedTab != tab) {
onTabSelected(tab)
}
},
) {
Text(
text = tabLabel(tab),
color = if (selectedTab == tab) Color.Black else Color.Gray,
fontWeight = FontWeight.W600,
)
}
}
}
これでシゴトとミートアップがそれぞれの文字幅に合うサイズで左寄せに並び、タブ切り替え時のインジケーターも滑らかに動きます。制約を外すために書き換えたのは測定時の1行だけで、ライブラリの挙動さえ掴めば対処は小さく済みました。
まとめ
ScrollableTabRowのタブが間延びする問題は、Materialが内部に持つ90dpの最小幅制約が原因でした。ドキュメントに表れない挙動を突き止めるには、ソースコードを直接読むのが最短ルートです。
解決にあたっては、Material3移行と自作を比較し、自作を選びました。判断の軸にしたのは影響範囲です。Material3移行はコード変更こそ小さいものの、テーマ体系の二重管理と同名コンポーネントの混在をチーム全体に持ち込みます。自作なら影響は1ファイルにとどまり、タブUI1箇所を直すだけなら割に合いました。ライブラリの制約にぶつかったときは、解決策そのものではなく、その解決策がどこまで波及するかで選ぶと判断がぶれません。
同じようにタブUIの実装で悩んでいる方の一助になれば嬉しく思います。