You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose整词渐变画笔+单字母独立动效实现问询

在Jetpack Compose中实现整词渐变+单字母独立动画的方案

我想在Jetpack Compose中实现一种文字效果:给整个单词应用对角平滑过渡的渐变画笔,同时让每个字母独立进行上下移动和小角度旋转动画。

遇到的问题

  • 问题1:给整个单词应用渐变画笔时,无法实现单个字母的移动和旋转动画,尝试代码如下:
Text(
    text = word,
    fontSize = fontSize,
    style = LocalTextStyle.current.copy(brush = gradientBrush),
    modifier = modifier
)
  • 问题2:给每个字母单独添加动画时,能实现预期的动画效果,但渐变画笔会被应用到单个字母上,效果不符合预期(需要画笔作用于整个单词),尝试代码如下:
Row {
    word.forEachIndexed { index, char ->
        val angle by rotationAngles[index]
        val offset by letterOffsets[index]
        Text(
            text = char.toString(),
            fontSize = fontSize,
            style = LocalTextStyle.current.copy(brush = gradientBrush),
            modifier = Modifier
                .rotate(angle)
                .offset(y = offset.dp)
        )
    }
}

两种效果的矛盾:

  1. 整词应用画笔的效果符合预期,但无法实现字母动画;
  2. 字母动画效果符合预期,但画笔仅作用于单个字母而非整词。

核心问题:如何在Jetpack Compose中实现单个字母独立上下移动、小角度旋转动画,同时为整个单词应用渐变画笔?

尝试的完整代码

@Preview(showBackground = true)
@Composable
fun RotatingLettersPreview() {
    RotatingLetters("Medium")
}

@OptIn(ExperimentalTextApi::class)
@Composable
fun RotatingLetters(word: String,
                    fontSize: TextUnit = TextUnit.Unspecified,
                    modifier: Modifier = Modifier) {

    val colorGradient = listOf(
        Color(0xFFC0EFFF), // Light blue
        Color(0xFF9BDBFB), // Medium blue
        Color(0xFF75C2F9), // Dark blue
        Color(0xFFF7D7C4), // Light pink
        Color(0xFFF5B2C7), // Medium pink
        Color(0xFFF28BAE), // Dark pink
        Color(0xFFF7F2D4), // Light beige
        Color(0xFFF5E0C3), // Medium beige
    )

    val fontSizeInPx = with(LocalDensity.current) { 30.sp.toPx() }
    val doubleFontSizeInPx = fontSizeInPx * 2

    val infiniteTransitionForOffset = rememberInfiniteTransition(label = "")
    val offsetAnimation by infiniteTransitionForOffset.animateFloat(
        initialValue = 0f,
        targetValue = doubleFontSizeInPx,
        animationSpec = infiniteRepeatable(tween(200000, easing = LinearEasing)), label = ""
    )

    val gradientBrush = remember(offsetAnimation) {
        object : ShaderBrush() {
            override fun createShader(size: Size): Shader {
                val widthOffset = size.width * offsetAnimation
                val heightOffset = size.height * offsetAnimation
                return LinearGradientShader(
                    colors = colorGradient,
                    from = Offset(widthOffset, heightOffset),
                    to = Offset(widthOffset + size.width, heightOffset + size.height),
                    tileMode = TileMode.Mirror
                )
            }
        }
    }

    val infiniteTransitionForRotation = rememberInfiniteTransition(label = "")
    val rotationDirection = remember { mutableStateOf(1) }

    val rotationAngles = List(word.length) { index ->
        infiniteTransitionForRotation.animateFloat(
            initialValue = 0f,
            targetValue =
            if (index % 2 == 0) 5f * rotationDirection.value else -5f * rotationDirection.value,
            animationSpec = infiniteRepeatable(
                animation = tween(250, easing = LinearEasing),
                repeatMode = RepeatMode.Restart
            ), label = ""
        )
    }

    LaunchedEffect(key1 = Unit) {
        while (true) {
            delay(250)
            rotationDirection.value *= -1
        }
    }

    val letterOffsets = List(word.length) { index ->
        infiniteTransitionForRotation.animateFloat(
            initialValue = 0f,
            targetValue = if (index % 2 == 0) 2f else -2f,
            animationSpec = infiniteRepeatable(
                animation = tween(250, easing = LinearEasing),
                repeatMode = RepeatMode.Reverse
            ), label = ""
        )
    }

    Text(
        text = word,
        fontSize = fontSize,
        style = LocalTextStyle.current.copy(brush = gradientBrush),
        modifier = modifier
    )

/*
    Row {
        word.forEachIndexed { index, char ->
            val angle by rotationAngles[index]
            val offset by letterOffsets[index]
            Text(
                text = char.toString(),
                fontSize = fontSize,
                style = LocalTextStyle.current.copy(brush = gradientBrush),
                modifier = Modifier
                    .rotate(angle)
                    .offset(y = offset.dp)
            )
        }
    }
*/
}

