作成日 2025/06/23
最終更新日 2025/06/23

ボタンでスクロール/触れるスクロールバー

背景

アプリを作っているときに、タップ以外の操作でスクロールを呼びたかった。要素数が多いときに頑張ってスクロールするのはめんどくさいので、一気にスクロールできるようにしてみたかった。

内容

  1. スクロールリストを作成
  2. ボタンでスクロール
  3. スクロールバーを表示
  4. スクロールバーをいじるとスクロール
  5. まとめ

スクロールリストを作成

LazyColumnを利用して作りました。

要素ごとにわかりやすいように枠線をつけています。

ここは別段説明することはないと思います。差分は こちら です。

ボタンでスクロール

本題その1。

ボタンでスクロールできるようにしていきます。

ボタンでスクロールするためにはボタンが必要なので配置してあげます。差分は こちら です。Composeでボタンを置いただけです。clickableにしているのは、触っていることがわかりやすいようにするためです。

で、ボタンにスクロール機能を付けていきます。差分は こちら です。

タップでも長押しでもスクロールできるようにしてあげます。

ModifierExt.kt
fun Modifier.isPressed(
    buttonState: ButtonState,
    update: (ButtonState) -> Unit,
): Modifier {
    return this.pointerInput(Unit) {
        awaitEachGesture {
            awaitPointerEvent()
            update(buttonState)

            // 押されている間はtrue
            while (
                awaitPointerEvent().changes.any {
                    it.pressed
                }
            ) {
               // なにもしない
            }

            // 放されたらfalse
            update(ButtonState.None)
        }
    }
}
            

ボタンの長押し検知用に作ったpointerInputです。

buttonStateがどのボタンが押しているかを指定し、updateで親で保持しているstateを更新します。

タップされたときに引数で指定されたボタンを押下状態にして、タップをやめたときにボタンの押下状態を解除します。

長押しを検知するModifierのcombinedClickableや 、PointerInput内で呼べるdetectTapGesturestがありますが [1] 、長押しになったことを検知するだけなので今回は使えません。

前に作ったのがpointerInputだったのでponiterInputを利用しましたが、pointerIntoropFilterというメソッドでも同様のことが出来そうです [2]

App.kt
@Composable
@Preview
fun App() {
    val scrollState = rememberLazyListState()

    var isButtonPressed by remember {
        mutableStateOf(ButtonState.None)
    }

    LaunchedEffect(isButtonPressed) {
        val delayTime = 100L
        var dif = 1
        while (true) {
            when (isButtonPressed) {
                ButtonState.Up -> scrollState.animateScrollToItem(
                    max(
                        scrollState.firstVisibleItemIndex - dif,
                        0,
                    ),
                )

                ButtonState.Down -> scrollState.animateScrollToItem(
                    scrollState.firstVisibleItemIndex + dif,
                )

                ButtonState.None -> return@LaunchedEffect
            }
            delay(delayTime)
            dif++
        }
    }


    ~省略~
    
} 
    

ボタンの状態が変わったときに非同期処理が呼ばれ、押されている間は上下にスクロールし、放された場合なにもしません。

difは移動量です。上に移動するときは、先頭のアイテム番号を小さくし、下に移動するときは先頭のアイテムを大きくします。itemIndexが0になると困るので0にならないようにします。大きい方は問題ないです。

スクロールバーを表示

実装を紹介している方がいたのでそのまま拝借します。

Columはこちら [3] 、LazyColumnはこちら [4] です。今回はLazyColumnなので後者を参考にしています。

(といっても、後者のLazyColumnの実装は、前者の記事を参考に作られていますが。)

ここはそのまま拝借しただけなので説明は省略します。

スクロールバーをいじるとスクロール

本題その2です。

当初はスクロールバーを触らないと動かせないようにしようとしていました。しかし、表示量が増えてバーが小さくなると触りにくくなるという本末転倒が起こったので諦めました。

なのでスクロールバー領域を触ったらそこを表示することを目標に実装します。

とりあえず今の状態だとタップの認識を使いにくいのでリファクタしてあげます。差分は こちら です。

canvasが画面全体でタップをキャッチすると困るので、スクロールバーの幅の分だけの領域を確保。canvasの中で定義していた各パラメータを外部に移動、合わせてviewHeightとreturn の内容を変更した形です。

続いて、表示の条件を修正します。差分は こちら です。

ScrollBar.kt
val updateVisibility: suspend () -> Unit = {
    isVisible = if (isAlwaysShowScrollBar || listState.isScrollInProgress || isPressed) {
        true
    } else {
        // 操作をやめてから800ms後に非表示にする
        delay(800)
        // 常に表示 or スクロール中 or tap中 は表示
        isAlwaysShowScrollBar || listState.isScrollInProgress || isPressed
    }
}

表示状態を変更する関数を作り、もともとの表示条件に加えスクロールバーをタップ中は表示することにしました。

タップ中の判定は先ほど長押し判定と同様にpointerInputを利用しました。

falseの場合、delayから再度orをっています。これはスクロール後にタップしたり、タップ後にスクロールした際に消えられると困るからです。

最後にスクロール処理です。差分は こちら です。

ScrollBar.kt
 // tap位置がバーの真ん中になるようにする
val tap = eventList.last().position.y -scrollbarHeight/2

// タップ位置と描画領域の比率から表示アイテムを決定
val target = (tap / viewHeight * listState.layoutInfo.totalItemsCount).toInt()

scope.launch {
    listState.scrollToItem(
        max(
            target,
            0
        )
    )
}                 

pointerInput部分の抜粋です。

