mcpField
C
compose-modifier-and-layout-style

compose-modifier-and-layout-style

github.com/chrisbanes開発1.1k1.0k 回インストール1 日前に更新
リポジトリ

compose-modifier-and-layout-style について

Kotlin Compose で composable を実装する開発者向けのスキルです。modifier の扱い、条件付きレイアウト、測定フェーズでの読み取り方を説明します。

主な機能

  1. 01呼び出し元の modifier をルートに適用
  2. 02ルートで親のレイアウト選択を固定しない
  3. 03modifier チェーンを一つの式で構築
  4. 04長い modifier チェーンを複数行で記述
  5. 05条件付きレイアウトを適切に外へ移動
  6. 06測定に依存する状態をレイアウト中に読み取り

活用シーン

  1. 01composable の modifier 対応を確認
  2. 02段階的な modifier の再代入をリファクタリング
  3. 03条件付きレイアウトのコードを整理
  4. 04コンポジション中の読み取りを避けて兄弟要素のサイズを揃える

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/chrisbanes/skills --skill compose-modifier-and-layout-style

インストール後、Claude Code で/compose-modifier-and-layout-styleを入力して読み込まれたか確認します。

詳細

Compose の modifier とレイアウトスタイル

基本原則

レイアウトを出力するコンポーザブルは、親が配置する末端要素です。位置、サイズ、配置、パディングは親が決めます。コンポーザブルの役割は構造(中に何があるか)であり、配置(どこに置くか)ではありません。ここから、次の3つのルールが導かれます。

  • modifier パラメーターを宣言し、ルートに適用します。 そうすれば、親が実際に配置を決められます。コンポーザブルのルートに .fillMaxWidth() をハードコードすると、今後のすべての呼び出し元からその判断を奪うことになります。
  • modifier チェーンは、段階的な再代入ではなく、流れるような1つの式として構築します。 どちらも同じものにコンパイルされますが、チェーン形式なら意図が一度に読み取れます。
  • 条件付きレンダリングは、その条件が適用される場所に置きます。 内容が1つの if だけのレイアウト呼び出しは、条件を入れるためだけに存在しています。代わりに if を外側に移します。

これらは互いに関係しています。同じコンポーザブルで、たいてい3つすべてが問題になるためです。パラメーターを宣言し(ルール1)、呼び出し元が配置用のチェーンを構築し(ルール2)、本体にはラップしたくなる条件分岐があるかもしれません(ルール3)。

このスキルを使う場面

  • @Composable fun を記述していて、レイアウト(Box、Column、Row、LazyColumn、Text、Image、Surface、Card、Layout { … }、compose.foundation.layout や compose.material* のあらゆる要素)を呼び出しているのに、シグネチャに modifier パラメーターがない場合、またはパラメーターがあってもルートに適用されていない場合、あるいはルートに .fillMaxWidth()/.padding(...) がハードコードされている場合。
  • var m = Modifier の後に m = m.padding(…)、m = m.background(…) などが続いている箇所を見つけた場合。
  • modifier = … 引数に、1行で3つ以上の呼び出しがチェーンされている場合。
  • コンポーザブルの本体が Layout { if (cond) Content() } になっている場合。条件分岐が1つだけで、ほかには何もありません。

1. modifier パラメーターを宣言する

レイアウトを出力するコンポーザブルでは、必須パラメーターの後、content/ラムダパラメーターの前に、デフォルト値を Modifier とする modifier パラメーターを置くことを推奨します。名前は正確に modifier とします。mod、m、wrapperModifier ではありません。

// ❌ BAD — no modifier param; caller can't position, size, or constrain this
@Composable
fun HomeScreenHeader(title: String, subtitle: String) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Text(title, style = MaterialTheme.typography.headlineLarge)
        Text(subtitle, style = MaterialTheme.typography.bodyMedium)
    }
}
// ✅ GOOD — parent decides width and padding; the composable describes structure only
@Composable
fun HomeScreenHeader(
    title: String,
    subtitle: String,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Text(title, style = MaterialTheme.typography.headlineLarge)
        Text(subtitle, style = MaterialTheme.typography.bodyMedium)
    }
}

