如何在 Jetpack Compose 中创建逐字动画

见字如面 👋

本文将探讨如何在 Jetpack Compose 中创建一个引人入胜的逐字动画 . 更棒的是, 它非常灵活! 我们还将将其适配为逐行版本.

放松心情, 让我们一起创造魔法 🪄

想法 💡

对于这个动画, 我们将利用 TextMeasurer. 这个强大的工具允许我们测量 Compose 文本并获取 TextLayoutResult, 而无需在屏幕上实际渲染任何内容.

TextLayoutResult中, 我们可以获取每个特定单词或行位置信息(Offset), 从而手动放置它们.

一旦我们获得了一组带有 offsets文本块 , 就可以触发动画, 并将它们 逐个 添加到一个 状态列表 中(带延迟).

最后, 对于状态列表中的每个项, 我们渲染一个 Text 可组合组件, 将其包裹在 AnimatedVisibility 盒子中, 并应用 Offset.

制作动画

为了使该解决方案可复用, 我们将引入一些抽象. 这样, 除了单词级效果外, 我们还可以轻松扩展到其他变体, 如行级, 而无需重写核心逻辑.

我们的第一步是定义一个数据模型 TextChunk, 它将存储一段文本(无论是单词还是行)及其对应的偏移量.

kotlin 复制代码
data class TextChunk(
    val text: String,
    val offset: Offset,
)

基础动画状态

接下来, 我们定义负责管理动画的抽象状态类:

kotlin 复制代码
abstract class ChunkedTextAnimationState(
    private val textMeasurer: TextMeasurer,
    val defaultTextStyle: TextStyle
) {

    var boxSize by mutableStateOf<IntSize>(IntSize.Zero)
        private set

    var textStyle by mutableStateOf<TextStyle>(defaultTextStyle)
        private set

    private var chunks = emptyList<TextChunk>()

    private val _chunksToDisplay = mutableStateListOf<TextChunk>()
    val chunksToDisplay: List<TextChunk> = _chunksToDisplay

    private var showTextJob: Job? = null

    suspend fun showText(delayBetweenChunksMillis: Long) = coroutineScope {
        dismissText()
        showTextJob = launch {
            for (chunk in chunks) {
                _chunksToDisplay.add(chunk)
                delay(delayBetweenChunksMillis)
            }
        }
    }

    fun loadText(
        text: String,
        style: TextStyle = this.defaultTextStyle,
        constraints: Constraints = Constraints()
    ) {
        clearState()
        val layoutResult = textMeasurer.measure(text, style, constraints = constraints)
        textStyle = style
        boxSize = layoutResult.size
        chunks = findChunks(text, layoutResult)
    }

    protected abstract fun findChunks(text: String, layoutResult: TextLayoutResult): List<TextChunk>

    fun clearState() {
        dismissText()
        boxSize = IntSize.Zero
        chunks = emptyList()
    }

    fun dismissText() {
        showTextJob?.cancel()
        showTextJob = null
        _chunksToDisplay.clear()
    }
}

该类包含一个抽象函数: findChunks(). 这里将定义文本如何被分解为可动画的片段.

需要注意的是, loadText() 函数是同步的. 建议在后台协程中调用它.

按单词实现

现在我们创建按单词的实现:

kotlin 复制代码
class WordByWordAnimationState(
    textMeasurer: TextMeasurer,
    defaultTextStyle: TextStyle
): ChunkedTextAnimationState(textMeasurer, defaultTextStyle) {

    override fun findChunks(text: String, layoutResult: TextLayoutResult): List<TextChunk> {
        val wordRegex = "\S+".toRegex()
        return wordRegex.findAll(text).map { matchResult ->
            val offset = layoutResult.getWordOffset(matchResult.range.start)
            val word = matchResult.value
            TextChunk(word, offset)
        }.toList()
    }

    private fun TextLayoutResult.getWordOffset(wordStart: Int): Offset {
        return this.getBoundingBox(wordStart).topLeft
    }
}

我们使用正则表达式("\S+")来查找每个由非空格 字符组成的片段. 然后使用TextLayoutResult获取其确切位置.

💎 扩展: 按行实现

此实现利用 TextLayoutResult 获取每行的边界和位置.

kotlin 复制代码
class LineByLineAnimationState(
    textMeasurer: TextMeasurer,
    defaultTextStyle: TextStyle
): ChunkedTextAnimationState(textMeasurer, defaultTextStyle) {

    override fun findChunks(text: String, layoutResult: TextLayoutResult): List<TextChunk> {
        val lines = mutableListOf<TextChunk>()

        for (line in 0 until layoutResult.lineCount) {
            val start = layoutResult.getLineStart(line)
            val end = layoutResult.getLineEnd(line)
            val lineText = text.substring(start, end)
            val offset = Offset(
                x = layoutResult.getLineLeft(line),
                y = layoutResult.getLineTop(line)
            )
            lines.add(TextChunk(lineText, offset))
        }

        return lines
    }
}

渲染字符串块

现在是时候在 Compose 中渲染这些动画字符串块了!

