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) ) } }
两种效果的矛盾:
- 整词应用画笔的效果符合预期,但无法实现字母动画;
- 字母动画效果符合预期,但画笔仅作用于单个字母而非整词。
核心问题:如何在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") }
方案说明
- 全局渐变Shader:基于整个单词的尺寸创建渐变,确保所有字母共享同一个渐变空间,实现整词连续渐变效果;
- 字母位置测量:通过
TextMeasurer获取每个字母在整词中的精确位置,保证动画后字母的相对布局正确; - 独立动画控制:在Canvas中对每个字母单独应用旋转、偏移变换,同时复用全局Shader,兼顾动画和渐变效果。
内容的提问来源于stack exchange,提问作者Oleksandr Myronchuk
相关产品推荐
相关产品推荐

