言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

Modifier.verticalScroll() を親に設定すると子のfillMaxHeight() が無視される仕組みを調べた

Android ComposeのModifier.verticalScroll()が親のcomposableに設定されていた場合に、子に Modifier.fillMaxHeight()を設定してもこれが無視される現象に遭遇した。どういった仕組みでそうなっているのか内部実装を読んだ時のメモを残す。

知りたいこと

例えば、以下のように親に verticalScroll() がついていないと子のfillMaxHeight()は期待通りに動作する。

@Composable
fun Sample(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier
    ) {
        Text(
            modifier = Modifier.fillMaxHeight(),
            text = "Without verticalScroll",
        )
    }
}

しかし親に verticalScroll() をつけると挙動が変わりfillMaxHeight()が無視される。

@Composable
fun Sample(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier.verticalScroll(rememberScrollState())
    ) {
        Text(
            modifier = Modifier.fillMaxHeight(),
            text = "With verticalScroll",
        )
    }
}

具体的にverticalScroll()が何をしてこの差が生まれるのか知りたい。

公式ドキュメント

developer.android.com

Modify element to allow to scroll vertically when height of the content is bigger than max constraints allow.

とあるので、コンテンツのサイズが親のサイズに依存しているとscrollするかどうかの計算ができないだろうから無視されるんだろうなーという空気を感じる。

内部実装

Modifier.verticalScroll() が中で何をやっているかを読んでいく。 色々省略するが、ScrollingLayoutModifierが使われるのがポイント。

fun Modifier.verticalScroll(
    state: ScrollState,
    enabled: Boolean = true,
    flingBehavior: FlingBehavior? = null,
    reverseScrolling: Boolean = false
) = scroll(
    state = state,
    isScrollable = enabled,
    reverseScrolling = reverseScrolling,
    flingBehavior = flingBehavior,
    isVertical = true
)
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.scroll(
    state: ScrollState,
    reverseScrolling: Boolean,
    flingBehavior: FlingBehavior?,
    isScrollable: Boolean,
    isVertical: Boolean
) = composed(
    factory = {

// いろいろ省略

        val layout =
            ScrollingLayoutModifier(state, reverseScrolling, isVertical)
        semantics
            .clipScrollableContainer(orientation)
            .overscroll(overscrollEffect)
            .then(scrolling)
            .then(layout)
    },

で、このScrollingLayoutModifier()が子のconstraints を上書いてしまっていて、maxHeightを設定していたとしてもInfinityにしてしまっている。

private data class ScrollingLayoutModifier(
    val scrollerState: ScrollState,
    val isReversed: Boolean,
    val isVertical: Boolean
) : LayoutModifier {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        checkScrollableContainerConstraints(
            constraints,
            if (isVertical) Orientation.Vertical else Orientation.Horizontal
        )

        val childConstraints = constraints.copy(
            maxHeight = if (isVertical) Constraints.Infinity else constraints.maxHeight, // ★ここ!!!
            maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity
        )
        val placeable = measurable.measure(childConstraints)
        val width = placeable.width.coerceAtMost(constraints.maxWidth)
        val height = placeable.height.coerceAtMost(constraints.maxHeight)

// いろいろ省略

        return layout(width, height) {
            val scroll = scrollerState.value.coerceIn(0, side)
            val absScroll = if (isReversed) scroll - side else -scroll
            val xOffset = if (isVertical) 0 else absScroll
            val yOffset = if (isVertical) absScroll else 0
            placeable.placeRelativeWithLayer(xOffset, yOffset)
        }
    }

constraintsのmaxHeightがConstraints.Infinityに設定されてしまったせいで子の高さはUnboundedになるので親の影響を受けない。よって子であるText分の高さになる。

ComposeのLayoutやらConstraintについてはこちらが参考になる。

developer.android.com