アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

Pagerのスクロールに合わせてバーが移動可能なTabLayoutを作る。

以下の記事に更に下のバーを付けたバージョン。
https://matsudamper.hatenablog.com/entry/2022/07/27/212056

Pagerとかのスクロールに合わせてタブのバーを移動させます。

imports

import androidx.annotation.FloatRange
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.SnapSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
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.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.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp

Stateです。Indexとfractionを指定してバーを移動させます。
左からのoffsetを計算させてレイアウトに提供します。

@Composable
public fun rememberTabLayoutState(): TabLayoutState {
    return rememberSaveable(
        inputs = arrayOf(),
        saver = listSaver(
            save = { listOf(it.animatableOffset.value) },
            restore = {
                TabLayoutState(
                    initialValue = it[0],
                )
            },
        ),
    ) {
        TabLayoutState(
            initialValue = 0f,
        )
    }
}

@Stable
public class TabLayoutState(
    initialValue: Float,
) {

    internal val animatableOffset = Animatable(
        initialValue = initialValue,
        typeConverter = Float.VectorConverter,
    )
    internal val offset: Float by animatableOffset.asState()

    public suspend fun snapTo(
        selectedIndex: Int,
        @FloatRange(from = 0.0, to = 1.0) fraction: Float = 0.0f,
    ) {
        if (animatableOffset.isRunning) return
        animatableOffset.animateTo(
            targetValue = selectedIndex + fraction,
            animationSpec = SnapSpec(0),
        )
    }

    public suspend fun animateTo(
        selectedIndex: Int,
        @FloatRange(from = 0.0, to = 1.0) fraction: Float = 0.0f,
    ) {
        animatableOffset.animateTo(
            targetValue = selectedIndex + fraction,
            animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
        )
    }
}

実装です。

@Composable
internal fun TabLayout(
    modifier: Modifier,
    size: Int,
    backgroundColor: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(backgroundColor),
    state: TabLayoutState = rememberTabLayoutState(),
    indicator: @Composable () -> Unit = {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(3.dp)
                .background(contentColor),
        )
    },
    content: @Composable (index: Int) -> Unit,
) {
    var enableScroll by rememberSaveable {
        mutableStateOf(false)
    }
    Surface(
        modifier = modifier,
        color = backgroundColor,
        contentColor = contentColor,
    ) {
        BoxWithConstraints(
            modifier = Modifier.fillMaxWidth(),
        ) {
            val containerWidthPx = with(LocalDensity.current) { maxWidth.toPx() }

            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .horizontalScroll(state = rememberScrollState(), enabled = enableScroll),
                contentAlignment = Alignment.BottomStart,
            ) {
                var indicatorWidth by rememberSaveable { mutableStateOf(0) }
                Layout(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(IntrinsicSize.Min),
                    content = {
                        (0 until size).forEach { index ->
                            content(index)
                        }
                    },
                    measurePolicy = object : MeasurePolicy {
                        override fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult {
                            val contentMaxOfMaxIntrinsicWidth = measurables.map { it.maxIntrinsicWidth(constraints.maxHeight) }
                                .maxOfOrNull { it } ?: return layout(0, 0) {}

                            val contentWidth: Int
                            if (contentMaxOfMaxIntrinsicWidth * measurables.size > containerWidthPx) {
                                enableScroll = true
                                contentWidth = contentMaxOfMaxIntrinsicWidth
                            } else {
                                enableScroll = false
                                contentWidth = (containerWidthPx / measurables.size).toInt()
                            }
                            indicatorWidth = contentWidth

                            val contentMaxIntrinsicHeight = measurables.map { it.maxIntrinsicHeight(contentWidth) }
                                .maxOfOrNull { it } ?: return layout(0, 0) {}

                            val placeables = measurables.map {
                                it.measure(Constraints.fixed(width = contentWidth, height = contentMaxIntrinsicHeight))
                            }
                            return layout(contentWidth * measurables.size, contentMaxIntrinsicHeight) {
                                placeables.forEachIndexed { index, placeable ->
                                    placeable.place(x = contentWidth * index, y = 0)
                                }
                            }
                        }
                    },
                )

                val value by remember {
                    derivedStateOf {
                        (indicatorWidth * state.offset).toInt()
                    }
                }

                Box(
                    modifier = Modifier
                        .width(
                            with(LocalDensity.current) {
                                indicatorWidth.toDp()
                            },
                        )
                        .offset {
                            IntOffset(x = value, y = 0)
                        },
                ) {
                    indicator()
                }
            }
        }
    }
}