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

触れるスクロールバー

背景

要素数が多いときに頑張ってスクロールするのはめんどくさいので、一気にスクロールできるようにしてみたかった。

内容

  1. スクロールバーを表示
  2. スクロールバーをいじるとスクロール
  3. まとめ

スクロールバーを表示

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

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

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

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

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

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

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

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

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. 『[Jetpack Compose] scrollStateに連動したScrollBarの実装』 Qiita (2025/06/23)
    https://qiita.com/yasukotelin/items/fcf5b538fac922cb08a5
  2. 『[Jetpack Compose] LazyListStateに連動したScrollBarの実装』 Qiita (2025/06/23)
    https://qiita.com/takke/items/e717a2aae56691d1af08