🎯

Jetpack ComposeでNegative Margin

2023/10/11に公開

はじめに

Negative Marginは文字通り負の値をもつMarginです。要素を互いに遠ざける正のMarginとは異なり、Negative Marginは要素を近づけたり要素を重ねたり、要素の位置の制約を調整するために使われます。Negative Marginの多用は好ましくないイメージがありますが、部分的に使いたいケースはあるでしょう。

Androidでは、ConstraintlayoutがNegative Marginをサポートしています(Version 2.1.0-alpha2以降)。Jetpack ComposeのConstraintlayoutも同様にNegative Marginをサポートしています。

一方で、Jetpack ComposeにはNegative Marginを直接サポートしていません。Negative Marginを利用するためにConstraintLayoutを用いるのは大袈裟です。また、paddingやその他のModifierを用いて要素の位置を調整することは可能ですが、親要素の描画領域を超えてComposableを配置するには、Jetpack Composeのフレームのレンダリングのフェーズについての理解が必要です。

そこで本、本記事ではModifierのlayoutを使用してJetpack ComposeでNegative Marginを実現する方法について紹介します。最初にComposeにおける基本的な位置調整とNegative Marginを考える上での問題点について説明します。次に説明のためにJetpack Composeのレンダリングのフェーズに簡単に触れた後、具体的でシンプルな実装例を紹介します。

Composeにおける位置調整のModifierとNegative Marginを実装する際の問題点

Jetpack ComposeではComposableの位置を調整するためにModifierを使用することがあります。位置を調整する代表的なModifierとしてはpaddingoffsetなどがあります。

しかし、paddingとoffsetではNegative Paddingを実現することはできません。paddingはNegativeな値が渡されるとIllegalArgumentExceptionがruntime時にthrowされます。また、offsetは引数にInt型のxとyを受け取ります。つまり、左右(上下)のいずれかのみの調整が可能で、左右に引き伸ばすことができません。要素の長さが保たれた状態で位置が調整され移動するので逆方向に不要なスペースが生じてしまいます。


Negative MarginにおけるModifier#offsetの問題点

Jetpack ComposeのレンダリングとLayoutフェーズ

Jetpack Composeも他のUIツールキットと同様に複数の異なるフェーズを介してレンダリングを行います。従来のAndroid Viewと比較して、Compositionというフェーズから開始されることが特徴的です。Jetpack Composeのレンダリングは主に次の3つのステップに分けられます。

  1. Composition
    どのようなUIを表示するかを決めるフェーズ。Composable関数を実行し、UIをツリー構造で表現する。

  2. Layout
    UIをどこに配置するかを決めるフェーズ。計測(Measurement)と配置(Placement)によって構成される。

  3. Drawing
    どのように描画するかを決めるフェーズ。UIツリーの各要素を順番に画面に表示する。


引用:https://developer.android.com/jetpack/compose/phases

Negative Marginを考える上で重要なフェーズであるLayoutについて説明します。Layoutフェーズでは、Compositionフェーズで生成されたUIツリーが入力として使用されます。UIツリーには、各ノード(UI要素)のサイズと2D空間での位置を決定するために必要な全ての情報が含まれています。Layoutフェーズもまた次の3つのステップでツリーの計測と配置を走査します。

  1. Measurement
    子ノードを計測する。

  2. Decide own size
    子ノードから返された計測結果を元に親ノード自身のサイズを決定する。

  3. Place children
    親ノードの左上隅を原点とした相対座標に子ノードを配置する。

つまり、Layoutフェーズを明示的に操作することでノードのサイズや座標を調整することができます。Jetpack ComposeではLayoutフェーズをカスタマイズする手段として、ModifierのlayoutLayout Composableが用意されています。これらは通常Custom layoutsを作成する場合に利用しますが、今回はNegative Marginを実装する目的で使用します。

Jetpack ComposeでNegative Margin

Jetpack ComposeでNegative Marginを実装する方法について考えていきましょう。今回は親のComposableの領域に対して子のComposableを左右に広げるケースについて紹介します。

子のComposableを広げる

まず、子のComposableを広げるケースを考えます。次のようなシナリオを仮定します。

  • 親のColumnにhorizontalのpaddingが設定されている
  • Column内に3つの要素が並び、2つ目の要素をColumnと同じ横幅に引き伸ばしたい

コードでのイメージ
Sample.kt
@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(
                horizontal = 16.dp, // horizontalにpadding
            )
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.LightGray)
        )

        // 目標: 左右に広げる
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.DarkGray),
        )

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.LightGray)
        )
    }
}

先述の通り、Negative Marginの実装に要素の測定と配置を変更することができるlayout Modifierを使用してみます。早速ですが、layoutを使用したhorizontalNegativeMarginLayout()というModifierの拡張関数を次のように定義します。

layout Modifierを用いたNegative Marginの実装
fun Modifier.horizontalNegativeMarginLayout(
    negativeMargin: Dp, // 1. 水平方向のNegative Marginを指定
) = layout { measurable, constraints ->
    // 2. constraintsの上書き
    val maxWidth = constraints.maxWidth + negativeMargin.roundToPx() * 2

    val newConstraints = constraints.copy(
        maxWidth = maxWidth,
    )

    // 3. Composableの計測
    val placeable = measurable.measure(newConstraints)

    // 4. サイズと位置の調整と配置
    layout(
        width = placeable.width,
        height = placeable.height,
    ) {
        placeable.place(
            x = 0,
            y = 0,
        )
    }
}

簡単にコードを解説すると、

  1. 水平方向のNegative Marginを指定
    Negative Marginの値を指定するためのパラメータを定義します。

  2. constraintsの上書き
    layoutのラムダから渡されるconstraintsを用いて、元の最大幅にNegative Marginの値の2倍を加算した新しいnewConstraintsを作成します。

  3. Composableの計測
    newConstraintsを使ってComposableの計測を行います。計測された情報はPlaceableとして返されるので変数として参照できるようにします。

  4. サイズと位置の調整と配置
    最後にlayout()を呼び出してコンテンツの配置を行います。サイズの指定は計測によって取得したPlaceableから情報を受け取ります。

horizontalNegativeMarginLayout()を呼び出してみましょう。Composableが左右に広げられているかを確認するためにText Composableを左上と右下に配置します。また、horizontalNegativeMarginLayout()の後にfillMaxWidth()をチェインします。

horizontalNegativeMarginLayout()
@Composable
fun Sample() {
    Column(
        modifier = Modifier
            .padding(
                horizontal = 16.dp, // horizontalにpadding
            )
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.LightGray)
        )

        Box(
            modifier = Modifier
                .horizontalNegativeMarginLayout(
                    negativeMargin = 16.dp, // 左右に16.dpずつ広げる
                )
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.DarkGray),
        ) {
            // 確認のためにTextを呼び出す
            Text(text = "左上", color = Color.White)
            Text(text = "右下", modifier = Modifier.align(Alignment.BottomEnd), color = Color.White)
        }

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(100.dp)
                .background(Color.LightGray)
        )
    }
}

Previewの出力は以下の通りです。親のComposableの領域を超えて左右に子のComposableを引きのばすことができました。

まとめ

Jetpack ComposeでNegative Marginを実現する方法としてlayout Modifierを用いた例を紹介しました。今回紹介した以外にもlayout Modifierを用いることで、Composableの位置をずらしたり、左右のMarginを個別に設定したり、正負の両方の値に対応したNegative Marginを実装したりすることが可能です。しかし、レイアウトの可読性や保守性を考慮するとNegative Marginの利用は極力避けたほうが無難だと感じます。

参考

Discussion