Custom Ease Interpolator for Meaningful Motion in Android

César Gómez
Yellowme
9 min readMay 25, 2020

--

Interpolators are very useful to model movement for your UI elements. In this article, we’ll focus on how to create aesthetic motion by using two simple tools: animator and interpolator instances.

Some Animation Theory

How do interpolators work? Well, when it comes to changing the property of an object over time, they take an initial and a final value and create a series of ‘middle values’ between them, according to a specific rule. This rule is a mathematical function. As an example, consider a simple geometric shape as an object of which properties can be animated. Let’s have a look at changing a property of this shape, namely, its horizontal position.

Linear change

The simplest interpolator is the linear interpolator. Give this interpolator an initial and final position, and the interpolator will calculate a series of middle points, equally separated from each other, along which the shape will pass in order to get to the final position. Take a look at how the square moves. From the graph of position over time, you can see that the position value follows a linear path through the graph as the position of the square changes. This illustrates that, per unit of time, the position value changes in equal parts, with no variation in speed. In other words, the linear interpolator creates constant speed.

Easing

Easing refers to a value’s speed changing over time. In the case of an object’s position, for example, you’ll see that its rate of motion changes, by moving slow and then fast or vice versa. Look at an example of the same shape as before, but now the shape changes its speed during its motion:

As you can see from the graph of the shape’s position over time, the value now plots a curved path. It illustrates quite clearly what’s happening:

  • At first, as time passes, the value of position doesn’t change much; the shape has a slow movement.
  • As we reach the middle of our animation, the shape accelerates steeply. The shape now moves much faster because the change in position per unit of time is greater.
  • Finally, it decelerates until it reaches zero speed; its motion finishes.

To create this kind of motion, we use easing interpolators, which create variable speed. This is the most appealing interpolator because of the characteristics it gives to objects that change. Usually, the kind of change that is more appealing to the eye is what is called an ease in-out, in which it accelerates at the start and decelerates at the end. It starts slow, becomes faster in the middle, and ends slow. Easing interpolators are perfect to animate objects in this way. So now, let’s look at how we can create them.

Bezier curves

Bezier curves allow for graphical modeling of a numerical property changing over time, making them the de facto tool for creating the aesthetic motion we’ve talked about. The shape of an ease-in-out Bezier curve is modeled by four control points laid out in a graph of a certain value over time (the same kind as in our previous examples).

The extreme points in this curve are fixed, and only the two middle points move. This is because the points in the extremes represent the start and end values, at the beginning and end of our animation’s time, respectively. Because of this, we only need two points in a graph to change the characteristics of the object’s movement. To modify this rate of acceleration and deceleration, the middle points, along with the fixed extreme points, create the path that is shown. This is done by De Casteljau’s algorithm, which provides a way of constructing Bezier curves using segments along control points.

Two fixed start and end points and two variable middle points make a motion-oriented Bezier curve

Check this site to see how the curves are constructed in an interactive way. If you’re into math, you’ll also see the parametric equation that represents De Casteljau’s algorithm for 2, 3, and 4 control points.

A tool for creating motion-oriented Bezier curves

The Android framework provides support for interpolating using Bezier curves. The middle control points mentioned before are precisely what the Android’s ease interpolation class (which we’ll cover in a bit) asks for when creating an instance. So all we need are these middle control points. Fortunately, there are some online tools that can help us customize Bezier curves in just the way we need them, and give us these points, which we then can use to create our custom interpolator. Check cubic-bezier.com and play with the parameters to creates Bezier curves like the one below. You can even preview how your animation will behave, so you can make sure it’s perfect.

The Bezier curve customizer from cubic-bezier.com

Creating and using interpolators in Android

For an Android case, let’s consider a CardView like the one below.

This a custom view that we would like to expand and contract as a response to click events. Let’s explore how it would look like using a linear interpolator first, and then providing a custom ease interpolator that will help give it a more meaningful motion.

Using ValueAnimator

One simple approach for animating a View's properties is by using a ValueAnimator. It works by taking two values and creating values in between (so, an interpolation). By adding an AnimatorUpdateListener to it, you can perform an assignment of this animated value to your view's properties (or any other variable you want) in order to update them. Let's use them to update the width of our card, with the help of a flag that helps us determine if the card is being expanded or contracted.

setOnClickListener {   
val widthAnimator =
ValueAnimator.ofInt(cardWidth, cardWidth + if (isExpanded) (-80).dp else 80.dp)
.apply {
addUpdateListener { animator ->
layoutParams.width = animator.animatedValue as Int
}
}

}

Ok, now we have an animator that interpolates some values and we perform some assignments, that update the view with the animated value across time. We can tell the animator to start the animation.

widthAnimator.start()

For the height, we create a height animator, and create an AnimatorSet to play it together with the width animator. We also toggle our expanded/contracted flag when the animation's finished.

