UI共通化の罠と、「スロット」の推しどころ
はじめに
みなさん、スロットをご存知でしょうか?
競馬、パチンコとくればもちろんスロットですが、Jetpack ComposeにもSlot APIというものがあります。
知られていたり知られていなかったりするので、今回はそれを紹介していこうと思います。
この記事の対象読者
- Androidアプリ開発をしている人
- UIの共通化設計に興味がある人
スロットとは
まずは公式の「Slot-based layouts」の解説を見てみましょう
Compose provides a large variety of composables based on Material Design with the androidx.compose.material:material dependency (included when creating a Compose project in Android Studio) to make UI building easy. Elements like Drawer, FloatingActionButton, and TopAppBar are all provided.
では雑に翻訳した日本語訳も
Composeは、Material Designをベースにした多様なコンポーザブルを提供し、Android StudioでComposeプロジェクトを作成する際には自動的に含まれる『androidx.compose.material:material』依存関係を通じて、UIの構築を容易にします。Drawer、FloatingActionButton、TopAppBarなどの要素がすべて提供されています。
むむむ、なんだかSlot APIがなんなのかまだわからないですね。
これはあるあるですが、公式ドキュメントで「〇〇とは」にあたる文章をなかなか参照することができなかったりします。続く文章を確認していきます。
Material components make heavy use of slot APIs, a pattern Compose introduces to bring in a layer of customization on top of composables. This approach makes components more flexible, as they accept a child element which can configure itself rather than having to expose every configuration parameter of the child. Slots leave an empty space in the UI for the developer to fill as they wish. For example, these are the slots that you can customize in a TopAppBar:
マテリアルコンポーネントは、スロットAPIを多用します。これは、コンポーザブルの上にカスタマイズレイヤーを追加するためにComposeが導入したパターンです。このアプローチにより、コンポーネントは子要素を受け入れることができ、子要素自体が設定できるため、子要素のすべての設定パラメータを公開する必要がなく、柔軟性が高まります。スロットはUIに空きスペースを残し、開発者が自由に設定できます。例えば、以下はでカスタマイズできるスロットです TopAppBar。
少しずつ全体像が見えてきました。
どうやら、「コンポーザブルの上にカスタマイズレイヤーを追加するためにComposeが導入したパターン」がSlot APIみたいです。
まだふんわりしているので、私の言葉でスロットについてズバっと定義しておこうと思います。
スロットとは、引数でComposableを受け取る仕組みのことです。
よく使う例だと、TopAppBarやBottomSheetで以下のようにContentを受け取って表示できるようになっています。
ModalBottomSheet(
modifier = Modifier.padding(contentPadding),
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState
) {
// Sheet content
Box(
modifier = Modifier.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text("こんにちは")
}
}
「あーこれってスロットって言うんだ」って人もけっこう多いのではないでしょうか?
概念的には、部品を指すための穴のことで、ゲーム機にソフトを指すことを想像するとわかりやすいと思います。
同様に、Composableでも部品(Content)を指すための穴をあけている状態になっています。
具体的な例で考えてみる
さてここからが本題ですが、スロットは特に柔軟な変更を要するレイアウトで強力な手法になります。
以下のようなよくあるSNSアプリのタイムライン画面でも考えてみましょうか
これはジェバンニが一晩でAIが1分で作ってくれました
分解するとこんな感じです
この画面は以下の項目で構成されています
- アイコン
- 名前
- 本文
- ハッシュタグ
- ボタングループ
まずは、シンプルにUiStateを受け取って表示できるComposableで考えてみます。
@Composable
fun TimelinePostCard(
uiState: TimelinePostUiState,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(modifier = Modifier.padding(12.dp)) {
// 左:アバター(uiState反映)
PlaceholderAvatarFixed(text = uiState.avatarText)
Spacer(Modifier.width(12.dp))
// 右:本文列
Column(modifier = Modifier.weight(1f)) {
PostHeaderFixed(
displayName = uiState.displayName,
handle = uiState.handle,
time = uiState.timeLabel
)
PostBodyFixed(uiState.body)
// フッタ(タグ任意)
uiState.tags?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
Spacer(Modifier.height(8.dp))
ActionRowFixed(initialLiked = uiState.initialLiked)
}
}
}
}
さて、シンプルな文章投稿一覧を想定したこの画面ですが、似た画面を追加する時のことを考えてみます。
以下は投稿詳細画面です。
投稿詳細画面はタイムラインとは全く別の画面ですが、かなり近い画面構成となっており
右上に投稿者をフォローするボタンがあります。
ではみなさん…
タイムライン画面は出来上がっている状態で、こちらの投稿詳細画面を1から作りますか!?
私は作りたくありません(泣)
無駄な重複を避け、適切な共通化を図りたいと思うのも自然だと思います。
共通化にもさまざまな手法があるので、いくつか紹介していきます。
引数でifの分岐を作成する
@Composable
fun TimelinePostCard(
uiState: TimelinePostUiState2,
modifier: Modifier = Modifier,
onFollowChanged: (Boolean) -> Unit = {}
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row {
// 左:アバター
PlaceholderAvatarFixed(text = uiState.avatarText)
Spacer(Modifier.width(12.dp))
// 右:本文列
Column(modifier = Modifier.weight(1f)) {
// ← ヘッダ行の中にフォローボタンが入る(uiState から供給)
PostHeaderFixed(
displayName = uiState.displayName,
handle = uiState.handle,
time = uiState.timeLabel,
showFollowButton = uiState.showFollowButton,
isFollowingInitial = uiState.isFollowingInitial,
onFollowChanged = onFollowChanged
)
PostBodyFixed(uiState.body)
uiState.tags?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
Spacer(Modifier.height(8.dp))
ActionRowFixed(initialLiked = uiState.initialLiked)
}
}
}
}
}
なんとなく良さそうな実装です。
この先タイムラインと詳細画面しか利用せず、画面に手が加わることもあまりないのであれば問題ないと思われます。
しかし、実際にはタイムラインと詳細画面の他にも、広告画面だったりさまざまな画面で利用されたり、
ABテストで少し変えたものを使ったりすることもあります。
そんな時に一つのComposableを使って共通化を試みると、if分岐の地獄に落とされることがあります。
if分岐の地獄に落ちたComposable
/* なんでも入りがちな「汎用」UIステート(すでに匂う) */
data class UniversalPostUiState(
val avatarText: String = "NA",
val displayName: String = "Nagumo",
val handle: String = "ngm_dev",
val timeLabel: String = "2時間前",
val body: String = "スロットAPI記事の制作メモ。1つのComposableで全部やると地獄を見る…。",
val tags: List<String>? = listOf("#Android", "#JetpackCompose"),
val isSponsored: Boolean = false,
val sponsorName: String? = null,
val likeCount: Int = 12,
val replyCount: Int = 3,
val shareCount: Int = 1
)
/**
* if分岐の地獄:1つのComposableで全画面・全バリエーション・A/Bテストまで処理しようとして崩壊。
* - 画面種別:タイムライン / 詳細 / 広告
* - A/Bテスト:control / A / B
* - 表示オプション:フォロー、メディア、カウント、コンパクト、強調表示、etc...
*/
@Composable
fun PostCardMonolith(
uiState: UniversalPostUiState,
modifier: Modifier = Modifier,
// 画面種別フラグ(本来はsealedで分けるべき)
isTimeline: Boolean = false,
isDetail: Boolean = false,
isAd: Boolean = false,
// A/Bテスト(文字列で分岐…)
abBucket: String = "control", // "A", "B" など
// 表示オプションが増え続ける…
showFollowButton: Boolean = false,
isFollowingInitial: Boolean = false,
showMedia: Boolean = true,
showActionCounts: Boolean = false,
compactMode: Boolean = false,
emphasizeAuthor: Boolean = false,
showHashtags: Boolean = true,
showTimestampAbsolute: Boolean = false,
useRoundedAvatar: Boolean = true,
showDividerBelow: Boolean = false,
debugOutline: Boolean = false
) {
// さらに状態もここで抱える
var following by remember { mutableStateOf(isFollowingInitial) }
var liked by remember { mutableStateOf(false) }
val isBucketA = abBucket.equals("A", ignoreCase = true)
val isBucketB = abBucket.equals("B", ignoreCase = true)
// 画面種別とA/Bの組み合わせでカードの見た目がコロコロ変わる(読み手涙目)
val cardShape =
if (isAd) RoundedCornerShape(if (isBucketB) 4.dp else 16.dp)
else if (isDetail) RoundedCornerShape(20.dp)
else RoundedCornerShape(if (isBucketA) 12.dp else 16.dp)
val containerColor =
if (isAd) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface
Card(
modifier = modifier.then(
if (debugOutline) Modifier
.padding(1.dp)
.background(MaterialTheme.colorScheme.error.copy(alpha = 0.05f))
else Modifier
),
shape = cardShape,
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Column(modifier = Modifier.padding(if (compactMode && isTimeline && !isDetail) 8.dp else 12.dp)) {
// 広告なら“広告”ラベルをどのバケットでも出すはずだが、なぜかBだけ表記が違う…
if (isAd) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isBucketB) "Sponsored" else "広告",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
// 広告だけはフォローを出さないはずが、Aバケットで誤って残っている…(事故ポイント)
if (showFollowButton && isBucketA) {
FilledTonalButton(
onClick = { following = !following },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
) {
Text(if (following) "フォロー中" else "フォロー")
}
}
}
Spacer(Modifier.height(8.dp))
}
Row {
// アバター形状が画面とバケットで変わる(UIの一貫性が死ぬ)
val avatarShape = if (useRoundedAvatar || isDetail) CircleShape else RoundedCornerShape(6.dp)
Box(
modifier = Modifier
.size(if (compactMode) 40.dp else 48.dp)
.clip(avatarShape)
.background(
if (isAd) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primaryContainer,
avatarShape
),
contentAlignment = Alignment.Center
) {
Text(
text = if (uiState.avatarText.isBlank()) "??" else uiState.avatarText,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
color = if (isAd) MaterialTheme.colorScheme.onSecondary else MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// ヘッダ(名前・ハンドル・時刻・フォロー)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// A/Bや強調フラグでフォントが変わる(読みづらい)
Text(
text = uiState.displayName,
style = if (emphasizeAuthor || isDetail)
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
else
MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)
)
Spacer(Modifier.width(6.dp))
// ハンドル&時刻も条件で書式が変わる
val timeText = if (showTimestampAbsolute && isDetail) {
// 本当はISOからフォーマットすべきだが、ここに生文字を突っ込む(技術的負債)
"2025-08-27 21:05"
} else uiState.timeLabel
Text(
text = if (uiState.handle.isBlank()) timeText else "@${uiState.handle} · $timeText",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.weight(1f))
// 広告以外で、タイムラインか詳細のときだけフォロー。さらにBは色違い。
if (!isAd && showFollowButton && (isTimeline || isDetail)) {
if (isBucketB) {
Button(
onClick = { following = !following },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
) { Text(if (following) "フォロー中" else "フォロー") }
} else {
FilledTonalButton(
onClick = { following = !following },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
) { Text(if (following) "フォロー中" else "フォロー") }
}
}
}
// 本文:コンパクト表示とバケットで行数がコロコロ
val maxLines =
if (isDetail) Int.MAX_VALUE
else if (compactMode && isTimeline) 2
else if (isBucketA) 3
else 4
Text(
text = uiState.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
// メディア:広告なら横長、Bは4:3、詳細は16:9固定…(要件が枝分かれ)
val shouldShowMedia = showMedia && (!compactMode || isDetail || isAd)
if (shouldShowMedia) {
Spacer(Modifier.height(8.dp))
val aspect = when {
isAd -> 1.91f / 1f
isBucketB && isTimeline -> 4f / 3f
else -> 16f / 9f
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspect)
.clip(RoundedCornerShape(if (isAd) 10.dp else 12.dp))
.background(
if (isAd) MaterialTheme.colorScheme.tertiaryContainer
else MaterialTheme.colorScheme.secondaryContainer
),
contentAlignment = Alignment.Center
) {
Text(
text = if (isAd) "AD Media" else "Media",
style = MaterialTheme.typography.labelLarge
)
}
}
// ハッシュタグ:広告では非表示、Bでは強制表示、コンパクトでは隠す…(矛盾の温床)
val showTags =
(showHashtags && !isAd && !compactMode) || (isBucketB && !isAd)
if (showTags) {
uiState.tags?.takeIf { it.isNotEmpty() }?.let { tags ->
Spacer(Modifier.height(4.dp))
Text(
text = tags.joinToString(" "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (compactMode) 1 else 2,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(Modifier.height(if (compactMode) 4.dp else 8.dp))
// アクション行:詳細では常にカウント表示、タイムラインはフラグ依存、広告はシェアのみ等…
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
AssistChip(
onClick = { /* reply */ },
label = {
Text(
if (isAd) "問い合わせ" // 広告だけラベルも違う
else if (showActionCounts || isDetail) "返信 (${uiState.replyCount})"
else "返信"
)
},
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) }
)
AssistChip(
onClick = { /* share */ },
label = {
Text(
if (showActionCounts || isDetail) "共有 (${uiState.shareCount})" else "共有"
)
},
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) }
)
AssistChip(
onClick = { liked = !liked },
label = {
Text(
when {
isAd -> "保存" // 広告だけ「いいね」禁止(でもBでは許可されてたり…?)
liked -> "いいね済み (${uiState.likeCount + 1})"
showActionCounts || isDetail -> "いいね (${uiState.likeCount})"
else -> "いいね"
}
)
},
leadingIcon = { Icon(Icons.Default.Favorite, contentDescription = null) }
)
}
// 広告ならCTA、詳細なら関連記事、Bは「後で読む」ボタン追加…などが混ざる
if (isAd) {
Spacer(Modifier.height(8.dp))
Button(onClick = { /* open sponsor */ }) {
Text(uiState.sponsorName?.let { "$it の詳細を見る" } ?: "詳細を見る")
}
} else if (isDetail && isBucketB) {
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { /* save for later */ }) {
Text("後で読む")
}
}
}
}
if (showDividerBelow && isTimeline && !isDetail) {
Spacer(Modifier.height(8.dp))
Divider()
}
}
}
}
中身は細かくチェックしていませんが、とにかく地獄の共通Composableを作ってもらいました。
上記は極端な例であり、実際にはもう少し秩序を持たせられるような工夫ができたりできなかったりするのですが、複雑性が高いのは間違いありません。
しかもいろんな画面で使われているというもんですから、なにかイジったらすぐ壊れそうで怖いです。
これが共通化の罠です。
繰り返しを避けるようとするあまり過度な抽象化をしてしまい、かえって管理がしにくくなるパターンですね。
また、今回は詳しい説明を省略しますが、型によるUIパターンの分岐を定義しておくやり方もあります。
しかし数パターンで決まっている場合ならともかく、アプリのコアな体験に近いUIは多種多様な場面で利用されるため、複雑性に対処できなくなる問題は残ったままです。
では、共通化は諦めてコードのコピーペーストを駆使しながら開発を進めていくべきなのでしょうか?
いや、それもできれば避けたいですよね。
そんな時にオススメしたいのが、満を持してご登場、スロットというわけです
スロットを利用したUIの共通化
@Composable
fun TimelinePostCard(
modifier: Modifier = Modifier,
leading: @Composable () -> Unit,
header: @Composable () -> Unit = {},
body: @Composable () -> Unit,
media: (@Composable () -> Unit)? = null,
actions: @Composable () -> Unit,
footer: @Composable () -> Unit = {}
) {
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Row(modifier = Modifier.padding(12.dp)) {
// ── 左側:アバターなど(leading slot)
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
leading()
}
Spacer(Modifier.width(12.dp))
// ── 右側:本文ブロック
Column(modifier = Modifier.weight(1f)) {
// ヘッダ(名前/ハンドル/時刻 など)
header()
// 本文
body()
// メディア領域(任意)
if (media != null) {
Spacer(Modifier.height(8.dp))
media()
}
// フッタ(任意:ハッシュタグ、位置情報、翻訳ボタンなど)
footer()
// アクション行(返信/共有/いいね など)
Spacer(Modifier.height(8.dp))
actions()
}
}
}
}
このようにスロットを利用してComposable自体を引数に持つことで、
この画面に何を表示するかについて、TimelinePostCardが関心を持つ必要がなくなっています。
利用する際は各パーツを呼び出すだけでよくなるので、
繰り返しの記述を除きながら、共通化における複雑化の問題も見事に解決しています。
スロットの注意点
ではスロットは共通化問題の銀の弾丸たり得るのか?というと、やはり向き・不向きがあります。
パターンが限られている時は、条件分岐やバリエーションの型の定義で十分
スロットのComposableの呼び出しと、条件分岐のComposableの呼び出しを比較してみます。
スロット
TimelinePostCard(
leading = { PlaceholderAvatar(initials = "NA") },
header = { PostHeader(displayName = "Nagumo", handle = "ngm_dev", time = "2時間前") },
body = {
PostBody(
"ComposeのスロットAPI解説を書いています。タイムラインの各部を" +
"差し替え可能なComposableとして設計すると表現力が上がります。"
)
},
media = null, // ← メディアなし
actions = {
ActionRow(
onReply = { /* no-op */ },
onShare = { /* no-op */ }
)
},
footer = {
Text(
text = "#Android #JetpackCompose",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
)
条件分岐
@Composable
fun PostCard_IfCall(pattern: PostPattern) {
TimelinePostCard(
uiState = TimelinePostUiState2(
avatarText = "NA",
displayName = "Nagumo",
handle = "ngm_dev",
timeLabel = "2時間前",
body = "条件分岐の比較用。呼び出し側は値を渡すだけ。",
tags = listOf("#Android", "#JetpackCompose")
),
showMedia = (pattern == PostPattern.WithMedia), // ← ここは値を渡すだけ
showFollowButton = false // ← ここも値を渡すだけ
)
}
上記の比較を見てわかるように、スロットは呼び出す時にComposableを差し込む必要があるので、やや冗長になります。
例えば2パターンしかないことが決まりきっているであれば、条件分岐で十分です。
文脈知識が必要
スロットは、コンポーネントをそれぞれ作っておけば、それを呼び出すだけで済むようになります。
しかし、どのコンポーネントを差し込むか理解しておく必要があるので、ドメイン知識の共有が少ないと、似たようなコンポーネントを誤って差し込んでしまうこともあります。
ユビキタス言語などを通じて、認識を合わせられるように工夫できると良いでしょう。
おわりに
スロットは特にパターンが多く、変更が頻繁に入る画面に向いています。
コア体験に関わる画面ほど使い回しも多く、スロットによる共通化が強力に働くことが多いです。
この記事の読者に、似たような画面の作り直しに飽き飽きとしている方がいらっしゃれば、ぜひUI共通化設計の見直しにスロットを検討してみてください!
Discussion