画像がスクロールするバナーを作ります。
Preview Code
Column { ImageBannerPagerWithIndicator( modifier = Modifier .fillMaxWidth() .heightIn(max = 200.dp), scrollMills = 2000, activeColor = androidx.compose.ui.graphics.Color.Yellow, inActiveColor = androidx.compose.ui.graphics.Color.LightGray, banners = immutableListOf( PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=1", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=2", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=3", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=4", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=5", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), ), ) Spacer(modifier = Modifier.height(12.dp).fillMaxWidth().background(androidx.compose.ui.graphics.Color.Black)) ImageBannerPagerWithIndicator( modifier = Modifier .fillMaxWidth() .heightIn(max = 200.dp), scrollMills = 2000, activeColor = androidx.compose.ui.graphics.Color.Yellow, inActiveColor = androidx.compose.ui.graphics.Color.LightGray, banners = immutableListOf( PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=1", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=2", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=3", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x200/ff0000/000000&text=4", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), PagerBannerUiState( imageUrl = "https://dummyimage.com/600x400/ff0000/000000&text=5", listener = object : PagerBannerUiState.Listener { override fun onClick() { } }, description = "", ), ), ) }
要件
- ネットワークから複数画像を取得してページャーで横スクロールする
- サイズ要件
- 1つの画像で1ページを使用する
- できるだけ横幅をいっぱいに表示する
- 但し、タブレット対応として高さ制限をかけられる
- 縦幅は一番高い画像に合わせる
- 自動でページが送られる
- 最後までスクロールされたら最初に戻る
- ユーザーがスクロールしたら自動スクロールを止める
- インジケータがある(おまけ)
実装
import
import
import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.forEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.size.Scale import coil.size.Size import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.rememberPagerState
必要なStateを定義
@OptIn(ExperimentalPagerApi::class) @Composable public fun rememberImageBannerPagerState( pagerState: PagerState = rememberPagerState(), ): ImageBannerPagerState { return rememberSaveable(pagerState, saver = ImageBannerPagerState.Saver) { ImageBannerPagerState( pagerState = pagerState, ) } } @Stable @OptIn(ExperimentalPagerApi::class) public data class ImageBannerPagerState( val pagerState: PagerState, ) { public var isAnyImageLoaded: Boolean by mutableStateOf(false) internal set public var isUserScrolled: Boolean by mutableStateOf(false) internal set public var height: Float by mutableStateOf(1f) internal set public companion object { public val Saver: Saver<ImageBannerPagerState, *> = listSaver( save = { listOf<Any>( it.pagerState.currentPage, it.isAnyImageLoaded, it.isUserScrolled, it.height, ) }, restore = { list -> ImageBannerPagerState( pagerState = PagerState(currentPage = list[0] as Int), ).also { it.isAnyImageLoaded = list[1] as Boolean it.isUserScrolled = list[2] as Boolean it.height = list[3] as Float } }, ) } }
UIの型定義
public data class PagerBannerUiState( val imageUrl: String, val description: String, val listener: Listener, ) { @Immutable public interface Listener { public fun onClick() } }
Pager部分
@OptIn(ExperimentalPagerApi::class) @Composable private fun ImageBannerPager( modifier: Modifier = Modifier, scrollDuration: Long, pagerState: ImageBannerPagerState = rememberImageBannerPagerState(), banners: List<PagerBannerUiState>, ) { val density = LocalDensity.current LaunchedEffect(Unit) { pagerState.isUserScrolled = false } LaunchedEffect(pagerState.isUserScrolled) { if (pagerState.isUserScrolled) return@LaunchedEffect while (isActive) { delay(scrollDuration) val nextPage = if (pagerState.pagerState.currentPage >= pagerState.pagerState.pageCount - 1) { 0 } else { pagerState.pagerState.currentPage + 1 } pagerState.pagerState.animateScrollToPage(nextPage) } } HorizontalPager( modifier = Modifier .fillMaxWidth() .heightIn(min = with(density) { pagerState.height.toDp() }) // ユーザーが触ったらスクロールを無効にする .pointerInput(Unit) { forEachGesture { awaitPointerEventScope { awaitPointerEvent() pagerState.isUserScrolled = true } } } .then(modifier), count = banners.size, state = pagerState.pagerState, ) { index -> BoxWithConstraints( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { val containerWidth = with(density) { maxWidth.toPx() } val containerHeight = with(density) { maxHeight.toPx() } val banner = banners[index] val painter = rememberAsyncImagePainter( model = ImageRequest.Builder(LocalContext.current) .data(banner.imageUrl) // 仮でサイズを指定しないとサイズが読めないので設定する .size(Size(containerWidth.toInt(), containerHeight.toInt())) .scale(Scale.FILL) .build(), ) var currentImageHeight by remember { mutableStateOf(0f) } var currentImageWidth by remember { mutableStateOf(0f) } LaunchedEffect(painter.state) { if (painter.state is AsyncImagePainter.State.Success) { pagerState.isAnyImageLoaded = true } // 表示サイズ計算ロジック if ( painter.intrinsicSize != androidx.compose.ui.geometry.Size.Unspecified && painter.intrinsicSize.height != 0f ) { val painterWidth = painter.intrinsicSize.width val painterHeight = painter.intrinsicSize.height val tmpHeight = painterHeight * (containerWidth / painterWidth) currentImageHeight = tmpHeight.coerceAtMost(containerHeight) currentImageWidth = currentImageHeight * (painterWidth / painterHeight) val result = currentImageHeight .coerceAtLeast(pagerState.height) pagerState.height = result } } // 読み込みが完了してから表示する if (painter.state is AsyncImagePainter.State.Success) { Image( modifier = Modifier .clickable { banner.listener.onClick() } .height(with(density) { currentImageHeight.toDp() }) .width(with(density) { currentImageWidth.toDp() }), painter = painter, contentDescription = banner.description, contentScale = ContentScale.FillHeight, ) } } } }
インジケータ(おまけ)
ImageBannerPagerState
は引数で入れるようにしているのでいくらでもなんとかなります
@OptIn(ExperimentalPagerApi::class) @Composable public fun ImageBannerPagerWithIndicator( modifier: Modifier, bannerState: ImageBannerPagerState = rememberImageBannerPagerState(), scrollMills: Long, activeColor: Color, inActiveColor: Color, banners: List<PagerBannerUiState>, ) { Box( modifier = modifier, ) { ImageBannerPager( scrollDuration = scrollMills, pagerState = bannerState, banners = banners, ) if (bannerState.isAnyImageLoaded && banners.size > 1) { Row( Modifier .align(Alignment.BottomCenter) .padding(bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { repeat(banners.size) { index -> val isSelected by remember { derivedStateOf { bannerState.pagerState.currentPage == index } } Box( Modifier .size(8.dp) .then( if (isSelected) { Modifier .background(activeColor, CircleShape) } else { Modifier .background(inActiveColor, CircleShape) }, ), ) if (index != banners.size - 1) { Spacer(modifier = Modifier.width(4.dp)) } } } } } }