Creating a wave-like text animation in jetpack compose: sequence disruption issue

277 views Asked by At

I am currently developing an application using Kotlin and Jetpack Compose. My task is to create an animation where each letter in a given word alternately increases and decreases in size, creating a wave-like effect across the text. This means that one letter at a time will grow larger, then shrink back to its original size, before the next letter does the same. This pattern will repeat for each letter in the word.

Here is the relevant code:

@Preview(showBackground = true)
@Composable
fun AnimatedTextPreview()
{
    AnimatedText("Hello")
}

@Composable
fun AnimatedText(word: String) {
    val duration = 500L
    val delayPerItem = 100L
    val transition = rememberInfiniteTransition()

    Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
        word.forEachIndexed { index, c ->
            val scale by transition.animateFloat(
                initialValue = 1f,
                targetValue = 2f,
                animationSpec = infiniteRepeatable(
                    animation = tween(
                        durationMillis = duration.toInt(),
                        delayMillis = ((delayPerItem * index) % duration).toInt(),
                        easing = FastOutSlowInEasing
                    ),
                    repeatMode = RepeatMode.Reverse
                ), label = ""
            )
            Text(text = c.toString(), fontSize = (24 * scale).sp)
        }
    }
}

The animation sequence works as expected the first time, creating a wave effect as each letter increases and decreases. However, from the second time onwards, the letters start increasing and decreasing out of order, disrupting the animation. I suspect the issue might be with delayMillis.

I would appreciate any guidance on how to resolve this issue

2

There are 2 answers

0
Phil Dukhov On BEST ANSWER

infiniteRepeatable repeats the animation along with the specified delay on each cycle, causing desynchronization after the initial cycle.

To avoid this, delay only the first iteration using initialStartOffset parameter:

animationSpec = infiniteRepeatable(
    animation = tween(
        durationMillis = duration.toInt(),
        easing = FastOutSlowInEasing
    ),
    repeatMode = RepeatMode.Reverse,
    initialStartOffset = StartOffset(offsetMillis = ((delayPerItem * index) % duration).toInt())
),

Note that your animation has to recompose one each frame, which may cause lags if your UI is complex. If you face such issue in release version of the app, you can draw letters on Canvas - measure each letter once with maximum font size(48.sp in this case), and scale/position them depending on animation state.

0
VonC On

In your code, each letter in the word 'Hello' animates independently, based on the delay calculated with delayMillis (using tween).

I suppose the timing does not reset correctly in subsequent cycles after the first complete sequence: ideally, you would want each letter to start its animation (grow and shrink) in the same sequence as the first cycle.

However, the letters start animating in an incorrect order or timing.
Once the first cycle completes, the subsequent cycles need to reset the timing for each letter to maintain the wave effect. The original calculation does not reset or synchronize the delays properly for each new cycle. This desynchronization leads to the letters starting their animations out of order in the following cycles.

Try and adjust the delayMillis calculation to make sure it correctly loops over each cycle. You should consider the total duration of the animation cycle for all letters, not just each individual letter.

@Composable
fun AnimatedText(word: String) {
    val duration = 500L
    val delayPerItem = 100L
    val totalDurationPerCycle = duration + (delayPerItem * (word.length - 1))
    val transition = rememberInfiniteTransition()

    Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
        word.forEachIndexed { index, c ->
            val scale by transition.animateFloat(
                initialValue = 1f,
                targetValue = 2f,
                animationSpec = infiniteRepeatable(
                    animation = tween(
                        durationMillis = duration.toInt(),
                        delayMillis = (delayPerItem * index % totalDurationPerCycle).toInt(),
                        easing = FastOutSlowInEasing
                    ),
                    repeatMode = RepeatMode.Reverse
                ), label = ""
            )
            Text(text = c.toString(), fontSize = (24 * scale).sp)
        }
    }
}

The totalDurationPerCycle represents the total time taken for a complete cycle of the animation across all letters in the word. That duration is calculated as the sum of the individual animation duration for one letter (duration) and the cumulative delay added for each subsequent letter (delayPerItem * (word.length - 1)).

That means the delay for each letter is now calculated relative to the entire cycle of the animation: each letter's animation should start and end in a synchronized manner in every cycle.

  • In the first cycle, each letter starts its animation after a delay proportional to its position (indexed). The totalDurationPerCycle does not affect the first cycle much; it works similarly to your original approach.

  • In the following cycles, the use of totalDurationPerCycle makes sure the delay for each letter's animation resets correctly relative to the total animation cycle. That synchronization prevents the letters from animating out of order and should maintain the wave-like effect consistently in every cycle.