これで呼び出し元は、ホーム画面で一度だけ HomeScreenHeader(title, subtitle, Modifier.fillMaxWidth().padding(horizontal = 16.dp)) と記述できます。レイアウトが実際に何を必要とするかを知っているのは、ホーム画面だけです。

2. 呼び出し元の modifier をルートに適用し、最初に適用する

ルートレイアウトがほかの引数(配置、並べ方、コンポーザブル固有のパディング)をすでに受け取る場合でも、呼び出し元から渡された modifier はルートレイアウトの modifier パラメーターに設定します。その後に、コンポーザブル内のチェーンを追加します。

// ❌ BAD — modifier accepted but never applied
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(painter = rememberAsyncImagePainter(url), contentDescription = null)
}

// ❌ BAD — applied to a child, not the root; caller's size/position changes don't take
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Box {
        Image(
            painter = rememberAsyncImagePainter(url),
            contentDescription = null,
            modifier = modifier,
        )
    }
}

// ❌ BAD — caller's modifier ends up last, so the composable's own size wins
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(
        painter = rememberAsyncImagePainter(url),
        contentDescription = null,
        modifier = Modifier
            .clip(CircleShape)
            .size(48.dp)
            .then(modifier),
    )
}
// ✅ GOOD — caller's modifier first, then the composable's intrinsic chain
@Composable
fun Avatar(url: String, modifier: Modifier = Modifier) {
    Image(
        painter = rememberAsyncImagePainter(url),
        contentDescription = null,
        modifier = modifier
            .clip(CircleShape)
            .size(48.dp),
    )
}

順序は重要です。modifier チェーンでは、先に記述したセグメントが外側のラッパーになります。呼び出し元の modifier を最も外側にすることで、呼び出し元が指定した .size(...) や .padding(...) によって、コンポーザブルのデフォルトを上書きできます。逆にすると、デフォルトに上書きされてしまいます。

3. ルートにレイアウトの判断をハードコードしない

コンポーザブルのルートに .fillMaxWidth()、.padding(horizontal = 16.dp)、.height(56.dp) などがあると、呼び出し元はそれらを使わないようにできません。これらは親が決めるべきレイアウト上の選択です。

// ❌ BAD — every caller now fills max width whether they want to or not
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(
        onClick = onClick,
        modifier = modifier.fillMaxWidth(),   // ← hardcoded
    ) { Text(text) }
}

// ✅ GOOD — caller adds .fillMaxWidth() if (and only if) they want it
@Composable
fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(onClick = onClick, modifier = modifier) { Text(text) }
}

例外となるのは、コンポーザブルのアイデンティティの一部である modifier です。たとえば、Avatar をアバターたらしめるもの(.clip(CircleShape) とデフォルトの .size(48.dp))であり、画面上の配置を決めるものではありません。次のように考えてみてください。このコンポーザブルからその modifier を外したものを、呼び出し元が望むことはあるでしょうか。あるなら、外に移します。ないなら(clip(CircleShape) のないアバターはアバターではありません)、そのままにします。ただし、チェーンでは呼び出し元の modifier の後に置いてください(§2 を参照)。

4. 修飾子チェーンは、1つの流れるような式として構築する

再コンポーズではコンポーザブルの本体が再実行されるため、修飾子の式はすべて再評価されます。var modifier = と段階的に再代入する方法は、一見もっともらしく見えますが、視覚的な流れを損ない、さらなる変更を招き、チェーンでできること以上のものは何も生み出しません。

// ❌ BAD — visual flow broken into reassignments; `var` invites more mutation
@Composable
fun Demo() {
    var m = Modifier
    m = m.padding(16.dp)
    m = m.fillMaxSize()
    Box(m) { }
}

// ❌ ALSO BAD — same shape, dressed up with .then()
@Composable
fun Demo() {
    var m = Modifier
    m = m.padding(16.dp)
    m = m.then(Modifier.fillMaxSize())
    Box(m) { }
}
// ✅ GOOD
@Composable
fun Demo() {
    val m = Modifier
        .padding(16.dp)
        .fillMaxSize()
    Box(m) { }
}

var ではなく val を使います。チェーンを構築したら、再代入する必要はありません。再代入する形にすると var が必要に見えますが、チェーンの形なら不要です。

短いチェーンは呼び出し元にインラインで記述してかまいません

