たくさんの自由帳

Jetpack Compose で縦横ななめスクロール

投稿日 : | 0 日前

文字数(だいたい) : 2722

どうもこんばんわ。ネタ無いので記録として書いておきます、
健康診断行った結果が帰ってきました、ALTが再検査とか書いてあります。なんすかこれ

あとは、AC12になったせいなのかコメント欄がいっぱい書いてあります、
まあそもそもこんなくっそ暑い中、水すらも飲めない状態で行ったら不健康診断になるだろうがよ!!!!!!!!!!

本題

Jetpack Composeでできた、表みたいなUI縦横斜め方向にスクロールできるようにしてみます。
verticalScroll()horizontalScroll()を両方兼ね備えた的なやつ。

表

動画編集アプリのタイムラインとかで使えそうですね(使います!!

先に答え

説明は後、Modifier.verticalScroll()スクロールジェスチャー検出 + スクロールに合わせて描画をずらす機能があります。
が、が、が
今のところModifier.scrollable2D()にはスクロールジェスチャー検出機能しかありません、よって自前でずらす必要があります。

val offset = remember { mutableStateOf(Offset.Zero) }
val size = remember { mutableStateOf(IntSize.Zero) }
Box(
    modifier = Modifier // あとは fillMaxSize() するとか
        .clipToBounds()
        .scrollable2D(state = rememberScrollable2DState { delta ->
            // これをしないと見えないスクロール(スクロールしても UI がなかなか反映されない)が起きる
            val newX = (offset.value.x + delta.x).toInt().coerceIn(-size.value.width..0)
            val newY = (offset.value.y + delta.y).toInt().coerceIn(-size.value.height..0)
            offset.value = Offset(newX.toFloat(), newY.toFloat())
            // TODO 今回は面倒なのでネストスクロールを考慮していません。
            // TODO 本来は利用した分だけ return するべきです
            delta
        })
        .layout { measurable, constraints ->
            // ここを infinity にすると左端に寄ってくれる
            val childConstraints = constraints.copy(
                maxHeight = Constraints.Infinity,
                maxWidth = Constraints.Infinity,
            )
            // この辺は全部 Scroll.kt のパクリ
            val placeable = measurable.measure(childConstraints)
            val width = placeable.width.coerceAtMost(constraints.maxWidth)
            val height = placeable.height.coerceAtMost(constraints.maxHeight)
            val scrollHeight = placeable.height - height
            val scrollWidth = placeable.width - width
            size.value = IntSize(scrollWidth, scrollHeight)
            layout(width, height) {
                val scrollX = offset.value.x.toInt().coerceIn(-scrollWidth..0)
                val scrollY = offset.value.y.toInt().coerceIn(-scrollHeight..0)
                val xOffset = scrollX
                val yOffset = scrollY
                withMotionFrameOfReferencePlacement {
                    placeable.placeRelativeWithLayer(xOffset, yOffset)
                }
            }
        }
    ) {
    // ここに縦横にはみ出すレイアウト
}

2025.08.00

連続で体温超え、っていうかお熱。するとかどーなってんだよおい。暑すぎる。Google Pixelが毎日熱中症警戒アラート出してる。

JetpackComposeのこのバージョンから縦横斜め方向にスクロールした分を取得できるModifierが追加されました。
Modifier.scrollable2D()ですね。

Modifier.scrollable2D(rememberScrollable2DState { delta ->
    delta
})

deltaが移動した分で、あとは自分でスクロール分のオフセットを調整していくだけ。
・・・

どうやら斜め方向のジェスチャー登録までで、スクロール分だけオフセットを調整して表示する機能はないらしい。ええ

環境

一応書いておくか

端末Pixel 8 Pro
Android16 QPR 2
Jetpack Compose Bom2025.08.00
Android StudioAndroid Studio Narwhal 3 Feature Drop 2025.1.3

こんな感じに縦横に数字を敷き詰めるようにしてみました。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            JetpackComposeScrollable2DTheme {
                MainScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            for (i in 0..10) {
                Row {
                    for (j in 0..10) {
                        NumberSquare(number = i * 10 + j)
                    }
                }
            }
        }
    }
}

