见字如面 👋
本文将探讨如何在 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!