compose-slot-api-pattern について
再利用可能なコンポーネント向けの Compose API 設計スキルです。コンポーザブルスロットによる可変コンテンツの受け渡しを、スコープやデフォルト値の指針とともに説明します。
主な機能
- 01コンテンツ引数をコンポーザブルスロットに置換
- 02スロットに適したスコープレシーバーの選択
- 03null による任意スロットの表現
- 04Defaults オブジェクトへの共通デフォルト集約
- 05スロットが適さないケースの提示
活用シーン
- 01再利用可能なコンポーネントの引数設計レビュー
- 02見出し、アイコン、操作領域の呼び出し側カスタマイズ
- 03コンテンツ不在時の余分なレイアウト領域の回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-slot-api-patternインストール後、Claude Code で/compose-slot-api-patternを入力して読み込まれたか確認します。
詳細
Compose:スロット API パターン
基本原則
再利用可能な Compose コンポーネントでは、レイアウト構造を定義します。呼び出し側はスロットを通じて、変化する視覚的なコンテンツを提供します。
API のレビュー手順
- コンポーネントが再利用可能か確認します。本当に一度しか使わないコンポーザブルには、スロットを追加する必要はありません。
- 呼び出し側によって変わる領域を特定します。見出し、補足テキスト、先頭のビジュアル、末尾のビジュアル、アクション、本文などです。
- 呼び出し側が制御するプリミティブなコンテンツや形状フラグをスロットに置き換えます。
- スロットを、呼び出し側がスコープ API を使うべきレイアウト内で出力する場合にのみ、レシーバースコープを追加します。
- 省略可能な領域は
nullを指定できるようにし、コンポーネントがコンテナや間隔を省略できるようにします。 - 繰り返し使うデフォルトコンテンツやトークンは
XxxDefaultsに配置します。 - これを
compose-modifier-and-layout-styleのmodifierルールと併せて適用します。
1. プリミティブなコンテンツを @Composable スロットに置き換える
呼び出し側が制御する コンテンツ をコンポーネントが求める場合は、@Composable () -> Unit スロットを優先します。スロットが構造上必須の場合は、デフォルト値のない非 nullable にします。省略可能な場合は、null をデフォルト値とする nullable にします。
// ❌ BAD — primitive parameters; trailing area is the only slot; everything else is locked
@Composable
fun SettingsRow(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
leadingIcon: ImageVector? = null,
trailing: (@Composable () -> Unit)? = null,
) { … }
// ✅ GOOD — every visual region is a slot; the row describes structure, not content
@Composable
fun SettingsRow(
headlineContent: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
supportingContent: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
) { … }
一般的なコンテンツが 1 行で済む場合、呼び出し側のコードを短くできます。
SettingsRow(
headlineContent = { Text("Account") },
leadingContent = { Icon(Icons.Default.Person, contentDescription = null) },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)
特殊なケースでも、新しいコンポーネントパラメーターを追加する必要はありません。
SettingsRow(
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Inbox")
Spacer(Modifier.width(8.dp))
Badge { Text("3") }
}
},
onClick = { … },
)
スロットの命名
- 自由形式の
@Composable () -> UnitスロットにはxxxContentを使います(headlineContent、supportingContent、trailingContentなど)。Material 3 と同じ命名規則です。 - スロットの意味が限定され、コンポーネント名で意味が明確になる場合は、単数形の名詞を使います(
Scaffold(topBar = { … }, bottomBar = { … }, floatingActionButton = { … })など)。 contentと他のxxxContentスロットを併用しないでください。コンポーネントごとにどちらかの規則を選びます。
2. スロットがレイアウト内に出力される場合はスコープレシーバーを使う
スロットのコンテンツが、呼び出し側にレイアウト機能(Modifier.weight、BoxScope.matchParentSize、配置)を提供すべき Row/Column/Box 内に配置される場合、スロットをレシーバーラムダとして宣言します。@Composable RowScope.() -> Unit のようにします。
// ❌ BAD — actions render inside a Row, but callers can't use RowScope.weight()
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable () -> Unit = {}, // ← caller has no Row scope
)
// ✅ GOOD — caller gets RowScope; .weight() and alignment-by works inside
@Composable
fun MyTopBar(
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
)
これにより、TopAppBar(actions = { IconButton(…); IconButton(…) }) が機能します。呼び出し側は暗黙的に RowScope 内にいるためです。
すべてのスロットに反射的にスコープレシーバーを追加しないでください。レシーバーは、スロットが出力される実際の親レイアウトに合わせます。スロットが Box 内にレンダリングされる場合は BoxScope、Column 内の場合は ColumnScope を使います。親が標準レイアウトではない場合や、そのスコープ API がスロットのコンテンツで役立たない場合は、レシーバーを使いません。
3. 省略可能なスロット — null をデフォルト値とする nullable にする
省略される可能性のあるスロットには、@Composable () -> Unit = {} よりも (@Composable () -> Unit)? = null を優先します。
// ❌ BAD — empty default; "no leading content" is the empty lambda
leadingContent: @Composable () -> Unit = {}
// ✅ GOOD — null means "no slot"; the component can omit space/padding when absent
leadingContent: (@Composable () -> Unit)? = null
nullable スロットであれば、コンポーネントは leadingContent != null を条件に分岐し、スロットのコンテナ、間隔、パディングをすべて省略できます。空のデフォルト値を使うと、コンテンツがない場合でもレイアウトにスペースが確保されることがよくあります。
4. デフォルト値は XxxDefaults に配置する
「末尾のスロットには通常シェブロンを使う」や「デフォルトの背景には MaterialTheme.colorScheme.surface を渡す」といった説明が必要になったら、ヘルパーをコンポーネントの隣にある XxxDefaults オブジェクトにまとめます。
object SettingsRowDefaults {
@Composable
fun Chevron() = Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
)
@Composable
fun TrailingValue(text: String) = Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
一般的なケースでは呼び出し側のコードを宣言的に保ちながら、スロットは個別のケースでも完全に自由に使えます。
SettingsRow(
headlineContent = { Text("Notifications") },
trailingContent = { SettingsRowDefaults.Chevron() },
onClick = { … },
)
これは Material 3 の ButtonDefaults、TopAppBarDefaults などと同じ考え方です。デフォルト値そのものがコンポーザブルである場合は、MaterialTheme.x.y をインラインで展開したデフォルト値を持つ新しいコンポーネントパラメーターにするのではなく、ここに配置します。
クイックリファレンス
| 症状 | 診断 | 修正 |
|---|---|---|
再利用可能なコンポーネントに title: String, subtitle: String?, leadingIcon: ImageVector? がある | プリミティブなコンテンツパラメーター(§1) | xxxContent: (@Composable () -> Unit)? スロットに変換します |
末尾の形状を選択する複数のブールフラグ(showChevron、showSwitch) | 形状の列挙(§1) | trailingContent: (@Composable () -> Unit)? スロットを1つ使います |
バリアントを列挙する mode: Mode.Sealed パラメーター | フラグの羅列と同じです(§1) | スロットにします |
Row 本体内の actions: @Composable () -> Unit = {} | スコープレシーバーの不足(§2) | actions: @Composable RowScope.() -> Unit = {} にします |
任意の領域に対する slot: @Composable () -> Unit = {} | 空ラムダのデフォルト値(§3) | slot: (@Composable () -> Unit)? = null にし、条件分岐します |
コンポーネントのパラメーター defaultColor: Color = MaterialTheme.colorScheme.surface | デフォルト値のインライン化(§4) | XxxDefaults.color に移動し、それを参照します |
| 共通の末尾コンテンツが呼び出し箇所ごとに繰り返される | デフォルトヘルパーの不足(§4) | XxxDefaults.Chevron() などを追加します |
適用しない場合
- 1か所だけで使うコンポーネント。 再利用する予定がなく、ちょうど1か所で使うコンポーザブルでは、スロットの柔軟性は役に立ちません。スロットを介することで、1人の読み手にとってコードが読みにくくなります。プリミティブなパラメーターとインラインコンテンツで問題ありません。(2つ目の呼び出し箇所ができたら、スロットにします。)
- すべての呼び出し元で見た目を統一する必要があるデザインシステムのプリミティブ。
Heading2(text: String)が存在するのは、すべての H2 の見た目を同じにしたいからです。headlineContent: @Composable () -> Unitにすると、呼び出し元がルールを破る余地が生まれます。プリミティブのままにしてください。(一方、Heading2にバッジをインラインで表示する必要が生じたら、スロットにします。) - コンポーネントが意図的に所有するセマンティックなパラメーター。 コンポーネントがタイポグラフィ、アイコン、アクセシビリティ上の文言、またはプロダクトの一貫性を所有する場合、プリミティブなパラメーターが必要な制約になることがあります。
- 制約が実際に必要な型のパラメーター。
Switch(checked: Boolean, onCheckedChange: ...)では、チェック状態を示すインジケーターをスロットにする必要はありません。コールバックを伴うブール値は「コンテンツ」ではありません。 - パフォーマンスが重要な高速経路(アプリコードではまれで、フレームワークのプリミティブではよくあります)。スロットはラムダを割り当てます。最も深い LazyList 項目のレイヤーでは、プリミティブのほうが適している場合があります。フレームワークを書いていないのであれば、これは該当しません。
レビューでの注意点
| 考え | 実際のところ |
|---|---|
| 「タイトルは常に String だから、スロットにするのは過剰設計です」 | 「今日のところは常にそうだ」というのが落とし穴です。Material の ListItem.headlineContent があるのは、明日になって誰かが Text + Badge を使いたくなるからです。スロットを使うと、呼び出し箇所ごとにラップするコードが8文字増えます({ Text(…) })。あとからスロットを追加するリファクタリングでは、既存の呼び出し箇所をすべて編集することになります。 |
| 「ラムダは文字列より重いです」 | 一般的な Compose UI の規模では、測定できるほどの差はありません。また、フレームワーク自体のコンポーネント(Button、ListItem、TopAppBar、Scaffold)はすべてスロットを使っています。コンポーネントが非常にホットなパスにある場合は、「適用しない場合」を参照してください。 |
| 「誰かに頼まれたら、あとでスロットを追加します」 | スロットを追加すると、1つのパラメーターが2つ(スロット自体と、場合によっては内部フラグ)になり、すべての呼び出し箇所を編集することになります。形状の変更は「あとで」行うものではありません。 |
「代わりに、sealed な Trailing 型でバリエーションをモデル化します」 | sealed な列挙型には上限がありますが、スロットにはありません。sealed 型は、想定していなかったバリエーションが必要になるまでは機能します。その時点で、コンポーネントの編集に戻ることになります。スロットなら、この繰り返しを避けられます。 |
| 「先頭の領域は常にアイコンで、末尾の領域だけが変わります。末尾だけをスロットにします」 | これは部分スロットの落とし穴です。「常にアイコン」という想定は、行にアバター、旗の絵文字、色付きの図形が必要になったときに破綻します。先頭もスロットにしてください。 |
| 「今日のところ、呼び出し箇所は1つだけです」 | 呼び出し箇所が1つしかないなら、まだ再利用可能なコンポーネントを設計していないのでしょう。「適用しない場合」を参照してください。本当に一度しか使わないものなら、プリミティブで問題ありません。コピー&ペーストした時点で、スロットにしてください。 |
関連項目
compose-modifier-and-layout-style— 修飾子パラメーターのルール(そちらの§1~§3)は、スロット API にも適用されます。再利用可能なコンポーネントにはmodifierパラメーターを持たせ、コンテンツをスロットにします。配置と配置する内容の両方を呼び出し側が担います。