呼び出しが1つか2つなら、修飾子をインラインで構築します。「val に切り出す」というルールが役立つのは、名前を付ける価値があるほどチェーンが長い場合、または同じチェーンを繰り返し使う場合だけです。

// ✅ GOOD — short chain inline
Box(modifier = Modifier.fillMaxWidth()) { … }
Box(modifier = Modifier.padding(8.dp).background(Color.Red)) { … }
条件付きの部分もチェーンにつなげます

var を使いたくなるよくある理由は、「修飾子が条件によって変わる」ことです。そうではなく、条件をインラインで組み込みます。

// ✅ GOOD — conditional inside the chain, still one expression
Box(
    modifier = Modifier
        .fillMaxWidth()
        .then(if (selected) Modifier.background(Color.Red) else Modifier),
)

空の修飾子である Modifier は、.then の単位元です。一方の分岐で何も追加しない場合でも、チェーンの形を保てます。

5. 呼び出し箇所での複数行フォーマット

modifier 引数のチェーンに3つ以上の呼び出しがある場合は、呼び出しごとに1行を使って複数行で記述します。値の下にドット付きの呼び出しがそろうよう、チェーンをインデントします。

// ❌ BAD — three+ calls on one line; hard to scan
Box(
    modifier = modifier.fillMaxSize().padding(16.dp).weight(1f),
)

// ✅ GOOD
Box(
    modifier = modifier
        .fillMaxSize()
        .padding(16.dp)
        .weight(1f),
)

呼び出しが1つか2つなら、1行のままにします。基準は文字数ではなく、呼び出しの数です。1つの呼び出しの引数が非常に長い場合は別の問題なので、val に切り出すか、引数を短くしてください。

これは modifier という名前のパラメーターにのみ適用されます。ほかの流れるような形式の引数は対象外です。

6. 単独の条件式はレイアウトの外に出す

レイアウトの唯一の内容が1つの if である場合、そのレイアウトは条件式を「保持する」ためだけに存在しています。if を外に出せば、表示する内容があるときだけレイアウトが存在するようになります。

// ❌ BAD — Column always emitted; only its inner content is conditional
@Composable
fun A() {
    Column {
        if (showHeader) {
            Text("Title")
            Text("Subtitle")
        }
    }
}

// ✅ GOOD — Column only exists when it has content
@Composable
fun A() {
    if (showHeader) {
        Column {
            Text("Title")
            Text("Subtitle")
        }
    }
}

メリットはパフォーマンスの向上ではありません。実行時はどちらの形式でも問題ありません。違いは、2つ目の形式が「ヘッダーセクションが条件付きで表示される」と読めることです。1つ目の形式は「常に存在する列に、内容がある場合とない場合がある」と読めます。

例外とその理由
  • レイアウトが、条件に依存しない視覚的な意味を持つ場合。 レイアウト呼び出しに modifier、contentAlignment、horizontalArrangement、または verticalAlignment を渡す場合、それらの引数はコンテンツではなくコンテナーを表します。条件式を外に出すと、それらが失われる(コンテンツとともにコンテナーが消える)か、両方の分岐に重複して記述することになります。そのままにします。

    // ✅ KEEP AS-IS — modifier on the container is doing visible work
    @Composable
    fun A(modifier: Modifier = Modifier) {
        Box(modifier = modifier) {
            if (something) {
                Text("Bleh1")
                Text("Bleh2")
            }
        }
    }
    
  • if のほかに兄弟要素がある場合。 レイアウトにほかのコンテンツがあり、if はその一部にすぎません。条件式を外に出すと、兄弟要素も外に移動してレイアウトが変わるか、異なる形が残ります。そのままにします。

  • if … else … の両方の分岐にコンポーザブルがある場合。 両方の分岐で処理が行われるため、外に出せるものはありません。レイアウトが共有コンテナーです。

    // ✅ KEEP AS-IS — both branches contribute to the layout
    Box {
        if (something) Text("Hint") else innerTextField()
    }
    

7. 計測フェーズでの制約の装飾

コンポーザブル A がサイズを取得し、コンポーザブル B がそのサイズに合わせる必要がある場合、取得したサイズを B のコンポーザブル本体で読み取らないでください(Modifier.height(state.dp))。計測状態が変わるたびに B が再コンポーズされるためです。

