はじめに

Hello, Swift Kotlin ラバーなみなさん。これからラバーのみなさん。

いつもは Swift 関連の記事がメインですが、なんと今回は Android アプリ開発、Jetpack Compose を使った記事です。

私、ほぼ Kotlin というか Android アプリ開発初心者でした。
ですが、最近のプロジェクトで Wear OS アプリを作成していて、その中でなかなか解決策が見つからないものがいくつかあるため、それを記事にしようぜプロジェクトです。

今回の記事は・・

表題の通り、「Jetpack Compose のLazyListで ScrollBar を実装する」です。

SwiftUI の ScrollView()であれば、showsIndicators: trueにするだけ(カスタムは面倒くさい)なんですが、Jetpack Compose の LazyRow() と LazyColumn() には、標準にスクロールバーの用意はありません。

それを作成していきたいと思います。

実装

バージョン

  • Android Studio: Android Studio Koala | 2024.1.1
  • Kotlin: 1.9.0
  • Jetpack Compose: 2024.04.01(compose-bom)

下準備

なにはともあれ、スクロールする画面を作成する必要があります。

今回は以下のような画面を作成しました。

おや?某 SNS に似ている?そんな馬鹿な

スクロールバー適用前のコードは以下のとおりです。

@Composable
fun ChatView(paddingValues: PaddingValues) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(127, 145, 186))
    ) {
        LazyColumn(
            modifier = Modifier
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            contentPadding = paddingValues,
        ) {
            items(messages, key = { it.id }) { item ->
                ChatMessageBubble(
                    type = if (item.type == ChatType.USER) BubbleType.USER else BubbleType.SYSTEM,
                    text = item.message,
                )
            }
        }
    }
}

本筋と関係のない箇所は省略していますが、最後にまとめて掲載します。

ScrollBar の実装

大本は StackOverFlow などで見た実装を参考に、(ChatGPT と一緒に)使いやすくアレンジしました。

ドンッ

@Composable
fun Modifier.verticalScrollbar(
    state: LazyListState,
    width: Float = 12f,
    barTint: Color = Color.Blue,
    backgroundColor: Color = Color.Gray,
): Modifier {
    return drawWithContent {
        drawContent()

        val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index

        firstVisibleElementIndex?.let {
            val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size
            val scrollbarHeight = this.size.height / scrollableItems
            val offsetY = ((this.size.height - scrollbarHeight) * it) / scrollableItems

            // スクロールバーの背景部分
            drawRoundRect(
                color = backgroundColor,
                topLeft = Offset(x = this.size.width - width, y = 0f),
                size = Size(width, this.size.height),
                cornerRadius = CornerRadius(width / 2, width / 2),
                alpha = 1f
            )

            // スクロールバーの本体部分
            drawRoundRect(
                color = barTint,
                topLeft = Offset(x = this.size.width - width, y = offsetY),
                size = Size(width, scrollbarHeight - 60f),
                cornerRadius = CornerRadius(width / 2, width / 2),
                alpha = 1f
            )
        }
    }
}

ざっくり解説していきます。

@Composable
fun Modifier.verticalScrollbar(
    // `rememberLazyListState()`の変数を渡してあげます。
    state: LazyListState,
    // ScrollBar の横幅
    width: Float = 12f,
    // ScrollBar 本体の色
    barTint: Color = Color.Blue,
    // ScrollBar の背
    backgroundColor: Color = Color.Gray,
): Modifier {}

次に Body

// 大本の content(今回は LazyColumn)と同時に描画できる
return drawWithContent {
    // 大本のcontent
    drawContent()

    // 表示されている最初のitemのindex
    val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index

    firstVisibleElementIndex?.let {

        val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size
        val scrollbarHeight = this.size.height / scrollableItems
        // スクロールバーの位置
        val offsetY = ((this.size.height - scrollbarHeight) * it) / scrollableItems

        // スクロールバーの背景部分
        // 角丸にする。角丸にしたくなければ`drawRect`を使ってもいい
        drawRoundRect(
            color = backgroundColor,
            topLeft = Offset(x = this.size.width - width, y = 0f),
            size = Size(width, this.size.height),
            // 角丸のサイズ、widthの半分のサイズにするときれいな円形になる
            cornerRadius = CornerRadius(width / 2, width / 2),
            alpha = 1f
        )

        // スクロールバーの本体部分
        drawRoundRect(
            color = barTint,
            topLeft = Offset(x = this.size.width - width, y = offsetY),
            // ここの`-60f`でバーの長さを微調整する
            size = Size(width, scrollbarHeight - 60f),
            cornerRadius = CornerRadius(width / 2, width / 2),
            alpha = 1f
        )
    }
}

