🫠

`Layout()`でサイズを強制したTextがはみ出る問題を対処する

2024/11/25に公開

以前作成した自動スクロールするtextでは以下のように配置すると表示範囲外でもtextが表示されてしまう問題があった。
以前の記事↓
https://zenn.dev/matuyuhi/articles/403eb513881176

擬似的な再現のPreview

@Preview
@Composable
private fun AutoScrollTextPreview() {
    Box(
        modifier = Modifier
            .width(300.dp)
            .background(Color.Gray),
        contentAlignment = Alignment.Center
    ) {
        Column(
            modifier = Modifier
                .background(Color.White)
                .width(100.dp)
        ) {
            AutoScrollText(
                text = "abcdefghijklmnopqrstuvwxyz",
                autoScrollType = AutoScrollType.Duration(3000, 500),
                startVisualize = true,
                modifier = Modifier
            )
            AutoScrollText(
                text = "abcdefghijklmnopqrstuvwxyz",
                autoScrollType = AutoScrollType.Duration(3000),
                startVisualize = false
            )
            AutoScrollText(
                text = "abcdefghijklmnopqrstuvwxyz",
                autoScrollType = AutoScrollType.PerCharacterLength(20),
                startVisualize = true
            )
            AutoScrollText(
                text = "abcdefghijklmnopqrstuvwxyz",
                autoScrollType = AutoScrollType.PerCharacterLength(10),
                startVisualize = false
            )
        }
    }
}


grayの部分がcomposable外の想定で、whiteが親Layoutの表示範囲。
これでは他のcomposableと組み合わせて使う時に不便


これをtextの表示されている範囲のインデックスを計算して、それ以外はColor.Transparentで上書きする。

@Composable
fun AutoScrollText(
    modifier: Modifier = Modifier,
    text: String,
    style: TextStyle = LocalTextStyle.current,
    autoScrollType: AutoScrollType = AutoScrollType.Duration(5000),
    startVisualize: Boolean = true,
) {

    val textWidth = remember { mutableIntStateOf(0) }
    val layoutWidth = remember { mutableIntStateOf(0) }

    val transitionWidth =
        if (startVisualize) textWidth.intValue - layoutWidth.intValue
        else textWidth.intValue

    val animatedOffsetX = remember { Animatable(0f) }

    val visibleCharRange = remember(text) { mutableStateOf(text.indices) }

    LaunchedEffect(transitionWidth) {
        while (
            // layoutサイズに収まっていれば何もしない
            transitionWidth > 0
        ) {
            animatedOffsetX.snapTo(if (startVisualize)  0f else layoutWidth.intValue.toFloat())
            delay(autoScrollType.delayMillis)
            animatedOffsetX.animateTo(
                targetValue = -transitionWidth.toFloat(),
                animationSpec = tween(
                    durationMillis = when (autoScrollType) {
                        is AutoScrollType.Duration -> autoScrollType.millis
                        is AutoScrollType.PerCharacterLength -> transitionWidth * autoScrollType.millis
                    }.toInt(),
                    easing = LinearEasing,
                )
            )
            delay(autoScrollType.delayMillis)
        }
    }

    LaunchedEffect(Unit) {
        // 1文字あたりの幅
        val charWidth = textWidth.intValue / text.length.toFloat()
        snapshotFlow {
            animatedOffsetX.value
        }.collect {
            // 現在のアニメーションオフセットに基づく開始インデックスと終了インデックスを計算
            val startIndex = ceil(-animatedOffsetX.value / charWidth).toInt()
            val visibleSize = floor(layoutWidth.intValue / charWidth).toInt()
            val endIndex = minOf(startIndex + visibleSize, text.length)

            // 表示中の文字範囲を更新
            visibleCharRange.value = startIndex until endIndex
        }
    }

    Layout(
        modifier = modifier,
        content = {
            Text(
                text = buildAnnotatedString {
                    text.forEachIndexed { index, c ->
                        if (visibleCharRange.value.contains(index)) {
                            append(c)
                        } else {
                            // 表示範囲でなければ透過色にする
                            withStyle(style = SpanStyle(color = Color.Transparent)) {
                                append(c)
                            }
                        }
                    }
                }
                ,
                style = style,
                maxLines = 1,
                overflow = TextOverflow.Clip,
            )
        },
        measurePolicy = { measurable, constraints ->
            val textMeasurable = measurable.first()

            layoutWidth.intValue = constraints.maxWidth

            val placeable = textMeasurable.measure(
                // layoutのサイズに関わらず全てを表示する
                constraints.copy(maxWidth = Int.MAX_VALUE)
            )
            // textのサイズを取得する
            textWidth.intValue = placeable.width

            layout(layoutWidth.intValue, placeable.height) {
                placeable.place(animatedOffsetX.value.toInt(), 0)
            }
        }
    )
}

結果↓

いい感じに非表示?に出来るようになる

GitHubで編集を提案

Discussion