A のレイアウトコールバックでサイズを取得し、B では Modifier.layout 内で適用すれば、無効化はレイアウトだけで済みます。

fun Modifier.decorateMeasureConstraints(
    decorate: (Constraints) -> Constraints,
): Modifier = layout { measurable, incoming ->
    val constraints = decorate(incoming).constrain(incoming)
    val placeable = measurable.measure(constraints)
    layout(placeable.width, placeable.height) {
        placeable.placeRelative(0, 0)
    }
}
// Hoisted at the common parent of both rows:
//   var anchorHeightPx by remember { mutableIntStateOf(0) }

// Measured row — write state only from onSizeChanged
RowAnchor(Modifier.onSizeChanged { size -> if (size.height != anchorHeightPx) anchorHeightPx = size.height })

// Sibling rows — read anchorHeightPx only inside layout
RowSibling(
    Modifier.decorateMeasureConstraints { incoming ->
        if (anchorHeightPx > 0) {
            // Clamp to incoming bounds so the constraint never exceeds the parent's max.
            incoming.copy(minHeight = anchorHeightPx, maxHeight = anchorHeightPx)
        } else {
            incoming
        }
    },
)

anchorHeightPx が 0 の間だけ、コンポジション時のフォールバック(固定の高さ)を使います。行をまたぐパターン全体については、compose-state-deferred-reads を参照してください。

クイックリファレンス

症状診断修正
本体に Column/Box/Text がある @Composable fun Foo(text: String)modifier パラメーターがありません(§1)modifier: Modifier = Modifier を追加し、ルートに渡します
modifier: Modifier = Modifier を宣言しているが、参照されていないパラメーターが無視されています(§2)ルートレイアウトの modifier 引数に適用します
modifier をルートではなく子に渡している対象が違います(§2)最も外側のレイアウトの modifier に移します
modifier = Modifier.x().y().then(modifier)呼び出し元の modifier が最後です(§2)順序を入れ替えます:modifier = modifier.x().y()
汎用コンポーネントで modifier = modifier.fillMaxWidth().padding(...) を指定しているレイアウトが固定されています(§3)固定の呼び出しを削除し、呼び出し元に追加させます
ファイル内の兄弟コンポーザブルにも modifier がないアンチパターンの拡散この箇所を修正し、機会があれば兄弟も修正します
mod: Modifier = Modifier または wrapperModifier: Modifier = Modifier名前が違います(§1)正確に modifier に変更します
var m = Modifier の後に m = m.xxx() の再代入がある段階的な Modifier の構築です(§4)val で 1 つの連結チェーンを使うか、インラインで構築します
var m = Modifier; m = m.then(Modifier.xxx()).then を使った同じパターンです(§4).then(Modifier.x()) をチェーン内の .x() にまとめます
Modifier の分岐に条件が必要var を使おうとしています(§4)チェーン内で .then(if (c) Modifier.x() else Modifier) を使います
modifier = modifier.a().b().c() が 1 行にある長いチェーンのフォーマットが不適切です(§5)1 行に 1 つの呼び出しとし、値の下にインデントします
他に内容もレイアウト調整用の引数もない Layout { if (cond) X() }外に移動します(§6)if をレイアウトの外に移します
Box(modifier = …) { if (cond) X() }レイアウトがセマンティクスを担っています — そのままにします(§6 の例外)変更しません
Box { if (cond) X() else Y() }どちらの分岐も寄与しています — そのままにします(§6 の例外)変更しません
兄弟の遅延行が、別の行の計測から height(state) を読み取っているコンポジション時のサイズ依存です(§7)計測される行で値を取得し、兄弟には decorateMeasureConstraints を使って適用します