これを利用側は、こんな感じになります。

@Composable
fun ChatView(paddingValues: PaddingValues) {
    // これでスクロールの位置とかを管理する
    val listState = rememberLazyListState() // 追加

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(127, 145, 186))
    ) {
        LazyColumn(
            // スクロールの位置とかを反映する
            state = listState, // 追加
            modifier = Modifier
                .fillMaxSize()
                // 追加
                // さっき作ったやつ
                .verticalScrollbar(
                    listState,
                    barTint = Color.Red,
                    backgroundColor = Color.White,
                ),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            contentPadding = paddingValues,
        ) {
            items(messages, key = { it.id }) { item ->
                ChatMessageBubble(
                    type = if (item.type == ChatType.USER) BubbleType.USER else BubbleType.SYSTEM,
                    text = item.message,
                )
            }
        }
    }
}

完成した Image です!

ちょっと実装は必要ですが、スクロールバーを実装することができました。

以上です!
というのも寂しいので、いくつかおまけを掲載します。

おまけ

その 1

今回は縦スクロールでしたが、横スクロール対応版も作成してみましょう。

基本的には、縦スクロールと変わらないですが、HeightWidth の扱いが違います。

@Composable
fun Modifier.horizontalLazyScrollbar(
    state: LazyListState,
    height: Float = 12f, // barのwidthからheightになる
    barTint: Color = Color.Blue,
    backgroundColor: Color = Color.Gray,
): Modifier {
    return drawWithContent {
        drawContent()

        val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index

        firstVisibleElementIndex?.let {

            val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size
            // ここらへんがheightからwidthの計算になる
            val scrollBarWidth = this.size.width / scrollableItems
            val offsetX = ((this.size.width - scrollBarWidth) * it) / scrollableItems

            // 以下も右横のスクロールバーから、下部の位置になる
            drawRoundRect(
                color = backgroundColor,
                topLeft = Offset(x = 0f, y = this.size.height - height),
                size = Size(this.size.width, height),
                cornerRadius = CornerRadius(width / 2, width / 2),
                alpha = 1f
            )

            drawRoundRect(
                color = barTint,
                topLeft = Offset(x = offsetX, y = this.size.height - height),
                size = Size(scrollBarWidth - 60f, height),
                cornerRadius = CornerRadius(width / 2, width / 2),
                alpha = 1f
            )
        }
    }
}

適用したコードは以下のような形です。

先程の縦スクロールのチャット画面の中に横スクロールの View を追加しました。

@Composable
fun ChatView(paddingValues: PaddingValues) {
    val listState = rememberLazyListState()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(127, 145, 186))
    ) {
        LazyColumn(
            state = listState,
            modifier = Modifier
                .fillMaxSize()
                .verticalScrollbar(
                    listState,
                    barTint = Color.Red,
                    backgroundColor = Color.White,
                ),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            contentPadding = paddingValues,
        ) {
            items(messages, key = { it.id }) { item ->

               // 追加
                if (item.content != null) {
                    ChatComponentBubble {
                        ImagesView()
                    }
                    return@items
                }

                ChatMessageBubble(
                    type = if (item.type == ChatType.USER) BubbleType.USER else BubbleType.SYSTEM,
                    text = item.message,
                )
            }
        }
    }
}

// 追加
@Composable
fun ImagesView() {
    val items: List<ImageItem> = listOf(<タイトルと画像URLのdata classを配列にしたやつ>)

    val listState = rememberLazyListState()

    Box(
        modifier = Modifier
            .horizontalLazyScrollbar(
                state = listState,
                height = 6f,
                backgroundColor = Color.Transparent,
                color = Color.Red,
            )
    ) {
        LazyRow(
            modifier = Modifier
                .wrapContentSize()
                .padding(bottom = 12.dp),
            state = listState,
            userScrollEnabled = true,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            items(items) { item ->
                // ただ画像とタイトルを表示するだけのやつ
                ImageView(item)
            }
        }
    }
}

完成イメージはこんな感じ

その 2

2 つの同じような関数を増やすのも無駄になるので、1 つにまとめてみました。

directionで Vertical と Horizontal を分けます。

もうちょい共通化できればいいなと思いましたが、似ているようで違うものなので、これが限界ですかね。

enum class ScrollDirection {
    Vertical, Horizontal
}

