Using Animatable to achieve Custom Canvas Animations

Imagine the smoothest app experience you’ve seen… Everything moving along with silky smooth transitions and sensible reactions to your interactions…. Are you drooling yet? 🤤 Well you no longer need to dream, as adding animations with Compose will make this dream a reality. Don’t believe me? Read on.

Compose has a range of Animation APIs that cover many different use cases. The simplest animation API is <a class="au lx" href="https://developer.android.com/reference/kotlin/androidx/compose/animation/package-summary#AnimatedVisibility(kotlin.Boolean,androidx.compose.ui.Modifier,androidx.compose.animation.EnterTransition,androidx.compose.animation.ExitTransition,kotlin.String,kotlin.Function1)" target="_blank" rel="noopener ugc nofollow">AnimatedVisibility</a>. We can use it to animate the visibility of any Composable by wrapping it in an AnimatedVisibility Composable and providing a boolean that toggles the visibility:

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */

var showText by remember {
    mutableStateOf(true)
}
AnimatedVisibility(visible = showText) {
    RoundedRect()
}
AnimatedVisibility in action

This is a quick way to get fluid user interactions in your Compose apps. But what if we wanted to do something more complex, like animating the rotation or color of our custom drawing?

Custom drawing animating rotation and color change over time

The Animation documentation covers the different APIs that will enable you to achieve these sorts of animations and we are going to dive into one of those options: Animatable.

But before we jump into how to achieve this with Compose, let’s remind ourselves how to achieve custom animations in the standard view system:

Reminiscing on the Standard View approach 😶‍🌫️

Without Compose, you’d need to use something like ValueAnimator or ObjectAnimator to animate properties of your custom view. I covered this example in a previous talk.

Below is a sample of how to animate two properties of a custom view that draws a rounded rect, namely a rotation angle and a color Int. We use the ValueAnimator.ofPropertyValuesHolder to animate multiple properties at the same time, calling invalidate() on the view. This triggers a redraw and consequently, the animation runs as the properties are updated.

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */
class CustomCanvasAnimationView @JvmOverloads constructor(
   context: Context,
   attrs: AttributeSet? = null,
   defStyle: Int = 0) : View(context, attrs, defStyle) {

   private var angle = 0f

   @ColorInt
   private var color: Int = Color.GREEN
       set(value) {
           field = value
           paint.color = value
       }
   private val paint = Paint()
   private val sizeBoxPx = SIZE_BOX.toPx

   fun animateViewChanges(toAngle: Float, @ColorInt toColor: Int) {
       val propAngle = PropertyValuesHolder.ofFloat(PROP_ANGLE, angle, toAngle)
       val propColor = PropertyValuesHolder.ofInt(PROP_COLOR, color, toColor)
       ValueAnimator.ofPropertyValuesHolder(propColor, propAngle).apply      {
           setEvaluator(ArgbEvaluator())
           duration = 3000
           addUpdateListener { animation ->
               [email protected] = animation.getAnimatedValue(PROP_ANGLE) as Float
               [email protected] = animation.getAnimatedValue(PROP_COLOR) as Int
               invalidate() // performs animation as it calls onDraw() again
           }
           start()
       }
   }

   override fun onDraw(canvas: Canvas) {
       // use newly updated angle and color, this onDraw function is called multiple times to
       // produce the animation
       canvas.save()
       canvas.rotate(angle, sizeBoxPx / 2f, sizeBoxPx / 2f)
       canvas.drawRoundRect(0f, 0f, sizeBoxPx, sizeBoxPx, 16f, 16f, paint)
       canvas.restore()
   }

   companion object {
       private const val PROP_ANGLE = "angle"
       private const val PROP_COLOR = "color"
       private const val SIZE_BOX = 200f
   }
}

val Float.toPx get() = this * Resources.getSystem().displayMetrics.density

Using Compose to Animate Custom Drawing 🎨

To implement this animation in Compose, we create two remembered Animatable states–one for the angle, and one for the color. The angle will animate from 0–360° and the color will animate from Color.Green to Color.Blue, we will then use these two values to draw our elements on the Canvas.

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */
@Composable
fun CustomCanvasAnimation() {
   val angle = remember {
       Animatable(0f)
   }
   val color = remember {
       Animatable(green, typeConverter = Color.VectorConverter(ColorSpaces.LinearSrgb))
   }

   LaunchedEffect(angle, color) {
       launch {
           angle.animateTo(360f, animationSpec = tween(3000))
       }
       launch {
           color.animateTo(blue, animationSpec = tween(3000))
       }
   }
   Canvas(modifier = Modifier.size(200.dp),
       onDraw = {
           rotate(angle.value) {
               drawRoundRect(
                   color = color.value,
                   cornerRadius = CornerRadius(16.dp.toPx())
               )
           }
       }
   )
}

