はじめに
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
今回は縦スクロールでしたが、横スクロール対応版も作成してみましょう。
基本的には、縦スクロールと変わらないですが、Height と Width の扱いが違います。
@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 ライフを!!








