Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Jetpack ComposeでModifierの関数はどうして使えたり使えなかったりするのか

皆さん Jetpack Compose は触っていますか?
Jetpack Compose といえば Modifier ですが、Modifier の関数は場所によって使えたり使えなかったりする場合があると思います。 どうなっているのでしょうか?

例えばこの画像のようなものを実装したいとします。

f:id:tya_pon:20211222120442p:plain
実装したいコンポーザブルの画像
方法はいくつかあると思いますが、今回は Modifier.align(Alignment.Center) を使いたいと思います。

次のコードでは、Box コンポーザブルの中にある Text コンポーザブル内では Modifier.align(Alignment.Center) は期待通り動作します。

@Composable
fun HogeComposable() {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .height(70.dp)
            .background(Color.White)
    ) {
        // Box コンポーザブルの中では Modifier.align(alignment: Alignment) を解決できる
        Box {
            Text(
                text = "真ん中に表示したいテキスト!",
                fontSize = 20.sp,
                modifier = Modifier.align(Alignment.Center)
            )
        }
    }
}

Box コンポーザブルを利用せず、Text コンポーザブルをそのまま書いた場合は、Text 内の Modifier.align(Alignment.Center) は解決できず、Unresolved reference: align エラーとなります。

@Composable
fun HogeComposable() {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .height(70.dp)
            .background(Color.White)
    ) {
        // Text コンポーザブルをそのまま書いた場合は Modifier.align(alignment: Alignment) を解決できない
        Text(
            text = "真ん中に表示したいテキスト!",
            fontSize = 20.sp,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

なんで Box コンポーザブルの中でないと Modifier.align(alignment: Alignment) が解決できないんだろう?

Compose では、カスタム スコープによってこの型の安全性が適用されます。たとえば、matchParentSize は BoxScope でのみ使用できます。

https://developer.android.com/jetpack/compose/layouts/basics?hl=ja#type-safety

なるほど、どうやら BoxScope というカスタムスコープがあるらしい?

実際に Box コンポーザブルの実装を見てみます。

@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt;l=64-77?q=BoxScope&ss=androidx%2Fplatform%2Fframeworks%2Fsupport:compose%2F

何やら content: @Composable BoxScope.() -> Unit が怪しそうですね。BoxScope を追ってみます。

@LayoutScopeMarker
@Immutable
interface BoxScope {
    /**
     * Pull the content element to a specific [Alignment] within the [Box]. This alignment will
     * have priority over the [Box]'s `alignment` parameter.
     */
    @Stable
    fun Modifier.align(alignment: Alignment): Modifier

    /**
     * Size the element to match the size of the [Box] after all other content elements have
     * been measured.
     *
     * The element using this modifier does not take part in defining the size of the [Box].
     * Instead, it matches the size of the [Box] after all other children (not using
     * matchParentSize() modifier) have been measured to obtain the [Box]'s size.
     * In contrast, a general-purpose [Modifier.fillMaxSize] modifier, which makes an element
     * occupy all available space, will take part in defining the size of the [Box]. Consequently,
     * using it for an element inside a [Box] will make the [Box] itself always fill the
     * available space.
     */
    @Stable
    fun Modifier.matchParentSize(): Modifier
}

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt;l=208-235?q=BoxScope&ss=androidx%2Fplatform%2Fframeworks%2Fsupport:compose%2F

Modifier.align(alignment: Alignment): Modifier が Kotlin の Extension functions で宣言されていることがわかりました。
つまり、BoxScope の中であれば Modifier.align() を解決することができます。

では、content: @Composable BoxScope.() -> UnitBoxScope.() とは?

これは Function literals with receiver といい(詳しくはリンク先を見てください)、呼び出しに渡されるレシーバーオブジェクトが暗黙の this になるため、今回では BoxScope が暗黙の this になります。
なのでここで渡されている lambda の中では Modifer.align(alignment: Alignment) を特に気にすることなく呼ぶことができます。

そして最後、BoxScope は interface なので、その実装はどうなっているのかをみます。

Box コンポーザブルの実装で BoxScopeInstance.content() とありますね。content は content: @Composable BoxScope.() -> Unit なので、この場合 this は BoxScopeInstance になります。
つまり、Box コンポーザブル内の Modifier.align(alignment: Alignment) は実際には BoxScopeInstance の実装が使われることになります。

BoxScopeInstance をみます。
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/Box.kt;l=237-258?q=BoxScope&ss=androidx%2Fplatform%2Fframeworks%2Fsupport:compose%2F

BoxScope interface に沿って Modifier.matchParentSize()Modifier.align(alignment: Alignment) の実装が書いてありました。

このようにして特定のスコープのみで使える仕組みを作り上げていたということになります。

この仕組みは Box コンポーザブルに限らず、Column コンポーザブルや Row コンポーザブル等様々あります。
もし「あれ?いつも使ってる Modifer の関数がない!」となったら、スコープが正しいのかを見てみるといいかもしれません。

いや〜面白いですね。