All the animation logic is in the same Composable as the drawing logic. Leveraging the same classes and functions that are used in Composables — such as coroutines and Compose’s State class to store the animation progress.

You’ll notice the color animatable looks a bit different: We’ve defined a <a class="au lx" href="https://developer.android.com/reference/kotlin/androidx/compose/animation/core/TwoWayConverter" target="_blank" rel="noopener ugc nofollow">TwoWayConverter</a> to convert the color into an AnimationVector. The Color.VectorConverter transforms between a Color and a 4 part AnimationVector storing the red, green, blue and alpha components of the color. In the previous view sample, we needed to add the ArgbEvaluator to achieve the same effect.

To start the animations as soon as the Composable is added to the Composition, we use the <a class="au lx" href="https://developer.android.com/jetpack/compose/side-effects#launchedeffect" target="_blank" rel="noopener ugc nofollow">LaunchedEffect</a> class. This will only run again when the provided keys change. We then use the animateTo function on the previously defined Animatable, this is a suspend function that needs to be called from a coroutine context.

In order to run the animations in parallel, we call launch twice, calling each animateTo in a new coroutine. If we wanted to run the animations sequentially, we would change the above LaunchedEffect block to use the same coroutine:

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */
LaunchedEffect(angle, color) {
   launch {
       angle.animateTo(360f, animationSpec = tween(3000))
       color.animateTo(blue, animationSpec = tween(3000))
   }
}

Running the animations in the same coroutine will suspend the thread until angle.animateTo() has completed running, then color.animateTo() will run. For more detail around using coroutines for animations — check out this video.

The last thing that remains is to use these two properties to draw something! ✏️ The Canvas onDraw function uses the animatable values — angle.value and color.value to rotate and set the color of the rounded rectangle. Compose automatically redraws when these values change. We don’t need to call invalidate.

That’s it! Using Animatable, we can achieve custom animations in the same way as we were previously using ValueAnimator. We’ve achieved a more readable version and less lines of code using the Compose version than the ValueAnimator approach.

And if less code hasn’t convinced you yet… Using <a class="au lx" href="https://developer.android.com/reference/kotlin/androidx/compose/animation/core/KeyframesSpec" target="_blank" rel="noopener ugc nofollow">keyframes</a> might be the real drawcard. Keyframes in Compose give you the ability to change key sections (frames) of your animations at certain time intervals.

For example, if we want to set specific colors to animate at certain points of our animation, we can do the following:

/* Copyright 2022 Google LLC.	
   SPDX-License-Identifier: Apache-2.0 */
LaunchedEffect(angle, color) {
        launch {
            angle.animateTo(360f, animationSpec = tween(3000))
        }
        launch {
            color.animateTo(Color.Black, animationSpec = keyframes {
                    durationMillis = 3000
                    Color.Red at 500 with LinearEasing
                    Color.Yellow at 1000 with FastOutSlowInEasing
                    Color.Green at 1500 with FastOutLinearInEasing
                    Color.Blue at 2000 with FastOutLinearInEasing
                    Color.Magenta at 2500 with FastOutLinearInEasing
                }
            ) {
                // callback with animation values if you need them
            }
        }
 }

The above example creates a keyframes AnimationSpec object, and provides it with the different Colors that should be animated to at specific time intervals with a specified Easing Curve.

It is worth noting that Animatable is a low level animation API offered by Compose, many of the higher level convenience APIs are based on top of Animatable. For example, animateColorAsState() wraps the above logic for Color conversions using TwoWayConverter under the hood. animateFloatAsState() could also be used for animating the angle. The animate*AsState APIs allow you to animate independently based on a state change, whereas Animatable offers more flexibility and control of your animation: allowing coordinating animations, indeterminate animations triggered by gestures etc.

For a more real world example, have a look at the custom date selection pill animation in the Crane sample.

For more information on different kinds of Animation in Compose, check out the Animation docs, the Animation codelab, or the Motion Compose sample on Github for more examples.

Share your drool-worthy animations with me on Twitter @riggaroo.

Bye for now! 👋

By Rebecca Franks
Source Android

Previous Spot Your UI Jank Using CPU Profiler In Android Studio
Next HP Unlocks Possibilities For Gamers To Play Anywhere, Longer, And Better