android-jetpack-composeandroid-jetpackjetpack-compose-animationandroid-jetpack-compose-animation

Applying Brush Effect to Whole Word While Animating Each Letter Individually in Jetpack Compose


I am trying to create a text effect in Jetpack Compose where the text uses a brush that looks like a set of colors smoothly changing diagonally. The individual letters in the text should move up and down, as well as make half turns.

I have encountered two problems:

  1. If I try to apply the brush to the whole word, I don’t know how to animate the letters that are moving and rotating. Here is the code I have tried:
        Text(
        text = word,
        fontSize = fontSize,
        style = LocalTextStyle.current.copy(brush = gradientBrush),
        modifier = modifier
    )
  1. If I apply the animation to each letter individually, I can achieve the desired result with the animation, but the brush is applied to each letter individually as well, creating an unattractive effect. I need the brush to be applied to the whole word. Here is the code I have tried:
    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)
            )
        }
    }

Here are two demonstrations:

enter image description here

  1. The desired result of applying the brush to the whole word, but I don’t know how to animate the letters.
  2. The desired result of applying the animation, but I can’t apply the brush to the whole word (the brush is applied to each letter individually).

How can I create an animation of moving up, down, and half-turning for each letter individually, while applying the brush to the whole word in Jetpack Compose?

Here is the full code:

@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)
            )
        }
    }
*/
}

Solution

  • you can achieve a similar effect by using a combination of drawWithContent and clipping each letter to create a single continuous gradient across all letters. Here is a conceptual solution:

    Create a custom composable that uses Canvas to draw the text manually, allowing you to apply a single gradient brush across the entire word. Use drawWithContent to clip the drawing area to the bounds of each character when drawing them, so they can each animate independently while still sharing the same brush. Here's an example of how you might implement it:

    @Composable
    fun AnimatedGradientText(word: String, gradientBrush: Brush, fontSize: TextUnit, modifier: Modifier = Modifier) {
        // Calculate text size, character positions, etc.
        val textPaint = remember {
            Paint().asFrameworkPaint().apply {
            isAntiAlias = true
            style = android.graphics.Paint.Style.FILL
        }
    }
    
    // For each letter, create a rotation and offset animation.
    val rotationAngles = remember { /* ... Your rotation animations ... */ }
    val letterOffsets = remember { /* ... Your offset animations ... */ }
    
    Canvas(modifier = modifier) {
        // Calculate text size and positions
        val textLayoutResult = remember(word, fontSize) { /* ... Measure text ... */ }
    
        word.forEachIndexed { index, char ->
            // Get animation values
            val angle: Float by rotationAngles[index]
            val offset: Float by letterOffsets[index]
    
            // Save the current canvas state
            save()
    
            // Position the canvas for this character
            translate(left + characterPositions[index], top)
    
            // Apply the rotation transformation
            rotate(angle)
    
            // Set the shader to the paint
            textPaint.shader = gradientBrush.asAndroidShader()
    
            // Draw this character
            drawContext.canvas.nativeCanvas.drawText(
                char.toString(),
                0f,
                0f + offset,
                textPaint
            )
    
            // Restore the canvas to avoid affecting subsequent characters
            restore()
        }
    }
    }