アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Compose】ネットワークから取得したサイズ不定の画像でバナーを作成する

画像がスクロールするバナーを作ります。

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))
                    }
                }
            }
        }
    }
}