Search code examples
androiddrawshapesandroid-graphics

Drawing a rounded hollow thumb over arc


I want to create a rounded graph that will display a range of values from my app. The values can be classified to 3 categories: low, mid, high - that are represented by 3 colors: blue, green and red (respectively).

Above this range, I want to show the actually measured values - in a form of a "thumb" over the relevant range part:

See the attached photo

The location of the white thumb over the range arc may change, according to the measured values.

Currently, I'm able to draw the 3-colored range by drawing 3 arcs over the same center, inside the view's onDraw method:

width = (float) getWidth();
height = (float) getHeight();

float radius;

if (width > height) {
    radius = height / 3;
} else {
    radius = width / 3;
}

paint.setAntiAlias(true);
paint.setStrokeWidth(arcLineWidth);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStyle(Paint.Style.STROKE);

center_x = width / 2;
center_y = height / 1.6f;

left = center_x - radius;
float top = center_y - radius;
right = center_x + radius;
float bottom = center_y + radius;

oval.set(left, top, right, bottom);

//blue arc
paint.setColor(colorLow);
canvas.drawArc(oval, 135, 55, false, paint);

//red arc
paint.setColor(colorHigh);
canvas.drawArc(oval, 350, 55, false, paint);

//green arc
paint.setColor(colorNormal);

canvas.drawArc(oval, 190, 160, false, paint);

And this is the result arc:

current arc

