アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Jetpack Compose】横幅に応じてLayoutをScaleさせる

Usecase

デザイン上、1行で表示させたいが、デバイスによっては横幅に収まらない。しかし改行をさせたくないものがあったので作りました。
上の様な感じではなく、下のように表示したい場合に使用します。
両方とも文字サイズ30spを使用し、表示できるデバイスでは30sp、横幅的に表示できないデバイスは表示できるまで縮小します。
f:id:matsudamper:20210816010937p:plain

使用例

これの右側の金額のテキストは動的に変えたいので、テキストになっています。
f:id:matsudamper:20220225234807p:plain

バイスが小さくて、文字を大きくしている場合も、スケールされて改行無しで表示されています。
f:id:matsudamper:20220225234907p:plain

Code

@Composable
public fun AutoScaling(
    modifier: Modifier,
    onlyScaleDown: Boolean = true,
    content: @Composable () -> Unit
) {
    val scaleState = remember { mutableStateOf(1.0f) }

    Box(
        modifier = modifier
            .clipToBounds()
    ) {
        Layout(
            modifier = Modifier
                .graphicsLayer {
                    scaleX = scaleState.value
                    scaleY = scaleState.value
                },
            content = {
                Box {
                    content()
                }
            }
        ) { measurables, constraints ->
            val containerWidth = constraints.maxWidth
            val measureResults = measurables.map {
                it.measure(constraints.copy(maxWidth = Int.MAX_VALUE))
            }

            val contentWidth = measureResults.sumOf { it.width }
            val contentHeight = measureResults.sumOf { it.height }

            val scale = (containerWidth.toFloat() / contentWidth).let { scale ->
                if (onlyScaleDown) {
                    scale.coerceAtMost(1f)
                } else {
                    scale
                }
            }
            scaleState.value = scale

            layout(floor(contentWidth * scale).toInt(), floor(contentHeight * scale).toInt()) {
                measureResults.onEach {
                    it.place(
                        -ceil((contentWidth - (contentWidth * scale)) / 2).toInt(),
                        -ceil((contentHeight - (contentHeight * scale)) / 2).toInt()
                    )
                }
            }
        }
    }
}

Usage

Preview Code

@Composable
public fun CustomAutoScalingView(
    textSize: TextUnit,
    onlyScaleDown: Boolean,
) {
    Text("onlyScaleDown=$onlyScaleDown, $textSize")
    AutoScaling(
        modifier = Modifier
            .background(Color.Green)
            .fillMaxWidth(),
        onlyScaleDown = onlyScaleDown,
    ) {
        CustomView(textSize)
    }
}

@Composable
public fun CustomView(
    textSize: TextUnit,
) {
    Row(modifier = Modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier.background(Color.Yellow),
            text = "1 2 3 4 5 6 7 8 9",
            fontSize = textSize
        )

        Icon(
            modifier = Modifier.fillMaxHeight().aspectRatio(1f),
            imageVector = Icons.Filled.Menu,
            contentDescription = null,
        )

        Text(
            modifier = Modifier.background(Color.Yellow),
            text = "1 2 3 4 5 6 7 8 9",
            fontSize = textSize
        )
    }
}

Preview

f:id:matsudamper:20210816011919p:plain

Column(
    modifier = Modifier
        .fillMaxWidth()
        .wrapContentHeight()
) {
    CustomView(14.sp)

    CustomAutoScalingView(
        textSize = 14.sp,
        onlyScaleDown = true,
    )

    CustomAutoScalingView(
        textSize = 14.sp,
        onlyScaleDown = false,
    )

    Spacer(modifier = Modifier.fillMaxWidth().height(16.dp).background(Color.Magenta))

    CustomView(30.sp)

    CustomAutoScalingView(
        textSize = 30.sp,
        onlyScaleDown = true,
    )

    CustomAutoScalingView(
        textSize = 30.sp,
        onlyScaleDown = false,
    )
}

Description

clipToBoundsPreviewがうまく行かないので、他のPreviewに影響が出ないように追加しました。実機では無くても問題ないです。

Box(
    modifier = modifier
        .clipToBounds()
) { /* Layout  */ }

f:id:matsudamper:20210816011257p:plain

Layout

measurePolicyを実行した後にサイズが確定するので、それを元にしてscaleします。

Layout(
    modifier = Modifier
        .graphicsLayer {
            scaleX = scaleState.value
            scaleY = scaleState.value
        },
    content = {
        Box {
            content()
        }
    }
) { measurables, constraints -> /* measurePolicy  */ }

measurePolicy

maxWidth = Int.MAX_VALUE で横幅の制限を無くした場合のサイズを計算します。

val containerWidth = constraints.maxWidth
val measureResults = measurables.map {
    it.measure(constraints.copy(maxWidth = Int.MAX_VALUE))
}

計算結果を取得します。 content はBoxしか無いので、sumOfじゃなくてfirstでもいいのですが、動作に違いは無いです。

val contentWidth = measureResults.sumOf { it.width }
val contentHeight = measureResults.sumOf { it.height }

Scaleを計算します。縮小にしか使用しないつもりですが、拡大も対応するために onlyScaleDown を追加しました。
そしてMutableStateにScaleを入れます。

val scale = (containerWidth.toFloat() / contentWidth).let { scale ->
    if (onlyScaleDown) {
        scale.coerceAtMost(1f)
    } else {
        scale
    }
}
scaleState.value = scale

スケール後のサイズを通知します

layout(floor(contentWidth * scale).toInt(), floor(contentHeight * scale).toInt()) {
    /* place */
}

place

以下のコードで place(0, 0) だと以下のようにレイアウトされます。
元のサイズの時のスケールされたサイズで中央に設置されます。
f:id:matsudamper:20210816013404p:plain

なので拡大縮小した差分の半分ずらします。

measureResults.onEach {
    it.place(
        -ceil((contentWidth - (contentWidth * scale)) / 2).toInt(),
        -ceil((contentHeight - (contentHeight * scale)) / 2).toInt()
    )
}

問題点

スケールしている為、若干ずれます。背景色を重ねる場合は注意です。
f:id:matsudamper:20210816021614p:plain