Home » Android » android – Draw bubble programmatically

android – Draw bubble programmatically

Posted by: admin June 15, 2020 Leave a comment

Questions:

I would like to have a bubble with a precentage value in my app, I can’t use 9 patches as i want it to be customizable and its background color should be changeble.
It should look something like this
enter image description here

How can I do it? This bubble will have views inflated inside of it, like this percentage or some larger layouts.
Also depending on the layout(phone or tablet) it might have one side larger than the other (arrow not at the center) so that’s another reason i prefer doing it programmatically

How to&Answers:

Create a custom Drawable and use it for the background of whatever container you are putting your text or other views into.
You will need to modify the padding of the background to take the bubble’s pointer into account.
The code below allows you to set the alignment of the pointer as LEFT, CENTER or RIGHT.
This is just a basic version to give you an idea. You could easily add a setter for the bubble color, or add stroke properties to ‘mPaint’ for additional flexibility.

public class BubbleDrawable extends Drawable {

    // Public Class Constants
    ////////////////////////////////////////////////////////////

    public static final int LEFT = 0;
    public static final int CENTER = 1;
    public static final int RIGHT = 2;

    // Private Instance Variables
    ////////////////////////////////////////////////////////////

    private Paint mPaint;
    private int mColor;

    private RectF mBoxRect;
    private int mBoxWidth;
    private int mBoxHeight;
    private float mCornerRad;
    private Rect mBoxPadding = new Rect();

    private Path mPointer;
    private int mPointerWidth;
    private int mPointerHeight;
    private int mPointerAlignment;

    // Constructors
    ////////////////////////////////////////////////////////////

    public BubbleDrawable(int pointerAlignment) {
        setPointerAlignment(pointerAlignment);
        initBubble();
    }

    // Setters
    ////////////////////////////////////////////////////////////

    public void setPadding(int left, int top, int right, int bottom) {
        mBoxPadding.left = left;
        mBoxPadding.top = top;
        mBoxPadding.right = right;
        mBoxPadding.bottom = bottom;
    }

    public void setCornerRadius(float cornerRad) {
        mCornerRad = cornerRad;
    }

    public void setPointerAlignment(int pointerAlignment) {
        if (pointerAlignment < 0 || pointerAlignment > 3) {
            Log.e("BubbleDrawable", "Invalid pointerAlignment argument");
        } else {
            mPointerAlignment = pointerAlignment;
        }
    }

    public void setPointerWidth(int pointerWidth) {
        mPointerWidth = pointerWidth;
    }

    public void setPointerHeight(int pointerHeight) {
        mPointerHeight = pointerHeight;
    }

    // Private Methods
    ////////////////////////////////////////////////////////////

    private void initBubble() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mColor = Color.RED;
        mPaint.setColor(mColor);
        mCornerRad = 0;
        setPointerWidth(40);
        setPointerHeight(40);
    }

    private void updatePointerPath() {
        mPointer = new Path();
        mPointer.setFillType(Path.FillType.EVEN_ODD);

        // Set the starting point
        mPointer.moveTo(pointerHorizontalStart(), mBoxHeight);

        // Define the lines
        mPointer.rLineTo(mPointerWidth, 0);
        mPointer.rLineTo(-(mPointerWidth / 2), mPointerHeight);
        mPointer.rLineTo(-(mPointerWidth / 2), -mPointerHeight);
        mPointer.close();
    }

    private float pointerHorizontalStart() {
        float x = 0;
        switch (mPointerAlignment) {
        case LEFT:
            x = mCornerRad;
            break;
        case CENTER:
            x = (mBoxWidth / 2) - (mPointerWidth / 2);
            break;
        case RIGHT:
            x = mBoxWidth - mCornerRad - mPointerWidth;
        }
        return x;
    }

    // Superclass Override Methods
    ////////////////////////////////////////////////////////////

    @Override
    public void draw(Canvas canvas) {
        mBoxRect = new RectF(0.0f, 0.0f, mBoxWidth, mBoxHeight);
        canvas.drawRoundRect(mBoxRect, mCornerRad, mCornerRad, mPaint);
        updatePointerPath();
        canvas.drawPath(mPointer, mPaint);
    }

    @Override
    public int getOpacity() {
        return 255;
    }

    @Override
    public void setAlpha(int alpha) {
        // TODO Auto-generated method stub

    }

    @Override
    public void setColorFilter(ColorFilter cf) {
        // TODO Auto-generated method stub

    }

    @Override
    public boolean getPadding(Rect padding) {
        padding.set(mBoxPadding);

        // Adjust the padding to include the height of the pointer
        padding.bottom += mPointerHeight;
        return true;
    }

    @Override
    protected void onBoundsChange(Rect bounds) {
        mBoxWidth = bounds.width();
        mBoxHeight = getBounds().height() - mPointerHeight;
        super.onBoundsChange(bounds);
    }
}

Usage
The example below shows how you might use BubbleDrawable.