val heightAnimator =
ValueAnimator.ofInt(
cardHeight,
cardHeight + if (isExpanded) (-80).dp else 80.dp
)
.apply {
addUpdateListener { animator ->
layoutParams.height = animator.animatedValue as Int
// We can delete this call from the width animator,
// since this will do the layout update for both.

requestLayout()
}
}
AnimatorSet().apply {
playTogether(widthAnimator, heightAnimator)
doOnEnd {
isExpanded = !isExpanded
}
start()
}

We haven’t specified a duration, or the interpolator that we want to use, but ValueAnimator has a duration of 300 milliseconds and uses an AccelerateDecelerate interpolator as default values. We’ll get to that kind of interpolator shortly, but before that, we want to expand and contract the corners or our card.

Using ObjectAnimator

There is a more specific implementation of ValueAnimator, named ObjectAnimator, which can be used to streamline the process of animating a property. This allows you to bypass the process of adding an AnimatorUpdateListener. We can use an instance of this to animate the card's corner radius.

val cornerAnimator = ObjectAnimator.ofFloat(
this,
"radius",
cornerRadius,
cornerRadius +
if (isExpanded) 6.dp.toFloat() else (-6).dp.toFloat()
)
AnimatorSet().apply {
playTogether(widthAnimator, heightAnimator, cornerAnimator)
doOnEnd {
isExpanded = !isExpanded
}
start()
}

Note that this only works with certain properties of which setters can be determined by the ObjectAnimator. For other cases, like the width and height, ValueAnimator is the adequate solution, since we can assign the changes manually via its update listener lambda.

LinearInterpolator class

Ok, now we can customize our animation with interpolators. Let’s start with the simpler one, the linear interpolator, and see what happens to our animation. We can assign an interpolator instance to an individual ValueAnimator or an AnimatorSet which will assign it to each of its animators. For this example, we'll assign a LinearInterpolator to our AnimatorSet. We can also change our duration to 500 ms to see more clearly what's happening.

AnimatorSet().apply {
interpolator = LinearInterpolator()
duration = 500
playTogether(widthAnimator, heightAnimator, cornerAnimator)
doOnEnd {
isExpanded = !isExpanded
}
start()
}

The result is what we expect: a card expanding and contracting with linear speed, which doesn’t look that good.

AccelerateDecelerateInterpolator class

Now we enter into the easing interpolator territory. This class provides an easing interpolator with a set of hardwired values. As mentioned before, this is the default interpolator in ValueAnimator, so we don't need to assign it explicitly.

This already looks better, but we can improve on it further by making the easing effect a little more pronounced. Let’s make our own easing interpolator.

PathInterpolator class

Finally, let’s create our custom interpolator. Android’s class for a Bezier-defined ease interpolator is called PathInterpolator, and we can create one by passing the (x, y) coordinates of the two control points that model the curve.

val customInterpolator = PathInterpolator(x1, y1, x2, y2)

We can also define one via an XML file inside the anim resource directory:

<?xml version="1.0" encoding="utf-8"?>
<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:controlX1="0.8"
android:controlX2="0.35"
android:controlY1="0"
android:controlY2="1" />

And loading it like so:

val customInterpolator = 
AnimationUtils.loadInterpolator(context, R.anim.ease)

Now it looks quite better. We can make further customizations to our view to make it look perfect. For example, making the text invisible during resizing, and making the duration customizable by providing a parameter that we can set from XML attributes. That way, we can have multiple card views that behave differently.

AnimatorSet().apply {
if
(isAnimationFinished) {
playTogether(
widthAnimator,
heightAnimator,
cornerAnimator
)
interpolator = when (this@ResizableCardView.interpolator) {
LINEAR -> LinearInterpolator()
EASE -> AnimationUtils.loadInterpolator(
context,
R.anim.ease
)
}
duration = this@ResizableCardView.duration
doOnStart {
isAnimationFinished = false
}
doOnEnd {
cardDescription.startAnimation(fadeIn())
isExpanded = !isExpanded
isAnimationFinished = true
}
startCardAnimation()
}
}

An extension for the AnimatorSet to handle the card’s body text fade:

private fun AnimatorSet.startCardAnimation() {
// Custom fade animation with listener to wait for card's text
// body to finish fading before resizing.
val descriptionFadeOut = fadeOut().apply {
setListener {
onAnimationEnd {
// Our AnimatorSet start()
this@startCardAnimation.start()
}
}
}
cardDescription.startAnimation(descriptionFadeOut)
}

Here’s our final custom view class.

Conclusion

We’ve seen that we can create cool animations with nothing more than a pair of numbers consisting of initial and final values and letting an interpolator fill the middle points for us. Ease interpolators make such a difference in the way your views behave when compared to linear ones. And by using custom ease interpolators created withPathInterpolator, we can customize the way our animation changes speed with very little effort. Google provides documentation for its Material Design language, which contains guidelines that can help you follow good design practices in your app, and that includes good animation practices. If you want to know how to apply interpolators so that your app's UI is more expressive, then check out the Material Design documentation on motion principles.

--

--

César Gómez
Yellowme

Android Developer, Software Engineer at Yellowme