Tags

, , , , , , , , , , , , , , , , , , , ,

Timely is a beautiful Android app. Waking up to see the numbers animate is like a morning bliss. I was intrigued by how the numbers are animated in that app. It’s not a simple fade-out-fade-in effect. It has some kind of folding in it — but not the same as the airport status boards. UiAutomator says that the entire block is one custom view. By profiling the GPU, I found that something is drawn to the screen consistently — even when there is a pause between animations. What could it be? That’s when Christoper Nolan came in my dreams and asked, “Are you watching closely?”.

The numbers are not directly coming from a font drawn as a TextView, instead constructed as multiple segments. The transition from one number to another happens by shape tweening the segments. Interesting! But how does a line change to a curve and back to a line? That’s where bezier curves help us. A bezier curve has two end points, say A and B, and two control points, say C1 and C2. The curve is drawn by.. alright, no math here! :P And the interesting fact is, if the C1 = A and C2 = B, we get a straight line from A to B. So a cubic bezier can behave as a curve and a line — that’s all we need.

Numbers

The first part of the problem is to identify the control points. Numbers like 1 and 7 needs only two control points. However, numbers like 0, 3, 8 needs 5 control points. If we have to map 4 segments between numbers, then all the 10 digits should use five control points. The image above doesn’t show all the points in digit 1. You could assume that points 3, 4 and 5 are shared. The digit 7 doesn’t need 5 control points. But they are added so that the animation from 7 to 8 looks nice. The next part is to draw the curves with these control points. If you are using an application like Adobe Illustrator, trying this out is easy. The following image shows how the curves are created for number 3. But wait! The choice of font also matters. They choose Futura Light, which has nice curves and straight lines. If you were to try this with Roboto — good luck! ;)

Number-3

Now that we’ve identified the end points and the control points for each segment, lets mash up some code.

public class NumberView extends View {

    private final Interpolator mInterpolator;
    private final Paint mPaint;
    private final Path mPath;

    // Numbers currently shown.
    private int mCurrent = 0;
    private int mNext = 1;

    // Frame of transition between current and next frames.
    private int mFrame = 0;
    
    // The 5 end points. (Note: The last end point is the first end point of the next segment.
    private final float[][][] mPoints = {
        {{44.5f, 100}, {100, 18}, {156, 100}, {100, 180}, {44.5f, 100}}, // 0
        ... and for the other numbers.
    };

    // The set of the "first" control points of each segment.
    private final float[][][] mControlPoint1 = { ... };

    // The set of the "second" control points of each segment.
    private final float[][][] mControlPoint2 = { ... };
    
    public NumberView(Context context, AttributeSet attrs) {
        super(context, attrs);
        
        setWillNotDraw(false);
        mInterpolator = new AccelerateDecelerateInterpolator();
        
        // A new paint with the style as stroke.
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(5.0f);
        mPaint.setStyle(Paint.Style.STROKE);
        
        mPath = new Path();
    }
    
    @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // Frames 0, 1 is the first pause.
        // Frames 9, 10 is the last pause.
        // Constrain current frame to be between 0 and 6.
        final int currentFrame;
        if (mFrame < 2) {
            currentFrame = 0;
        } else if (mFrame > 8) {
            currentFrame = 6;
        } else {
            currentFrame = mFrame - 2;
        }
        
        // A factor of the difference between current
        // and next frame based on interpolation.
        // Only 6 frames are used between the transition.
        final float factor = mInterpolator.getInterpolation(currentFrame / 6.0f);
        
        // Reset the path.
        mPath.reset();

        final float[][] current = mPoints[mCurrent];
        final float[][] next = mPoints[mNext];

        final float[][] curr1 = mControlPoint1[mCurrent];
        final float[][] next1 = mControlPoint1[mNext];

        final float[][] curr2 = mControlPoint2[mCurrent];
        final float[][] next2 = mControlPoint2[mNext];
        
        // First point.
        mPath.moveTo(current[0][0] + ((next[0][0] - current[0][0]) * factor),
                     current[0][1] + ((next[0][1] - current[0][1]) * factor));
        
        // Rest of the points connected as bezier curve.
        for (int i = 1; i < 5; i++) {
            mPath.cubicTo(curr1[i-1][0] + ((next1[i-1][0] - curr1[i-1][0]) * factor),
                          curr1[i-1][1] + ((next1[i-1][1] - curr1[i-1][1]) * factor),
                          curr2[i-1][0] + ((next2[i-1][0] - curr2[i-1][0]) * factor),
                          curr2[i-1][1] + ((next2[i-1][1] - curr2[i-1][1]) * factor),
                          current[i][0] + ((next[i][0] - current[i][0]) * factor),
                          current[i][1] + ((next[i][1] - current[i][1]) * factor));
        }

        // Draw the path.
        canvas.drawPath(mPath, mPaint);

        // Next frame.
        mFrame++;

        // Each number change has 10 frames. Reset.
        if (mFrame == 10) {
            // Reset to zarro.
            mFrame = 0;

            mCurrent = mNext;
            mNext++;

            // Reset to zarro.
            if (mNext == 10) {
                mNext = 0;
            }
        }

        // Callback for the next frame.
        postInvalidateDelayed(100);
    }
}

Given a set of end points and control points, the idea is to drawn 4 cubic bezier curves. We create a path and move to the first end point. From there, we draw a bezier curve to the second end point using the given control points. This end point will be the “first” end point for the next segment, and so on. This logic would drawn the numbers without any animation. To kick in some animation bits, each transition is defined by 10 frames, called at 100ms interval. The first two and the last two frames are static — that how the number stops for a heartbeat. The in-between 6 frames maps the end points and control points for the segments between the numbers, draws a part of the transition based on the interpolated value. To put this in simpler terms, let’s take the “first” end points to be mapped to be (100, 100) and (124, 124). The first of the six frames would draw (100, 100). The second would draw ((124 – 100)/6, (124 – 100)6), that is (104, 104). The third of the six frames would draw (108, 108) and the last would finally end at (124, 124). Now extend this argument for other end and control points. This would change the curves from one to another — with in-between curves looking twisted and turned ;)

As you can see in the code, all 6 points are calculated in each draw frame. This might not be a good. Since we know the interpolation, these values could be pre-calculated and held as a giant array of points. It would also be nice to have all the points to be a float value between 0 and 1, as that would allow scaling the numbers to any sized canvas easily. If you were to deal with different width for each digit, and have other texts showing up, which could also animate, and calculate where each digit show go in a single View — that’s when I bow and respect the Timely app! They’ve done more work that what this blog post says. This post is just to give an idea of how such things can be done. Their work is a piece of art! To see how this animation works, try a HTML version here.

About these ads