awaitPointerEventでタップを取得。タップ位置をviewの高さと比較して割合を出し、その割合の位置にあるアイテムにスクロールします。やはり負になると困るので制御を入れます。

lazyColumnで全体ビューの高さを取得できないのでアイテムの数でスクロールするようにしています。試してませんが、高さが違うアイテムが入っているとちょっと困りそうです。

animateScrollToItemにするとスクロールが終わってから再度スクロールするので移動がカクついてしまったので不採用です。

スクロールバー変更全体の差分は こちら です。関数本体は以下の通りです。

                触れるスクロールバー
    private val scrollBarWidth = 10.dp

@Composable
fun BoxScope.ScrollBar(
    modifier: Modifier = Modifier,
    listState: LazyListState,
    isAlwaysShowScrollBar: Boolean = false,
) {
    var isVisible by remember { mutableStateOf(isAlwaysShowScrollBar) }
    var isPressed by remember {
        mutableStateOf(false)
    }

    val scope = rememberCoroutineScope()

    val updateVisibility: suspend () -> Unit = {
        isVisible = if (isAlwaysShowScrollBar || listState.isScrollInProgress || isPressed) {
            true
        } else {
            // 操作をやめてから800ms後に非表示にする
            delay(800)
            // 常に表示 or スクロール中 or tap中 は表示
            isAlwaysShowScrollBar || listState.isScrollInProgress || isPressed
        }
    }

    LaunchedEffect(isAlwaysShowScrollBar, listState.isScrollInProgress) {
        updateVisibility.invoke()
    }

    var viewHeight by remember {
        mutableStateOf(0)
    }

    val totalCount = listState.layoutInfo.totalItemsCount
    if (totalCount == 0) return

    val firstVisibleItemIndex = listState.firstVisibleItemIndex
    val firstVisibleItemScrollOffset = listState.firstVisibleItemScrollOffset
    val visibleItemCount = listState.layoutInfo.visibleItemsInfo.size

    val scrollRatio = firstVisibleItemIndex.toFloat() / totalCount

    // スクロールバーの位置とサイズを計算
    val scrollbarHeight = viewHeight * (visibleItemCount.toFloat() / totalCount)
    val scrollbarTopY1 = scrollRatio * viewHeight

    // 次のアイテムの位置とサイズを計算
    val scrollRatio2 = (firstVisibleItemIndex + 1).toFloat() / totalCount
    val scrollbarTopY2 = scrollRatio2 * viewHeight

    // 表示中の先頭アイテムの高さ
    val firstVisibleItemHeight = listState.layoutInfo.visibleItemsInfo.getOrNull(0)?.size

    // スクロールバー位置の微調整(スクロール量をスクロールバーのoffsetに変換する。offsetの範囲はこのアイテムと次のアイテムのスクロールバーの位置)
    val scrollbarTopOffset = if (firstVisibleItemHeight == null || firstVisibleItemHeight == 0) {
        // 先頭アイテムの高さが不明なので微調整なし
        0f
    } else {
        firstVisibleItemScrollOffset.toFloat() / firstVisibleItemHeight * (scrollbarTopY2 - scrollbarTopY1)
    }

    AnimatedVisibility(
        visible = isVisible,
        enter = fadeIn(),
        exit = fadeOut(),
    ) {
        Row(
            modifier = modifier
                .onGloballyPositioned {
                    viewHeight = it.size.height
                },
            horizontalArrangement = Arrangement.End
        ) {
            Canvas(
                modifier = Modifier
                    .fillMaxHeight()
                    .width(scrollBarWidth)
                    .pointerInput(Unit) {
                        awaitEachGesture {
                            while (true) {
                                val eventList = awaitPointerEvent().changes

                                isPressed = true

                                if (eventList.any { it.pressed }.not()) {
                                    break
                                }

                                // tap位置がバーの真ん中になるようにする
                                val tap = eventList.last().position.y -scrollbarHeight/2

                                // タップ位置と描画領域の比率から表示アイテムを決定
                                val target = (tap / viewHeight * listState.layoutInfo.totalItemsCount).toInt()

                                scope.launch {
                                    listState.scrollToItem(
                                        max(
                                            target,
                                            0
                                        )
                                    )
                                }
                            }

                            scope.launch {
                                // tap終了
                                isPressed = false

                                //表示切り替え
                                updateVisibility.invoke()
                            }
                        }
                    }
            ) {
                drawRect(
                    color = Color.Gray,
                    topLeft = Offset(
                        size.width - scrollBarWidth.toPx(),
                        scrollbarTopY1 + scrollbarTopOffset
                    ),
                    size = Size(scrollBarWidth.toPx(), scrollbarHeight)
                )
            }
        }
    }
}

    

まとめ

以上、スクロールを外部からいじる方法について調べ、スクロールバーの拡張を行いました。

今後の課題は、gester周りについて詳しくなることですかね。

ではまた。

参考

  1. 『タップして押す』 Developers (2025/06/23)
    https://developer.android.com/develop/ui/compose/touch-input/pointer-input/tap-and-press?hl=ja
  2. 『Jetpack Composeで長押し中インクリメントするボタンを作る』 Zenn (2025/06/23)
    https://zenn.dev/tada_k/articles/352aea81d6d74d
  3. 『[Jetpack Compose] scrollStateに連動したScrollBarの実装』 Qiita (2025/06/23)
    https://qiita.com/yasukotelin/items/fcf5b538fac922cb08a5
  4. 『[Jetpack Compose] LazyListStateに連動したScrollBarの実装』 Qiita (2025/06/23)
    https://qiita.com/takke/items/e717a2aae56691d1af08