@Composable
fun Modifier.lazyScrollbar(
    state: LazyListState,
    direction: ScrollDirection,
    barLength: Float = 12f,
    barTint: Color = Color.LightGray,
    backgroundColor: Color = Color.Transparent,
): Modifier {
    return drawWithContent {
        drawContent()

        val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index
        val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size

        firstVisibleElementIndex?.let {

            when (direction) {
                ScrollDirection.Vertical -> {
                    val scrollbarHeight = this.size.height / scrollableItems
                    val offsetY = ((this.size.height - scrollbarHeight) * it) / scrollableItems

                    drawRoundRect(
                        color = backgroundColor,
                        topLeft = Offset(x = this.size.width - barLength, y = 0f),
                        size = Size(barLength, this.size.height),
                        cornerRadius = CornerRadius(width / 2, width / 2),
                        alpha = 1f
                    )

                    drawRoundRect(
                        color = barTint,
                        topLeft = Offset(x = this.size.width - barLength, y = offsetY),
                        size = Size(barLength, scrollbarHeight - 60f),
                        cornerRadius = CornerRadius(width / 2, width / 2),
                        alpha = 1f
                    )
                }

                ScrollDirection.Horizontal -> {
                    val scrollBarWidth = this.size.width / scrollableItems
                    val offsetX = ((this.size.width - scrollBarWidth) * it) / scrollableItems

                    drawRoundRect(
                        color = backgroundColor,
                        topLeft = Offset(x = 0f, y = this.size.height - barLength),
                        size = Size(this.size.width, barLength),
                        cornerRadius = CornerRadius(width / 2, width / 2),
                        alpha = 1f
                    )

                    drawRoundRect(
                        color = barTint,
                        topLeft = Offset(x = offsetX, y = this.size.height - barLength),
                        size = Size(scrollBarWidth - 60f, barLength),
                        cornerRadius = CornerRadius(width / 2, width / 2),
                        alpha = 1f
                    )
                }
            }
        }
    }
}

その 3

出し渋っていたソースコードを貼り付けます。

雑なコードが多いですが、あくまでサンプルなので(震え声)

チャット画面
enum class ChatType {
    USER, SYSTEM,
}

data class ChatItem(
    val id: UUID = UUID.randomUUID(),
    val type: ChatType,
    val message: String,
    val content: String? = null,
)

val messages: List<ChatItem> = listOf(
    ChatItem(type = ChatType.SYSTEM, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.USER, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.SYSTEM, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.USER, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.SYSTEM, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.USER, message = "ここはなんか適当にメッセージを入れてください"),
    ChatItem(type = ChatType.SYSTEM, message = "ここはなんか適当にメッセージを入れてください"),
)

@Composable
fun ChatView(paddingValues: PaddingValues) {
    val listState = rememberLazyListState()

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color(127, 145, 186))
    ) {
        LazyColumn(
            state = listState,
            modifier = Modifier
                .fillMaxSize()
                .lazyScrollbar(
                    state = listState,
                    direction = ScrollDirection.Vertical,
                    barTint = Color.Red,
                    backgroundColor = Color.White,
                ),
            verticalArrangement = Arrangement.spacedBy(10.dp),
            contentPadding = paddingValues,
        ) {
            items(messages, key = { it.id }) { item ->

                if (item.content != null) {
                    ChatComponentBubble {
                        ImagesView()
                    }
                    return@items
                }

                ChatMessageBubble(
                    type = if (item.type == ChatType.USER) BubbleType.USER else BubbleType.SYSTEM,
                    text = item.message,
                )
            }
        }
    }
}

data class ImageItem(
    val text: String,
    val imageUrl: String,
)

@Composable
fun ImagesView() {
    val items: List<ImageItem> = listOf(
        ImageItem("タイトル", "ここもなんか適当な画像URLでも入れてください"),
        ImageItem("タイトル", "ここもなんか適当な画像URLでも入れてください"),
        ImageItem("タイトル", "ここもなんか適当な画像URLでも入れてください"),
        ImageItem("タイトル", "ここもなんか適当な画像URLでも入れてください"),
        ImageItem("タイトル", "ここもなんか適当な画像URLでも入れてください"),
    )

    val listState = rememberLazyListState()

    Box(
        modifier = Modifier
            .lazyScrollbar(
                state = listState,
                direction = ScrollDirection.Horizontal,
                barTint = Color.Red,
                backgroundColor = Color.Transparent,
            ),
    ) {
        LazyRow(
            modifier = Modifier
                .wrapContentSize()
                .padding(bottom = 12.dp),
            state = listState,
            userScrollEnabled = true,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            items(items) { item ->
                ImageView(item)
            }
        }
    }
}