@Composable
private fun NumberSquare(
    modifier: Modifier = Modifier,
    number: Int
) {
    Box(
        modifier = modifier
            .border(1.dp, Color.Black)
            .size(100.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = number.toString(),
            fontSize = 20.sp
        )
    }
}

つくる

といってもModifier.verticalScroll()のコードをパクって、Modifier.scrollable2D()で動くように直しただけなのであんまり追求しないでください、

rememberScrollable2DState { }のコールバックで移動した分がもらえるので、offset.valueで累積していきます。
ここで勢いよくスクロールしたときに備えて、-size.value.width .. 0の範囲内でしか値が帰ってこないように制限します。Kotlin便利。
仮に勢いよくの制限がないと、いくらスクロールしても戻ってこれなくなります(範囲外に行ったので、範囲内に戻ってくるまで描画は無反応...)

rememberScrollable2DState { }の関数はちゃんとOffsetを返す必要がありますが、今回は別にネストスクロールしていないので諦めました。

layout { }Modifier.verticalScroll()の中を見てパクっただけなのよく分かっていません()。
Constraints.Infinityにするとはみ出させられるんだ~~くらいしか分かってません。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen() {

    val offset = remember { mutableStateOf(Offset.Zero) }
    val size = remember { mutableStateOf(IntSize.Zero) }

    Scaffold(
        topBar = { TopAppBar(title = { Text(text = stringResource(R.string.app_name)) }) }
    ) { paddingValues ->
        Column(
            modifier = Modifier

                .fillMaxSize()
                .padding(paddingValues)

                .clipToBounds()
                .scrollable2D(state = rememberScrollable2DState { delta ->
                    // これをしないと見えないスクロール(スクロールしても UI がなかなか反映されない)が起きる
                    val newX = (offset.value.x + delta.x).toInt().coerceIn(-size.value.width..0)
                    val newY = (offset.value.y + delta.y).toInt().coerceIn(-size.value.height..0)
                    offset.value = Offset(newX.toFloat(), newY.toFloat())
                    // TODO 今回は面倒なのでネストスクロールを考慮していません。
                    // TODO 本来は利用した分だけ return するべきです
                    delta
                })
                .layout { measurable, constraints ->
                    // ここを infinity にすると左端に寄ってくれる
                    val childConstraints = constraints.copy(
                        maxHeight = Constraints.Infinity,
                        maxWidth = Constraints.Infinity,
                    )
                    // この辺は全部 Scroll.kt のパクリ
                    val placeable = measurable.measure(childConstraints)
                    val width = placeable.width.coerceAtMost(constraints.maxWidth)
                    val height = placeable.height.coerceAtMost(constraints.maxHeight)
                    val scrollHeight = placeable.height - height
                    val scrollWidth = placeable.width - width
                    size.value = IntSize(scrollWidth, scrollHeight)
                    layout(width, height) {
                        val scrollX = offset.value.x.toInt().coerceIn(-scrollWidth..0)
                        val scrollY = offset.value.y.toInt().coerceIn(-scrollHeight..0)
                        val xOffset = scrollX
                        val yOffset = scrollY
                        withMotionFrameOfReferencePlacement {
                            placeable.placeRelativeWithLayer(xOffset, yOffset)
                        }
                    }
                }


        ) {
            for (i in 0..10) {
                Row {
                    for (j in 0..10) {
                        NumberSquare(number = i * 10 + j)
                    }
                }
            }
        }
    }
}

完成

ソースコード

どーぞ

以上です、おつ 888

おわりに

rememberScrollable2DState { delta -> }deltaをスクロールしたいコンポーネントに、
Modifier.offsetすればいいじゃんって思いましたが普通に動きませんでした。