MainActivity.java

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        LinearLayout linearLayout = (LinearLayout)findViewById(R.id.myLayout);
        BubbleDrawable myBubble = new BubbleDrawable(BubbleDrawable.CENTER);
        myBubble.setCornerRadius(20);
        myBubble.setPointerAlignment(BubbleDrawable.RIGHT);
        myBubble.setPadding(25, 25, 25, 25);
        linearLayout.setBackgroundDrawable(myBubble);
    }
}

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context=".MainActivity" >

    <LinearLayout
        android:id="@+id/myLayout"
        android:orientation="vertical"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"
        android:layout_alignParentTop="true" >

        <TextView
            android:id="@+id/textView1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Some Text"
            android:textAppearance="?android:attr/textAppearanceLarge" />

        <TextView
            android:id="@+id/textView2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Some Other Text"
            android:textAppearance="?android:attr/textAppearanceLarge" />
        </LinearLayout>

</RelativeLayout>

Answer:

Obviously it’s never a good idea to have code in your app that you don’t understand, so I won’t just write out a bunch of equations in java code for you. If however you follow and understand the maths below, then it will be a relatively simple matter for you to use the described equations in your code and draw the arc.


To achieve a rounded tip on the pointer you will need to modify updatePointerPath().
At the moment it just uses rLineTo() to draw three lines forming a triangle.
There is another method in the android Path class called arcTo() which takes the form:

    arcTo(RectF oval, float startAngle, float sweepAngle)

You can use this method to draw your arc at the tip of the pointer, but you need to work out a few things first.

You can already calculate the coordinates of the three corners of the pointer triangle. This is what updatePointerPath() does already. Now take a look at the diagram below. To use arcTo(), you will need to calculate the following:

  1. The coordinates of point T which is where your arc will start.
  2. The coordinates of the top left and bottom right corners of the bounding RectF
  3. Your starting angle (Alpha)
  4. Your sweep angle (2 * Phi)

Diagram 2

The start angle Alpha can easily be found with basic trig as shown in the diagram below.

Diagram 1

Note: It will be best if you stick to using Radians instead of Degrees for all the angles since this is what all the trig functions in the android ‘Math’ class require.
With this in mind:

  • There are 2Pi Radians in a circle
  • The three angles in a triangle add up to Pi Radians
  • A right angle is Pi/2 Radians

So adding the three angles in the triangle formed by points C, T and P you get:

Alpha + Phi + Pi/2 = Pi

Therefore

Phi = Pi/2 – Alpha

So we have now calculated Alpha and Phi.

Next, d is the distance between the point P and the bottom of the bounding box.
You can get it by calculating the distance from point C to point P, and then subtracting the radius r.
Now:

sin(Alpha) = r / (distance from C to P)

Therefore:

distance from C to P = r / sin(Alpha)

And so given that the distance d is the distance from point C to point P minus the radius r, we get:

d = (r / sin(Alpha)) – r

That gives you all the info you need to calculate the coordinates of the top left and bottom right corners of the bounding RectF.

Now all that’s left is to work out the coordinates of point T.

First work out the distance from P to T.
Given that:

tan(Alpha) = r / (distance from P to T)

We get:

distance from P to T = r / tan(Alpha)

Finally, adding one more point to the diagram….

Diagram 3

We can see that:

sin(Alpha) = (distance from P to A) / (distance from P to T)

So:

distance from P to A = (distance from P to T) * sin(Alpha)

Similarly:

cos(Alpha) = (distance from T to A) / (distance from P to T)

So:

distance from T to A = (distance from P to T) * cos(Alpha)

With this info you can now calculate the coordinates of point T !!

If you understood all that, then the coding from here is easy. If you’re unsure of anything, then just ask.


The following gives an idea how the updated updatePointerPath() might look.

private void updatePointerPath() {
    float xDistance;
    float yDistance;
    mPointer = new Path();
    mPointer.setFillType(Path.FillType.EVEN_ODD);

    // Set the starting point (top left corner of the pointer)
    mPointer.moveTo(pointerHorizontalStart(), mBoxHeight);

    // Define the lines

    // First draw a line to the top right corner
    xDistance= mPointerWidth;
    yDistance= 0;
    mPointer.rLineTo(xDistance, yDistance);

    // Next draw a line down to point T
    xDistance= (mPointerWidth / 2) - distancePtoA;
    yDistance= mPointerHeight - distanceTtoA;
    mPointer.rLineTo(-xDistance, yDistance); //Note: Negative sign because we are moving back to the left

    // Next draw the arc
    // Note: The RectF used in arcTo() is defined in absolute screen coordinates
    float boundingBoxLeft = pointerHorizontalStart() + (mPointerWidth / 2) - (2 * radius);
    float boundingBoxTop = mBoxHeight - distanceD - (2 * radius);
    float boundingBoxRight = boundingBoxLeft + (2 * radius);
    float boundingBoxBottom = boundingBoxTop + (2 * radius);

    RectF boundingBox = new RectF(boundingBoxLeft, boundingBoxTop, boundingBoxRight, boundingBoxBottom);

    // Note: While the methods in the android Math class like sin() and asin() all use Radians,
    // for some reason it was decided that arcTo() in the Path class would use Degrees,
    // so we have to convert the angles
    float startAngleInDegrees = angleAlpha * (180 / Math.PI);
    float sweepAngleInDegrees = 2 * anglePhi * (180 / Math.PI);

    mPointer.arcTo(boundingBox, startAngleInDegrees, sweepAngleInDegrees);

    // Finally draw the line from the end of the arc back up to the top left corner
    // Note: The distances are the same as from the top right corner to point T,
    // just the direction is different.
    mPointer.rLineTo(-xDistance, -yDistance); // Note: Negative in front of yDistance because we are moving up

    // Close off the path  
    mPointer.close();
}