適用しない場合

  • レイアウトを出力しないコンポーザブル。 @Composable fun computeColor(): Color や @Composable @ReadOnlyComposable アクセサーは、レイアウトノードを出力しません。modifier パラメーターは不要です(また、@ReadOnlyComposable はパラメーターとして受け取れません。compose-state-authoring を参照)。
  • @Preview 関数。 プレビューは使い捨てのエントリーポイントであり、フレームワークは呼び出し元なしで呼び出します。modifier パラメーターは使われない無駄な要素になります。
  • *Test ソース内のテスト専用コンポーザブルで、呼び出し元が composeTestRule.setContent { … } だけのもの。プレビューと同じ理由です。
  • modifier を最初の必須パラメーターとして受け取る内部レイアウトプリミティブ(非常にまれで、通常はフレームワークレベルのもの)。ルールは「最初の省略可能なパラメーター」です。一部のプライベートユーティリティでは、modifier が先頭の必須パラメーターであっても問題ありません。
  • アニメーション状態から命令的に組み立てる Modifier。 Animatable などの手続き型の値を追加して構築する Modifier では、中間変数が正当に必要になることがあります。目的はチェーンそのものではなく、読みやすさです。チェーンにすると式がかえって読みにくくなる場合は、命令的な形式で記述してください。
  • Modifier を保存するスロット API(データクラスやビルダーを使うもの。まれで、通常はフレームワークレベルのコード)。フルーエントチェーンの考え方は、利用側での構築に関するものです。
  • 特定の再コンポジション形状を固定するテスト用コンポーザブル — どちらの形式でも通常は問題ありません。スタイルだけを理由にテスト用コンポーザブルをリファクタリングしないでください。

「このコンポーザブルは内部用だから」「使われるのは 1 か所だけだから」「シグネチャに余分なパラメーターを加えたくないから」「呼び出し元はすべて把握しているから」といった理由で、宣言側のルール(§1〜§3)を省略してはいけません。こうした理屈こそが、誰かが 2 回呼び出そうとしたその日に単一用途になってしまうコンポーザブルを生み出します。

レビュー時の注意点

考え実情
「このコンポーザブルは内部専用だから、modifier を追加するのは過剰設計です」パラメーターは8文字とデフォルト値だけです。過剰設計ではなく、慣例です。省略することこそ過剰設計です。Compose API のあらゆる慣例に反する独自の判断だからです。
「1か所でしか使っていないので、レイアウト要件は把握しています」「1か所でしか使っていない」というのは現時点の話です。パラメーターを追加するコストは一度だけですが、2か所目で呼び出し側をリファクタリングするコストは、呼び出し側ごとに発生します。
「このファイルの兄弟コンポーザブルにも modifier がないので、スタイルを合わせています」アンチパターンを広めるのは、スタイルを合わせることではありません。まずこれを直してください。兄弟コンポーザブルも機会を見て直してください。
「ここでは親が常に .fillMaxWidth() を必要とします」それなら親が .fillMaxWidth() を渡します。まだ出会っていない呼び出し側のために、コンポーザブルが判断することではありません。
「必要になったら追加します」あなたがその人です。今、(慣例に従うために)必要です。次の呼び出し側も追加せず、パラメーターがない状態を回避する方法を見つけるでしょう。
「小さなコンポーザブルなのに、modifier パラメーターは余計です」宣言時のパラメーターは8文字で、必要としない呼び出し側では文字数ゼロです。「余計」というのは思い込みです。
「modifier を追加しましたが、ホーム画面で指定しなくて済むよう、ルートに .fillMaxWidth() を残しました」そうすると、ホーム画面以外の呼び出し側ではそれを解除できません。.fillMaxWidth() は呼び出し側に移してください。
「チェーンが条件によって変わるので、modifier には var が必要です」条件付きの部分は、.then(if (c) Modifier.x() else Modifier) とすれば、チェーンをひと続きに保てます。var は不要です。
「3行では複数行にするには短すぎます」チェーン呼び出しは3つが基準です。3つ未満なら1行、3つ以上なら複数行にします。
「Column は何も追加しませんが、対称性のために残します」それなら条件判定を外側に移し、条件が成立した場合の処理の中に Column を残してください。対称性を保ちながら、常に存在するコンテナーをなくせます。
「レイアウトがすでに存在するので、内側に if を置きます」「すでに存在する」ことが問題です。条件が false の場合、レイアウト自体が存在してはいけません。

関連項目

  • compose-slot-api-pattern — 再利用可能なコンポーザブルの公開 API を宣言する、もう一つの要点です。可変コンテンツには @Composable () -> Unit スロットを受け取ります。再利用可能なコンポーネントは modifier パラメーターとスロットの両方を受け取り、配置する場所と配置する内容の両方を呼び出し側が決めます。
  • compose-state-deferred-reads — フェーズをまたぐ書き戻しと、遅延された計測値の読み取りについて説明します。

コメント