@Composable
fun ImageView(item: ImageItem) {
    Column {
        AsyncImage(
            model = item.imageUrl,
            contentDescription = item.text,
            modifier = Modifier
                .width(200.dp),
        )

        Text(
            text = item.text,
            color = Color.Black,
            fontSize = 20.sp,
            fontFamily = FontFamily.SansSerif,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(horizontal = 8.dp)
        )
    }
}

@Preview(
    showBackground = true,
    backgroundColor = 0xFFF,
    device = Devices.PIXEL_7,
)
@Composable
fun MyScreenPreview() {
    ChatView(PaddingValues(20.dp))
}
チャットの背景バブル
enum class BubbleType(val shape: Shape) {
    USER(
        RoundedCornerShape(
            topStart = 20.dp,
            topEnd = 20.dp,
            bottomEnd = 0.dp,
            bottomStart = 20.dp
        )
    ),
    SYSTEM(
        RoundedCornerShape(
            topStart = 20.dp,
            topEnd = 20.dp,
            bottomEnd = 20.dp,
            bottomStart = 0.dp
        )
    ),
}

@Composable
fun ChatMessageBubble(
    type: BubbleType,
    text: String,
    fontColor: Color? = null,
) {
    val isUser = type == BubbleType.USER
    val textColor = fontColor ?: if (isUser) Color.White else Color.Black

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentSize(if (isUser) Alignment.CenterEnd else Alignment.CenterStart),
    ) {
        Box(
            modifier = Modifier
                .clip(if (isUser) BubbleType.USER.shape else BubbleType.SYSTEM.shape)
                .background(if (isUser) Color(92,197,103) else Color.White)
                .padding(PaddingValues(horizontal = 20 .dp, vertical = 12.dp))
                .widthIn(max = 280.dp)
                .wrapContentSize(Alignment.Center),
        ) {
            Text(
                text = text,
                color = textColor,
                fontSize = 20.sp,
                fontFamily = FontFamily.SansSerif,
                fontWeight = FontWeight.Bold,
            )
        }
    }
}

@Composable
fun ChatComponentBubble(
    content: @Composable () -> Unit,
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .wrapContentSize(Alignment.CenterStart),
    ) {
        Box(
            modifier = Modifier
                .clip(BubbleType.SYSTEM.shape)
                .background(Color.White)
                .padding(PaddingValues(horizontal = 20 .dp, vertical = 12.dp))
                .widthIn(max = 280.dp)
                .wrapContentSize(Alignment.Center),
        ) {
            content()
        }
    }
}
スクロールバー
@Composable
fun Modifier.lazyScrollbar(
    state: LazyListState,
    direction: ScrollDirection,
    barLength: Float = 12f,
    barTint: Color = Color.LightGray,
    backgroundColor: Color = Color.Transparent,
): Modifier {
    return drawWithContent {
        drawContent()

        val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index
        val scrollableItems = state.layoutInfo.totalItemsCount - state.layoutInfo.visibleItemsInfo.size

        firstVisibleElementIndex?.let {

            when (direction) {
                ScrollDirection.Vertical -> {
                    val scrollbarHeight = this.size.height / scrollableItems
                    val offsetY = ((this.size.height - scrollbarHeight) * it) / scrollableItems

                    drawRoundRect(
                        color = backgroundColor,
                        topLeft = Offset(x = this.size.width - barLength, y = 0f),
                        size = Size(barLength, this.size.height),
                        cornerRadius = CornerRadius(6f, 6f),
                        alpha = 1f
                    )

                    drawRoundRect(
                        color = barTint,
                        topLeft = Offset(x = this.size.width - barLength, y = offsetY),
                        size = Size(barLength, scrollbarHeight - 60f),
                        cornerRadius = CornerRadius(6f, 6f),
                        alpha = 1f
                    )
                }

                ScrollDirection.Horizontal -> {
                    val scrollBarWidth = this.size.width / scrollableItems
                    val offsetX = ((this.size.width - scrollBarWidth) * it) / scrollableItems

                    drawRoundRect(
                        color = backgroundColor,
                        topLeft = Offset(x = 0f, y = this.size.height - barLength),
                        size = Size(this.size.width, barLength),
                        cornerRadius = CornerRadius(6f, 6f),
                        alpha = 1f
                    )

                    drawRoundRect(
                        color = barTint,
                        topLeft = Offset(x = offsetX, y = this.size.height - barLength),
                        size = Size(scrollBarWidth - 60f, barLength),
                        cornerRadius = CornerRadius(6f, 6f),
                        alpha = 1f
                    )
                }
            }
        }
    }
}

まとめ

Jetpack Compose は、不慣れですが慣れてくると楽しいものです!また他にも困った箇所はあったのでこのシリーズは続くかもしれません!

それでは、素敵な Swift Kotlin ライフを!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる