アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

【Compose】Pagerのためのイモムシインジゲータを作成する

以下のように、ページをスワイプするたびにイモムシみたいに動くインジゲータを作成します。

横幅は大きくなったり小さくなったり小さくなったりします。offsetは横幅が増加している時は動かず、小さくなっている時にだけ、小さくなっている分だけ進むことで、右端は動かずに小さくなります。

コード

import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import kotlin.math.floor

@Composable
public fun Indicator(
    modifier: Modifier = Modifier,
    count: Int,
    size: Dp = 8.dp,
    indicatorPadding: Dp = 4.dp,
    currentIndex: Float,
    activeColor: Color,
    inActiveColor: Color,
    easing: Easing = LinearEasing,
) {
    val reverseEasing = remember(easing) {
        Easing { 1 - easing.transform(1 - it) }
    }
    Box(modifier = modifier.width(IntrinsicSize.Min)) {
        Row(
            modifier = Modifier.fillMaxWidth(),
        ) {
            repeat(count) { index ->
                Box(
                    Modifier
                        .size(size)
                        .then(
                            Modifier
                                .background(inActiveColor, CircleShape),
                        ),
                )
                if (index != count - 1) {
                    Spacer(modifier = Modifier.width(indicatorPadding))
                }
            }
        }
        Canvas(
            modifier = Modifier.fillMaxWidth(),
            onDraw = {
                val a = 2

                val offsetXFactor: Float
                val widthFactor: Float
                val indexRemainder = currentIndex % 1
                if (indexRemainder < 0.5) {
                    widthFactor = easing.transform(a * indexRemainder)
                    offsetXFactor = floor(currentIndex)
                } else {
                    widthFactor = reverseEasing.transform(1 - (a * (indexRemainder - 0.5f)))
                    offsetXFactor = floor(currentIndex) + easing.transform((a * (indexRemainder - 0.5f)))
                }

                drawRoundRect(
                    color = activeColor,
                    topLeft = Offset(offsetXFactor * (size + indicatorPadding).toPx(), 0f),
                    size = Size((widthFactor * (size + indicatorPadding) + size).toPx(), size.toPx()),
                    cornerRadius = CornerRadius(size.toPx() / 2),
                )
            },
        )
    }
}

プレビュー

動作プレビューのコード

@OptIn(ExperimentalFoundationApi::class)
@Composable
@Preview(heightDp = 200, showBackground = true)
private fun Preview() {
    Column(modifier = Modifier.fillMaxSize()) {
        val state = rememberPagerState()
        val pageCount = 5
        HorizontalPager(
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth(),
            state = state,
            pageCount = pageCount,
        ) {
            Box(modifier = Modifier.fillMaxSize()) {
                Text(
                    modifier = Modifier.align(Alignment.Center),
                    text = "Page $it",
                )
            }
        }
        val currentIndex by remember {
            derivedStateOf {
                state.currentPage + state.currentPageOffsetFraction
            }
        }
        Column(
            modifier = Modifier.fillMaxWidth()
                .padding(4.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Indicator(
                count = pageCount,
                currentIndex = currentIndex,
                activeColor = Color.Red,
                inActiveColor = Color.Gray,
            )
            Spacer(modifier = Modifier.height(4.dp))
            Indicator(
                count = pageCount,
                currentIndex = currentIndex,
                activeColor = Color.Red,
                inActiveColor = Color.Gray,
                easing = FastOutSlowInEasing,
            )
            Spacer(modifier = Modifier.height(4.dp))
            Indicator(
                count = pageCount,
                currentIndex = currentIndex,
                activeColor = Color.Red,
                inActiveColor = Color.Gray,
                easing = CubicBezierEasing(0f, 1f, 0f, 1f),
            )
        }
    }
}

easing確認用のコード

@Composable
@Preview(showBackground = true, widthDp = 100, heightDp = 200)
private fun Preview2() {
    Column(modifier = Modifier.fillMaxSize()) {
        (0..20).onEach {
            Row {
                Indicator(
                    modifier = Modifier
                        .padding(2.dp),
                    count = 2,
                    currentIndex = it * 0.05f,
                    activeColor = Color.Red,
                    inActiveColor = Color.Gray,
                )
                Spacer(modifier = Modifier.width(8.dp))
                Indicator(
                    modifier = Modifier
                        .padding(2.dp),
                    count = 2,
                    currentIndex = it * 0.05f,
                    activeColor = Color.Red,
                    inActiveColor = Color.Gray,
                    easing = FastOutSlowInEasing,
                )
                Spacer(modifier = Modifier.width(8.dp))
                Indicator(
                    modifier = Modifier
                        .padding(2.dp),
                    count = 2,
                    currentIndex = it * 0.05f,
                    activeColor = Color.Red,
                    inActiveColor = Color.Gray,
                    easing = CubicBezierEasing(0f, 1f, 0f, 1f),
                )
            }
        }
    }
}