[Jetpack Compose] scrollStateに連動したScrollBarの実装

この記事はand factory.inc Advent Calendar 2023 4日目の記事です。
昨日は @ichikawa7ss さんの「既存プロジェクトへのデザインシステム導入の取り組み」でした。

## はじめに

Jetpack Composeで、Android ViewのようなScrollBarを表示する標準のComposeは用意されておらず、自分で実装する必要があります。この記事では、scrollStateに連動して表示されるScrollBarの実装を紹介します。

:::note warn
LazyListStateに対応したScrollBarも別途自分で作る必要がありますが、この記事では紹介しません。LazyListのほうが作るのは難しい印象です😅
:::

## 動き

![adbeem-20231203194316.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166629/f662a9c2-ca15-3191-5e7e-bd259078f2d6.gif)

## ScrollBar.kt

Boxに対して使えば自動で表示されるように作ってみたので、このままコピペで貼り付ければ使えます :hugging: 

```kotlin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay

private val scrollBarWidth = 4.dp

/**
 * ScrollStateに連動したスクロールバーを表示するComposable
 *
 * 親のBoxの右端に表示されます。高さはコンテンツ量に応じて可変します。
 * スクロールできるだけのコンテンツがない場合は表示されません。
 *
 * @param isAlwaysShowScrollBar trueの場合はスクロールバーを常に表示します。
 * falseの場合はスクロール中のみ表示します。
 */
@Composable
fun BoxScope.ScrollBar(
    modifier: Modifier = Modifier,
    scrollState: ScrollState,
    isAlwaysShowScrollBar: Boolean = false,
) {
    var isVisible by remember { mutableStateOf(isAlwaysShowScrollBar) }

    LaunchedEffect(isAlwaysShowScrollBar, scrollState.isScrollInProgress) {
        isVisible = if (isAlwaysShowScrollBar || scrollState.isScrollInProgress) {
            true
        } else {
            delay(800) // スクロールが止まってから800ms後に非表示にする
            false
        }
    }

    AnimatedVisibility(
        visible = isVisible,
        enter = fadeIn(),
        exit = fadeOut(),
    ) {
        Canvas(
            modifier = modifier
                .align(Alignment.CenterEnd)
                .fillMaxSize()
        ) {
            val totalScrollDistance = scrollState.maxValue.toFloat()
            val viewHeight = size.height
            val scrollRatio = scrollState.value.toFloat() / totalScrollDistance

            // スクロールバーの位置とサイズを計算
            val scrollbarHeight = viewHeight * (viewHeight / (totalScrollDistance + viewHeight))
            val scrollbarTopOffset = scrollRatio * (viewHeight - scrollbarHeight)

            drawRect(
                color = Color.Gray,
                topLeft = Offset(size.width - scrollBarWidth.toPx(), scrollbarTopOffset),
                size = Size(scrollBarWidth.toPx(), scrollbarHeight)
            )
        }
    }
}
```

## 使い方

BoxScopeに対して使えるようにしているので、ScrollBarを表示したいBoxの中に配置してください。あとは自動で右端に表示されるようになっています。注意点としては、親のBoxをscrollableにしているとうまく動作しないので、入れ子にしてください。

使い方はこんな感じです。

```kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ScrollBarComposeTheme {

                val scrollState = rememberScrollState()

                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center,
                    ) {
                        Box(
                            modifier = Modifier
                                .padding(horizontal = 16.dp)
                                .fillMaxWidth()
                                .height(250.dp)
                                .background(Color.LightGray),
                        ) {
                            Column(
                                modifier = Modifier
                                    .fillMaxSize()
                                    .padding(6.dp)
                                    .verticalScroll(scrollState),
                            ) {
                                Text(
                                    text = termConsentText,
                                    fontSize = 12.sp,
                                    color = Color.Black,
                                    lineHeight = 14.sp,
                                )
                            }

                            ScrollBar(
                                scrollState = scrollState,
                                isAlwaysShowScrollBar = false,
                            )
                        }
                    }
                }
            }
        }
    }
}

private val termConsentText = """
        Compose Preview機能 利用規約

        第1条（概要）
        本規約は、ComposeのPreview機能（以下、「本機能」という）の利用に関して適用される条件を定めるものです。本機能の利用者（以下、「ユーザー」という）は、本規約に同意した上で本機能を利用するものとします。

        第2条（利用資格）
        1. ユーザーは、本規約に同意し、かつ以下の条件を満たすものとします：
           - 18歳以上であること。
           - 法的に契約を締結する能力を有すること。

        2. ユーザーが未成年者の場合、保護者の同意を得て本機能を利用するものとします。

        第3条（利用目的）
        本機能は、ユーザーによるコンテンツ作成の支援を目的として提供されます。商業目的での利用は原則として禁止されていますが、特別な許可を得た場合はこの限りではありません。

        第4条（著作権）
        1. 本機能を通じてユーザーが作成したコンテンツの著作権は、原則としてユーザーに帰属します。
        2. ただし、本機能自体やそのコンテンツは、提供元の知的財産として保護されており、無断での複製、配布、改変は禁止されています。

        第5条（禁止事項）
        本機能を利用するにあたり、以下の行為は禁止されています：
        - 違法行為または違法行為を助長する行為。
        - 他人の名誉やプライバシーを侵害する行為。
        - 有害なコンテンツの生成や拡散。

        第6条（免責事項）
        1. 本機能の利用によって生じたいかなる損害についても、提供元は責任を負わないものとします。
        2. 本機能は、予告なく変更または中止されることがあります。これによるユーザーの損害に対して、提供元は責任を負わないものとします。

        第7条（規約の変更）
        提供元は、ユーザーに通知することなく本規約を変更することができます。規約が変更された場合、変更後の規約に従うものとします。
    """.trimIndent()

```

規約の文言はChatGPTに作ってもらいました。便利。

コンテンツ量に応じてスクロールバーのサイズが適切なサイズに変わります。例えばコンテンツ量が多ければスクロールバーのサイズも小さくなるし、ほとんどスクロールする必要がない場合はめちゃくちゃ大きくなります。いわゆる一般的なスクロールバーと同じような機能になってると思います。
また、スクロールできない場合は表示されません。

`isAlwaysShowScrollBar` はデフォルトでfalseですが、これはスクロールされるまでは非表示で、スクロールされるとCrossfadeで表示されるオプションです。trueにすると常に表示されます。

## 実装

ScrollBarの部分はCanvasを使って描画しています。コンテンツの高さに応じて位置とサイズが変わるように計算しています。
。。。この部分はChatGPTに作ってもらいました :sweat_smile: めちゃくちゃ助かりました。笑

ChatGPTくんが作ってくれたのはCanvasのシンプルな実装だったので、それにAnimationの表示の機能を追加しています。
scrollStateに `isScrollInProgress` があるのでこれを直接visibleに使ってもいいのですが、指を離した瞬間に消えてしまい違和感があります。
そこえLaunchedEffect内で800msのdelayをかける処理を追加することで、指を離してから800ms後に消えるようにしました。

また、自分でScrollBarを右端に配置したりするのも手間だったので、Boxの中に置いてもらえばよしなに表示できるようにしました。
BoxScopeに対してfillMaxWidthを取り、endの位置に配置しています。

## おわりに

ScrollBarは需要があると思うので、標準で入るといいのになぁと思いつつも、GPTが小難しい計算部分を書いてくれたのでいい感じに作ることができました。