My question is, how do I:

  1. Create a smooth gradient between those 3 colors (I tried using SweepGradient but it didn't give me the correct result).
  2. Create the overlay white thumb as shown in the picture, so that I'll be able to control where to display it.

  3. Animate this white thumb over my range arc.

Note: the 3-colored range is static - so another solution can be to just take the drawable and paint the white thumb over it (and animate it), so I'm open to hear such a solution as well :)


Solution

  • I would use masks for your first two problems.

    1. Create a smooth gradient

    The very first step would be drawing two rectangles with a linear gradient. The first rectangle contains the colors blue and green while the second rectangle contains green and red as seen in the following picture. I marked the line where both rectangles touch each other black to clarify that they are infact two different rectangles.

    first step

    This can be achieved using the following code (excerpt):

    // Both color gradients
    private Shader shader1 = new LinearGradient(0, 400, 0, 500, Color.rgb(59, 242, 174), Color.rgb(101, 172, 242), Shader.TileMode.CLAMP);
    private Shader shader2 = new LinearGradient(0, 400, 0, 500, Color.rgb(59, 242, 174), Color.rgb(255, 31, 101), Shader.TileMode.CLAMP);
    private Paint paint = new Paint();
    
    // ...
    
    @Override
    protected void onDraw(Canvas canvas) {
        float width = 800;
        float height = 800;
        float radius = width / 3;
    
        // Arc Image
    
        Bitmap.Config conf = Bitmap.Config.ARGB_8888; // See other config types
        Bitmap mImage = Bitmap.createBitmap(800, 800, conf); // This creates a mutable bitmap
        Canvas imageCanvas = new Canvas(mImage);
    
        // Draw both rectangles
        paint.setShader(shader1);
        imageCanvas.drawRect(0, 0, 400, 800, paint);
        paint.setShader(shader2);
        imageCanvas.drawRect(400, 0, 800, 800, paint);
    
        // /Arc Image
    
        // Draw the rectangle image
        canvas.save();
        canvas.drawBitmap(mImage, 0, 0, null);
        canvas.restore();
    }
    

    As your goal is having a colored arc with rounded caps, we next need to define the area of both rectangles that should be visible to the user. This means that most of both rectangles will be masked away and thus not visible. Instead the only thing to remain is the arc area.

    The result should look like this:

    second step

    In order to achieve the needed behavior we define a mask that only reveals the arc area within the rectangles. For this we make heavy use of the setXfermode method of Paint. As argument we use different instances of a PorterDuffXfermode.

    private Paint maskPaint;
    private Paint imagePaint;
    
    // ...
    
    // To be called within all constructors
    private void init() {
        // I encourage you to research what this does in detail for a better understanding
    
        maskPaint = new Paint();
        maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
    
        imagePaint = new Paint();
        imagePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER));
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        // @step1
    
        // Mask
    
        Bitmap mMask = Bitmap.createBitmap(800, 800, conf);
        Canvas maskCanvas = new Canvas(mMask);
    
        paint.setColor(Color.WHITE);
        paint.setShader(null);
        paint.setStrokeWidth(70);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeCap(Paint.Cap.ROUND);
        paint.setAntiAlias(true);
        final RectF oval = new RectF();
        center_x = 400;
        center_y = 400;
        oval.set(center_x - radius,
                center_y - radius,
                center_x + radius,
                center_y + radius);
    
        maskCanvas.drawArc(oval, 135, 270, false, paint);
    
        // /Mask
    
        canvas.save();
        // This is new compared to step 1
        canvas.drawBitmap(mMask, 0, 0, maskPaint);
        canvas.drawBitmap(mImage, 0, 0, imagePaint); // Notice the imagePaint instead of null
        canvas.restore();
    }
    

    2. Create the overlay white thumb

    This solves your first problem. The second one can be achieved using masks again, though this time we want to achieve something different. Before, we wanted to show only a specific area (the arc) of the background image (being the two rectangles). This time we want to do the opposite: We define a background image (the thumb) and mask away its inner content, so that only the stroke seems to remain. Applied to the arc image the thumb overlays the colored arc with a transparent content area.

    So the first step would be drawing the thumb. We use an arc for this with the same radius as the background arc but different angles, resulting in a much smaller arc. But becaus the thumb should "surround" the background arc, its stroke width has to be bigger than the background arc.

    @Override
    protected void onDraw(Canvas canvas) {
        // @step1
    
        // @step2
    
        // Thumb Image
    
        mImage = Bitmap.createBitmap(800, 800, conf);
        imageCanvas = new Canvas(mImage);
    
        paint.setColor(Color.WHITE);
        paint.setStrokeWidth(120);
        final RectF oval2 = new RectF();
        center_x = 400;
        center_y = 400;
        oval2.set(center_x - radius,
                center_y - radius,
                center_x + radius,
                center_y + radius);
    
        imageCanvas.drawArc(oval2, 270, 45, false, paint);
    
        // /Thumb Image
    
        canvas.save();
        canvas.drawBitmap(RotateBitmap(mImage, 90f), 0, 0, null);
        canvas.restore();
    }
    
    public static Bitmap RotateBitmap(Bitmap source, float angle)
    {
        Matrix matrix = new Matrix();
        matrix.postRotate(angle);
        return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true);
    }
    

    The result of the code is shown below.

    third step

    So now that we have a thumb that is overlaying the background arc, we need to define the mask that removes the inner part of the thumb, so that the background arc becomes visible again.

    To achieve this we basically use the same parameters as before to create another arc, but this time the stroke width has to be identical to the width used for the background arc as this marks the area we want to remove inside the thumb.

    Using the following code, the resulting image is shown in picture 4.

    fourth step

    @Override
    protected void onDraw(Canvas canvas) {
        // @step1
    
        // @step2
    
        // Thumb Image
        // ...
        // /Thumb Image
    
        // Thumb Mask
    
        mMask = Bitmap.createBitmap(800, 800, conf);
        maskCanvas = new Canvas(mMask);
    
        paint.setColor(Color.WHITE);
        paint.setStrokeWidth(70);
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        final RectF oval3 = new RectF();
        center_x = 400;
        center_y = 400;
        oval3.set(center_x - radius,
                center_y - radius,
                center_x + radius,
                center_y + radius);
    
        maskCanvas.drawBitmap(mImage, 0, 0, null);
        maskCanvas.drawArc(oval3, 270, 45, false, paint);
    
        // /Thumb Mask
    
        canvas.save();
        canvas.drawBitmap(RotateBitmap(mMask, 90f), 0, 0, null); // Notice mImage changed to mMask
        canvas.restore();
    }
    

    3. Animate the white thumb

    The last part of your question would be animating the movement of the arc. I have no solid solution for this, but maybe can guide you in a useful direction. I would try the following:

    First define the thumb as a ImageView that is part of your whole arc graph. When changing the selected values of your graph, you rotate the thumb image around the center of the background arc. Because we want to animate the movement, just setting the rotation of the thumb image would not be adequate. Instead we use a RotateAnimation kind of like so:

    final RotateAnimation animRotate = new RotateAnimation(0.0f, -90.0f, // You have to replace these values with your calculated angles
            RotateAnimation.RELATIVE_TO_SELF, // This may be a tricky part. You probably have to change this to RELATIVE_TO_PARENT
            0.5f, // x pivot
            RotateAnimation.RELATIVE_TO_SELF,
            0.5f); // y pivot
    
    animRotate.setDuration(1500);
    animRotate.setFillAfter(true);
    animSet.addAnimation(animRotate);
    
    thumbView.startAnimation(animSet);
    

    This is far from final I guess, but it very well may aid you in your search for the needed solution. It is very important that your pivot values have to refer to the center of your background arc as this is the point your thumb image should rotate around.

    I have tested my (full) code with API Level 16 and 22, 23, so I hope that this answer at least gives you new ideas on how to solve your problems.

    Please note that allocation operations within the onDraw method are a bad idea and should be avoided. For simplicity I failed to follow this advise. Also the code is to be used as a guide in the right direction and not to be simply copy & pasted, because it makes heavy use of magic numbers and generally does not follow good coding standards.