ini 复制代码
@Composable
fun ChunkedTextAnimation(
    state: ChunkedTextAnimationState,
    modifier: Modifier = Modifier,
    chunkAnimation: (index: Int) -> EnterTransition = { fadeIn(tween(300)) + scaleIn(tween(300)) },
) {
    val density = LocalDensity.current
    val boxSizeDp = remember(density, state.boxSize) {
        with(density) { DpSize(state.boxSize.width.toDp(), state.boxSize.height.toDp()) }
    }
    Box(
        modifier = modifier.size(boxSizeDp)
    ) {
        state.chunksToDisplay.forEachIndexed { index, (text, offset) ->
            val visibleState = remember { MutableTransitionState(false).apply { targetState = true } }
            AnimatedVisibility(
                visibleState = visibleState,
                enter = chunkAnimation(index),
                exit = ExitTransition.None,
                modifier = Modifier.offset { IntOffset(offset.x.toInt(), offset.y.toInt()) }
            ) {
                Text(
                    text = text,
                    style = state.textStyle
                )
            }
        }
    }
}

完成 🙌

你可以在 GitHub Gist 上查看完整代码及文档. 现在, 让我们来看看它的用法 👇🏻

示例

通过调整 EnterTransition, 我们可以创建许多有趣的效果! 让我们看看其中一些 ✨

以下示例将使用以下设置:

ini 复制代码
val textMeasurer = rememberTextMeasurer()
val screenWidthPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.roundToPx() }
val textStyle = LocalTextStyle.current.copy(
    fontSize = 20.sp,
    lineHeight = 30.sp,
    textAlign = TextAlign.Start
)

val wordByWordState = remember { WordByWordAnimationState(textMeasurer, textStyle) }
val lineByLineState = remember { LineByLineAnimationState(textMeasurer, textStyle) }

LaunchedEffect(Unit) {
    launch {
        wordByWordState.loadText(
            text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry that has been the standard ever since the 1500s.",
            constraints = Constraints(maxWidth = screenWidthPx)
        )
        delay(1000)
        wordByWordState.showText(/* Depends on a specific animation */)
    }
    launch {
        lineByLineState.loadText(
            text = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
            constraints = Constraints(maxWidth = screenWidthPx)
        )
        delay(1000)
        lineByLineState.showText(/* Depends on a specific animation */)
    }
}

// Specific Animation

简单淡入淡出

单词之间的延迟设置为 250ms

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(350, easing = LinearEasing))
    }
)

**输出: **

滑入

单词之间的延迟设置为 180ms

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(350, easing = LinearEasing)) + slideInHorizontally(tween(350))
    }
)

输出:

缩放进入

单词之间的延迟设置为 200ms

文本对齐设置为 Center

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(350)) + scaleIn(tween(350))
    }
)

输出:

滑动缩放

单词之间的延迟设置为 180ms

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(400, easing = LinearEasing)) + slideInHorizontally(tween(400)) + scaleIn(tween(400))
    }
)

输出:

跳跃缩放

单词之间的延迟设置为 180ms

文本对齐设置为 Center

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(350)) + scaleIn(animationSpec = keyframes {
            durationMillis = 350
            0f atFraction 0f
            1.2f atFraction 0.8f using FastOutLinearInEasing
            1f atFraction 1f using LinearOutSlowInEasing
        })
    }
)

输出:

垂直滑入

单词间延迟设置为 180ms

文本对齐设置为 Center

ini 复制代码
ChunkedTextAnimation(
    state = wordByWordState,
    chunkAnimation = {
        fadeIn(tween(350)) + slideInVertically(tween(350)) { it / 2 }
    }
)

输出:

垂直线条揭示

行间延迟设置为 180ms

ini 复制代码
ChunkedTextAnimation(
    state = lineByLineState,
    chunkAnimation = {
        fadeIn(tween(350)) + slideInVertically(tween(350))
    }
)

输出:

线条有序滑动

行间延迟设置为 200ms

scss 复制代码
ChunkedTextAnimation(
    state = lineByLineState,
    chunkAnimation = { index ->
        fadeIn(tween(500)) + slideInHorizontally(tween(500)) { width ->
            val fraction = 0.2f + (0.2f * index)
            (-width * fraction).toInt()
        }
    }
)

输出:

好了, 可能还有更多, 但现在我让你的创造力自由发挥 👀

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy coding! Stay GOLDEN!

相关推荐
游戏开发爱好者82 小时前
日常开发与测试的 App 测试方法、查看设备状态、实时日志、应用数据
android·ios·小程序·https·uni-app·iphone·webview
王码码20352 小时前
Flutter for OpenHarmony 实战之基础组件:第三十一篇 Chip 系列组件 — 灵活的标签化交互
android·flutter·交互·harmonyos
黑码哥2 小时前
ViewHolder设计模式深度剖析:iOS开发者掌握Android列表性能优化的实战指南
android·ios·性能优化·跨平台开发·viewholder
亓才孓2 小时前
[JDBC]元数据
android
独行soc3 小时前
2026年渗透测试面试题总结-17(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
金融RPA机器人丨实在智能3 小时前
Android Studio开发App项目进入AI深水区:实在智能Agent引领无代码交互革命
android·人工智能·ai·android studio
科技块儿3 小时前
利用IP查询在智慧城市交通信号系统中的应用探索
android·tcp/ip·智慧城市
独行soc3 小时前
2026年渗透测试面试题总结-18(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
王码码20354 小时前
Flutter for OpenHarmony 实战之基础组件:第二十七篇 BottomSheet — 动态底部弹窗与底部栏菜单
android·flutter·harmonyos
2501_915106324 小时前
app 上架过程,安装包准备、证书与描述文件管理、安装测试、上传
android·ios·小程序·https·uni-app·iphone·webview