Tags

, , , , , , , , , , ,

Firefox has started to move towards curvy tabs. And mobile being first, we have curvy tab ending and tabs button in Firefox for Android. As it may sound simple to just add an image, there are additional constraints. The tabs button can take two states — default and pressed. The tabs button may or may not have a tail depending on the availability of hardware menu button. The tabs button can take 3 different sizes — 40dp, 48dp, 56dp in height. Also, different densities for these sizes. And the tablet counterpart is a mirror reflecting of the tabs button. Altogether we had to add 48 different images for tabs button alone. Adding the tab ending curve, curvy menu button, the new tab button increases the list to 93 images in total. Here is the distribution of the range of images we initially added.

Let’s take a closer look at the images. They are basically carved out of a gradient+textured background. And the curves are all bezier curves. Can we carve out a tabs button in code? Can we create bezier curves in code? Can we just use “one” image to recreate the entire array or images with no performance regression? Yes! It can be done!

As we all know, Android has a onDraw method for each View where can we draw on the Canvas given to us. So how does Canvas work? Canvas takes a Paint (brush), loaded with different properties to draw points, lines, curves, rectangles, and text. We can define properties like line stroke width, how two lines should join at a point, color for the brush, color filters for the brush, do we draw just a stroke or fill a path and so on. Additionally there is a Path class that can construct arbitrary shapes of our needs. We can keep adding paths, like SVG, and come up with our own shapes. To make things fall in place, Paint supports adding a Transfer Mode. What’s the transfer mode?

In basic windowing, by using alpha compositing, when something (SRC) is drawn to a Canvas (DST), the Paint can additionally say how the SRC should be placed on the DST. There are grouped into PorterDuff.Modes Some of them are: only SRC is drawn (SRC), only DST is drawn (DST), SRC is drawn on top of DST (SRC_ATOP), DST is drawn on top of source (DST_ATOP). We can use the SRC/DST as a mask too. Using SRC_IN will the draw of SRC in the DST. Using DST_OUT will draw the portion of DST that doesn’t intersect with SRC and so on.

Now back to the problem in hand. The background (DST) is a gradient+textured rectangle. A nice bezier curve (SRC) has to be masked on top of it, and the DST has to be carved in it.

The markup of the TabsButton remains the same as ImageButton. The interesting thing to note here is the background can take a state drawable even — as we would be carving the background in draw.

<TabsButton android:id="@+id/tabs"
            android:layout_width="200dp"
            android:layout_height="100dp"
            android:background="@drawable/a_state_drawable"/>

And the TabsButton class would look like this:

public class TabsButton extends ImageButton {
    Paint mPaint;
    Path mPath;

    public TabsButton(Context context, AttributeSet attrs) {
        super(context, attrs);
		
        // The Paint brush need for masking.
        mPaint = new Paint();
        // Anti-aliasing can be set in constructor. But for clarity, taken out.
        mPaint.setAntiAlias(true);
        // A color to draw the curved path.
        mPaint.setColor(0xFFFF0000);
	
        // The Path that holds the curved shape.
        // It's better to initialize here, as creating new objects in 
        // onMeasure() or draw() is a bad practice.	
        mPath = new Path();
    }

    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Any size change is reflected with a call to onMeasure.
        // So, creating a path here is better than creating for every draw.
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int width = getMeasuredWidth();
        int height = getMeasuredHeight();

        // Width of the curve.
        float curve = height * 1.125f;

        // The Path is reset to (0, 0)
        mPath.reset();

        // Add a Cubic Bezier for the left hand side by defining the control points.
        mPath.cubicTo(curve * 0.75f, 0, 
                      curve * 0.25f, height,
                      curve, height);

        // A line to the bottom right corner.
        mPath.lineTo(width, height);

        // Add a Cubic Bezier for the right hand side by defining the control points.
        mPath.cubicTo(width - (curve * 0.75f), height, 
                      width - (curve * 0.25f), 0,
                      width - curve, 0);

         // A line to the top left corner.
         mPath.lineTo(0, 0);
    }

    @Override
    public void draw(Canvas canvas) {
        // Save the canvas to a layer for off-screen composition.
        // If not, the transparency is not preserved, resulting in black areas.
        int count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null,     
                                     Canvas.MATRIX_SAVE_FLAG |
                                     Canvas.CLIP_SAVE_FLAG |
                                     Canvas.HAS_ALPHA_LAYER_SAVE_FLAG |
                                     Canvas.FULL_COLOR_LAYER_SAVE_FLAG | 
                                     Canvas.CLIP_TO_LAYER_SAVE_FLAG);

        // Ask parent to draw. It'll draw the background, and the android:src image, if any.
        // It also takes the additional burden of checking for the state and using the right drawable.
        super.draw(canvas);

        // ICS added double-buffering, which made it easier for drawing the Path directly over the DST.
        // In pre-ICS, drawPath() doesn't seem to use ARGB_8888 mode for performance, hence transparency is not preserved.
        if (Build.VERSION.SDK_INT >= 14) {
            // Draw the path over the canvas. Ensure that only what's under the path is left behind.
            // DST_IN helps in doing this alpha compositing.
            mPaint.setXfermode(new PorterDuffXfermode(Mode.DST_IN));
            canvas.drawPath(mPath, mPaint);
         } else {
            int width = getMeasuredWidth();
            int height = getMeasuredHeight();

            // Allocate a bitmap and draw the masking path.
            Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

            // Draw the path on the bitmap, but getting a canvas for the bitmap.
            (new Canvas(bitmap)).drawPath(mPath, mPaint);

            // Draw the bitmap containing the path, over the actual canvas.
            mPaint.setXfermode(new PorterDuffXfermode(Mode.DST_IN));
            canvas.drawBitmap(bitmap, 0, 0, mPaint);

            // Reset paint, as it will be used again to draw a path on bitmap, if need.
            mPaint.setXfermode(null);
        }

        // Restore the canvas, which will transfer it from off-screen to actual screen.
        canvas.restoreToCount(count);
    }
}

Voila! We have a scalable, simpler, easier solution that uses just one image!

Update: A better approach would be to use Shaders. This reduces the need for a offscreen bitmap.

Advertisements