解决方案

核心思路是:基于整词尺寸创建全局渐变Shader,通过TextMeasurer获取每个字母的位置,在Canvas中逐个绘制带独立动画的字母,所有字母共享同一个渐变Shader,实现整词渐变+单字母动画的效果。

实现代码

@OptIn(ExperimentalTextApi::class)
@Composable
fun RotatingLetters(word: String,
                    fontSize: TextUnit = 30.sp,
                    modifier: Modifier = Modifier) {

    val colorGradient = listOf(
        Color(0xFFC0EFFF), // 浅蓝
        Color(0xFF9BDBFB), // 中蓝
        Color(0xFF75C2F9), // 深蓝
        Color(0xFFF7D7C4), // 浅粉
        Color(0xFFF5B2C7), // 中粉
        Color(0xFFF28BAE), // 深粉
        Color(0xFFF7F2D4), // 浅米色
        Color(0xFFF5E0C3), // 中米色
    )

    val textStyle = LocalTextStyle.current.copy(fontSize = fontSize)
    val textMeasurer = rememberTextMeasurer()
    val textLayoutResult = remember(word, textStyle) {
        textMeasurer.measure(
            text = AnnotatedString(word),
            style = textStyle
        )
    }
    val fullTextSize = textLayoutResult.size

    val infiniteTransitionForOffset = rememberInfiniteTransition(label = "")
    val offsetAnimation by infiniteTransitionForOffset.animateFloat(
        initialValue = 0f,
        targetValue = fullTextSize.width * 2,
        animationSpec = infiniteRepeatable(tween(200000, easing = LinearEasing)), label = ""
    )

    // 基于整词尺寸创建全局渐变Shader
    val gradientShader = remember(offsetAnimation, fullTextSize) {
        LinearGradientShader(
            colors = colorGradient,
            from = Offset(offsetAnimation, offsetAnimation),
            to = Offset(offsetAnimation + fullTextSize.width, offsetAnimation + fullTextSize.height),
            tileMode = TileMode.Mirror
        )
    }

    val infiniteTransitionForRotation = rememberInfiniteTransition(label = "")
    val rotationDirection = remember { mutableStateOf(1) }

    val rotationAngles = List(word.length) { index ->
        infiniteTransitionForRotation.animateFloat(
            initialValue = 0f,
            targetValue = if (index % 2 == 0) 5f * rotationDirection.value else -5f * rotationDirection.value,
            animationSpec = infiniteRepeatable(
                animation = tween(250, easing = LinearEasing),
                repeatMode = RepeatMode.Restart
            ), label = ""
        )
    }

    LaunchedEffect(Unit) {
        while (true) {
            delay(250)
            rotationDirection.value *= -1
        }
    }

    val letterOffsets = List(word.length) { index ->
        infiniteTransitionForRotation.animateFloat(
            initialValue = 0f,
            targetValue = if (index % 2 == 0) 2f else -2f,
            animationSpec = infiniteRepeatable(
                animation = tween(250, easing = LinearEasing),
                repeatMode = RepeatMode.Reverse
            ), label = ""
        )
    }

    Canvas(modifier = modifier) {
        word.forEachIndexed { index, char ->
            val charBounds = textLayoutResult.getBoundingBox(index)
            val angle by rotationAngles[index]
            val offsetY by letterOffsets[index]

            // 保存画布状态,避免变换影响其他字母
            save()

            // 平移到字母的原始位置,并加上垂直偏移
            translate(
                left = charBounds.left,
                top = charBounds.top + offsetY.toPx()
            )

            // 以字母中心为旋转点执行旋转
            rotate(
                degrees = angle,
                pivot = Offset(charBounds.width / 2, charBounds.height / 2)
            )

            // 绘制单个字母,使用全局渐变Shader
            drawText(
                textMeasurer = textMeasurer,
                text = char.toString(),
                style = textStyle.copy(
                    brush = ShaderBrush(gradientShader)
                ),
                topLeft = Offset.Zero
            )

            // 恢复画布状态
            restore()
        }
    }
}

@Preview(showBackground = true)
@Composable
fun RotatingLettersPreview() {
    RotatingLetters("Medium")
}

方案说明

  1. 全局渐变Shader:基于整个单词的尺寸创建渐变,确保所有字母共享同一个渐变空间,实现整词连续渐变效果;
  2. 字母位置测量:通过TextMeasurer获取每个字母在整词中的精确位置,保证动画后字母的相对布局正确;
  3. 独立动画控制:在Canvas中对每个字母单独应用旋转、偏移变换,同时复用全局Shader,兼顾动画和渐变效果。

内容的提问来源于stack exchange,提问作者Oleksandr Myronchuk

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 03:22:37