Creating Custom Circle Image View for Android

Hello, this is my first post in english :smile: :clap:, todays post is about writing a custom android view, which is circle image view.

The end result of this custom view would be something like in the below figure:

fig1

If you just want to use the library go to the github repo and read the usage instructions. It is available here

This circle image view will have:

  • Border drawn around the circle
  • Highlight the circle when touched
  • The image would be scaled, cropped and centered automatically
  • Touch event only happened when we touched inside the circle

The Whole Class Codes

Before we start, I want to show you the whole code first (see the github repo also), I might not explain all line by line you could understand it better by your self, in this article i’ll explain the key things that I think important

public class CircleImageView extends ImageView {

    private static final int DEF_PRESS_HIGHLIGHT_COLOR = 0x32000000;

    private Shader mBitmapShader;
    private Matrix mShaderMatrix;

    private RectF mBitmapDrawBounds;
    private RectF mStrokeBounds;

    private Bitmap mBitmap;

    private Paint mBitmapPaint;
    private Paint mStrokePaint;
    private Paint mPressedPaint;

    private boolean mInitialized;
    private boolean mPressed;
    private boolean mHighlightEnable;

    public CircleImageView(Context context) {
        this(context, null);
    }

    public CircleImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);

        int strokeColor = Color.TRANSPARENT;
        float strokeWidth = 0;
        boolean highlightEnable = true;
        int highlightColor = DEF_PRESS_HIGHLIGHT_COLOR;

        if (attrs != null) {
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView, 0, 0);

            strokeColor = a.getColor(R.styleable.CircleImageView_strokeColor, Color.TRANSPARENT);
            strokeWidth = a.getDimensionPixelSize(R.styleable.CircleImageView_strokeWidth, 0);
            highlightEnable = a.getBoolean(R.styleable.CircleImageView_highlightEnable, true);
            highlightColor = a.getColor(R.styleable.CircleImageView_highlightColor, DEF_PRESS_HIGHLIGHT_COLOR);

            a.recycle();
        }

        mShaderMatrix = new Matrix();
        mBitmapPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mStrokeBounds = new RectF();
        mBitmapDrawBounds = new RectF();
        mStrokePaint.setColor(strokeColor);
        mStrokePaint.setStyle(Paint.Style.STROKE);
        mStrokePaint.setStrokeWidth(strokeWidth);

        mPressedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPressedPaint.setColor(highlightColor);
        mPressedPaint.setStyle(Paint.Style.FILL);

        mHighlightEnable = highlightEnable;
        mInitialized = true;

        setupBitmap();
    }

    @Override
    public void setImageResource(@DrawableRes int resId) {
        super.setImageResource(resId);
        setupBitmap();
    }

    @Override
    public void setImageDrawable(@Nullable Drawable drawable) {
        super.setImageDrawable(drawable);
        setupBitmap();
    }

    @Override
    public void setImageBitmap(@Nullable Bitmap bm) {
        super.setImageBitmap(bm);
        setupBitmap();
    }

    @Override
    public void setImageURI(@Nullable Uri uri) {
        super.setImageURI(uri);
        setupBitmap();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        float halfStrokeWidth = mStrokePaint.getStrokeWidth() / 2f;
        updateCircleDrawBounds(mBitmapDrawBounds);
        mStrokeBounds.set(mBitmapDrawBounds);
        mStrokeBounds.inset(halfStrokeWidth, halfStrokeWidth);

        updateBitmapSize();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean processed = false;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!isInCircle(event.getX(), event.getY())) {
                    return false;
                }
                processed = true;
                mPressed = true;
                invalidate();
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                processed = true;
                mPressed = false;
                invalidate();
                if (!isInCircle(event.getX(), event.getY())) {
                    return false;
                }
                break;
        }
        return super.onTouchEvent(event) || processed;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        drawBitmap(canvas);
        drawStroke(canvas);
        drawHighlight(canvas);
    }

    public boolean isHighlightEnable() {
        return mHighlightEnable;
    }

    public void setHighlightEnable(boolean enable) {
        mHighlightEnable = enable;
        invalidate();
    }

    @ColorInt
    public int getHighlightColor() {
        return mPressedPaint.getColor();
    }

    public void setHighlightColor(@ColorInt int color) {
        mPressedPaint.setColor(color);
        invalidate();
    }

    @ColorInt
    public int getStrokeColor() {
        return mStrokePaint.getColor();
    }

    public void setStrokeColor(@ColorInt int color) {
        mStrokePaint.setColor(color);
        invalidate();
    }

    @Dimension
    public float getStrokeWidth() {
        return mStrokePaint.getStrokeWidth();
    }

    public void setStrokeWidth(@Dimension float width) {
        mStrokePaint.setStrokeWidth(width);
        invalidate();
    }

    protected void drawHighlight(Canvas canvas) {
        if (mHighlightEnable && mPressed) {
            canvas.drawOval(mBitmapDrawBounds, mPressedPaint);
        }
    }

    protected void drawStroke(Canvas canvas) {
        if (mStrokePaint.getStrokeWidth() > 0f) {
            canvas.drawOval(mStrokeBounds, mStrokePaint);
        }
    }

    protected void drawBitmap(Canvas canvas) {
        canvas.drawOval(mBitmapDrawBounds, mBitmapPaint);
    }

    protected void updateCircleDrawBounds(RectF bounds) {
        float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
        float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();

        float left = getPaddingLeft();
        float top = getPaddingTop();
        if (contentWidth > contentHeight) {
            left += (contentWidth - contentHeight) / 2f;
        } else {
            top += (contentHeight - contentWidth) / 2f;
        }

        float diameter = Math.min(contentWidth, contentHeight);
        bounds.set(left, top, left + diameter, top + diameter);
    }

    private void setupBitmap() {
        if (!mInitialized) {
            return;
        }
        mBitmap = getBitmapFromDrawable(getDrawable());
        if (mBitmap == null) {
            return;
        }

        mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        mBitmapPaint.setShader(mBitmapShader);

        updateBitmapSize();
    }

    private void updateBitmapSize() {
        if (mBitmap == null) return;

        float dx;
        float dy;
        float scale;

        // scale up/down with respect to this view size and maintain aspect ratio
        // translate bitmap position with dx/dy to the center of the image
        if (mBitmap.getWidth() < mBitmap.getHeight()) {
            scale = mBitmapDrawBounds.width() / (float)mBitmap.getWidth();
            dx = mBitmapDrawBounds.left;
            dy = mBitmapDrawBounds.top - (mBitmap.getHeight() * scale / 2f) + (mBitmapDrawBounds.width() / 2f);
        } else {
            scale = mBitmapDrawBounds.height() / (float)mBitmap.getHeight();
            dx = mBitmapDrawBounds.left - (mBitmap.getWidth() * scale / 2f) + (mBitmapDrawBounds.width() / 2f);
            dy = mBitmapDrawBounds.top;
        }
        mShaderMatrix.setScale(scale, scale);
        mShaderMatrix.postTranslate(dx, dy);
        mBitmapShader.setLocalMatrix(mShaderMatrix);
    }

    private Bitmap getBitmapFromDrawable(Drawable drawable) {
        if (drawable == null) {
            return null;
        }

        if (drawable instanceof BitmapDrawable) {
            return ((BitmapDrawable) drawable).getBitmap();
        }

        Bitmap bitmap = Bitmap.createBitmap(
                drawable.getIntrinsicWidth(),
                drawable.getIntrinsicHeight(),
                Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);

        return bitmap;
    }

    private boolean isInCircle(float x, float y) {
        // find the distance between center of the view and x,y point
        double distance = Math.sqrt(
                Math.pow(mBitmapDrawBounds.centerX() - x, 2) + Math.pow(mBitmapDrawBounds.centerY() - y, 2)
        );
        return distance <= (mBitmapDrawBounds.width() / 2);
    }
}

Define custom view

Extend the ImageView class and define some fields to store the state and necessary things for drawing (rendering), also implement the required constructors. CircleImageView(Context context) constructor is the default constructor could be used when instantiating view programmatically, the second one used when we define view in the xml layout file.

public class CircleImageView extends ImageView {
	public CircleImageView(Context context) {
        this(context, null);
    }

    public CircleImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
	}
}

Define XML attributes

We need to specify what property we want to be able to customize using xml attributes, in this case we want to change stroke (border) color, set its width, enable/disable circle hightlight and specify the highlight color.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleImageView">
        <attr name="strokeColor" format="color"/>
        <attr name="strokeWidth" format="dimension"/>
        <attr name="highlightEnable" format="boolean"/>
        <attr name="highlightColor" format="color"/>
    </declare-styleable>
</resources>

Define fields

Declare member fields that we will be working with globally in the class

public class CircleImageView extends ImageView {

	private static final int DEF_PRESS_HIGHLIGHT_COLOR = 0x32000000;

	private Shader mBitmapShader;
    private Matrix mShaderMatrix;

    private RectF mBitmapDrawBounds;
    private RectF mStrokeBounds;

    private Bitmap mBitmap;

    private Paint mBitmapPaint;
    private Paint mStrokePaint;
    private Paint mPressedPaint;

    private boolean mInitialized;
    private boolean mPressed;
    private boolean mHighlightEnable;

    // ...
}

Initialize fields

Initialize all fields we have defined above in the second contructor, when we specify this view attributes in xml layout file this constructor would be called with the AttributeSet parameter containing those xml attributes. First check if the AttributeSet is not null, if true then query all defined attributes.

public class CircleImageView extends ImageView {

    // ...
    // ...

    public CircleImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);

        int strokeColor = Color.TRANSPARENT;
        float strokeWidth = 0;
        boolean highlightEnable = true;
        int highlightColor = DEF_PRESS_HIGHLIGHT_COLOR;

        if (attrs != null) {
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleImageView, 0, 0);

            strokeColor = a.getColor(R.styleable.CircleImageView_strokeColor, Color.TRANSPARENT);
            strokeWidth = a.getDimensionPixelSize(R.styleable.CircleImageView_strokeWidth, 0);
            highlightEnable = a.getBoolean(R.styleable.CircleImageView_highlightEnable, true);
            highlightColor = a.getColor(R.styleable.CircleImageView_highlightColor, DEF_PRESS_HIGHLIGHT_COLOR);

            a.recycle();
        }

        mShaderMatrix = new Matrix();
        mBitmapPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mStrokePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mStrokeBounds = new RectF();
        mBitmapDrawBounds = new RectF();
        mStrokePaint.setColor(strokeColor);
        mStrokePaint.setStyle(Paint.Style.STROKE);
        mStrokePaint.setStrokeWidth(strokeWidth);

        mPressedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPressedPaint.setColor(highlightColor);
        mPressedPaint.setStyle(Paint.Style.FILL);

        mHighlightEnable = highlightEnable;
        mInitialized = true;

        setupBitmap();
	}
}

I’ll implement/explain setupBitmap() later

we want to initialize/prepare bitmap when it was set before using xml attribute of the ImageView by calling setupBitmap(). we call super(context, attrs) above so bitmap could already been made available by ImageView when we call setupBitmap().

Preparing bitmap

Before drawing (rendering) it needs to prepare the bitmap and get ready to draw. getBitmapFromDrawable(Drawable drawable) method is responsible for converting the drawable into a Bitmap. If the drawable is instance of BitmapDrawable cast it to Bitmap an return, if not we create bitmap ourselves and copy the drawable image content by using Canvas with the bitmap to draw into, finally draw drawable using drawable.draw(canvas) then return the bitmap.

public class CircleImageView extends ImageView {

    // ...
    // ...

    private Bitmap getBitmapFromDrawable(Drawable drawable) {
        if (drawable == null) {
            return null;
        }

        if (drawable instanceof BitmapDrawable) {
            return ((BitmapDrawable) drawable).getBitmap();
        }

        Bitmap bitmap = Bitmap.createBitmap(
                drawable.getIntrinsicWidth(),
                drawable.getIntrinsicHeight(),
                Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
        drawable.draw(canvas);

        return bitmap;
    }
}

Implement methods to update the rectangle bounds where the circle would be drawn. updateCircleDrawBounds(RectF bounds) and call it in onSizeChanged() (we’ll update bounds when size of this view changed)

public class CircleImageView extends ImageView {

    // ...
    // ...

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        // ...

        updateCircleDrawBounds(mBitmapDrawBounds);

        // ...
    }

    protected void updateCircleDrawBounds(RectF bounds) {
        float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
        float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();

        float left = getPaddingLeft();
        float top = getPaddingTop();

        // we'll center bounds by translating left/top
        // so that the rendered circle always in the center of view
        if (contentWidth > contentHeight) {
            left += (contentWidth - contentHeight) / 2f;
        } else {
            top += (contentHeight - contentWidth) / 2f;
        }

		// we want to make this bounds always square (aspect ratio of 1:1)
        float diameter = Math.min(contentWidth, contentHeight);
        bounds.set(left, top, left + diameter, top + diameter);
    }
}

Next,setupBitmap() method is used to setup the bitmap so it’s ready to draw. First check if it’s initialized or not. Use function we have implemeted above getBitmapFromDrawable(getDrawable()) to get the bitmap with getDrawable() to get image view drawable. We used BitmapShader, Shader is class that represent and return horizontal spans of color during drawing using a Paint object.BitmapShader is used to draw a texture (bitmap) on the area defined by the Canvas draw command using a Paint that BitmapShader is assign to. Shader.TileMode.CLAMP Arguments in the BitmapShader constructor is tile mode and clamp means that we’d replicate/draw the edge color of the texture when it’s smaller than the area of the canvas that we can draw on.

public class CircleImageView extends ImageView {

    // ...
    // ...

    private void setupBitmap() {
        if (!mInitialized) {
            return;
        }
        mBitmap = getBitmapFromDrawable(getDrawable());
        if (mBitmap == null) {
            return;
        }

        mBitmapShader = new BitmapShader(mBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
        mBitmapPaint.setShader(mBitmapShader);

        updateBitmapSize();
    }
}

Notice we use updateBitmapSize() method, let’s implement it

This method is used to resize (scale up/down) the bitmap in the BitmapShader based on the size of view while maintaining aspect ratio of bitmap. We use Matrix to transform (scale and translation) bitmap. This will a little bit more involved.

More explanation is in the code

public class CircleImageView extends ImageView {

    // ...
    // ...

    private void updateBitmapSize() {
        if (mBitmap == null) return;

		// trainslate bitmap in the BitmapShader using dx and dy so that it's centered
        float dx;
        float dy;

        // scale factor
        float scale;

        // scale up/down with respect to this view size and maintain aspect ratio
        // translate bitmap position with dx/dy to the center of the image
        //
        // check do we want to scale based on width or height
        if (mBitmap.getWidth() < mBitmap.getHeight()) {
        	// if bitmp with is less than its height, we wanna scale based on its width
            // assign scale factor based on difference (ratio) between bitmap width and bitmap draw bounds
            scale = mBitmapDrawBounds.width() / (float)mBitmap.getWidth();
            // because we know that the scale was based on width, the width would fit
            // exaclty with bounds, so we just translate x with its left padding
            dx = mBitmapDrawBounds.left;
            // we want to center y(height) axis of the scaled bitmap,
            // the logial way to see this is:
            // at the first state bitmap would rendered at the top left area
            // by translating with top padding of the view,
            // translate up by half of bitmap height (so center of bitmap now in the top of the view),
            // translate down by half of the bitmap bounds (so center of bitmap would be in the center of the view (bitmap bounds))
            dy = mBitmapDrawBounds.top - (mBitmap.getHeight() * scale / 2f) + (mBitmapDrawBounds.width() / 2f);
        } else {
        	// the same concept goes the same here, the difference is we
            // translate (center) horizontal axis instead of vertical/y axis
            scale = mBitmapDrawBounds.height() / (float)mBitmap.getHeight();
            dx = mBitmapDrawBounds.left - (mBitmap.getWidth() * scale / 2f) + (mBitmapDrawBounds.width() / 2f);
            dy = mBitmapDrawBounds.top;
        }

        // apply this transformation into shader matrix -> bitmap shader
        mShaderMatrix.setScale(scale, scale);
        mShaderMatrix.postTranslate(dx, dy);
        mBitmapShader.setLocalMatrix(mShaderMatrix);
    }
}

Drawing

At this point so far we are ready to do some drawing :). The code is straighforward, so let’s do that

public class CircleImageView extends ImageView {

    // ...

	@Override
    protected void onDraw(Canvas canvas) {
        drawBitmap(canvas);
        drawStroke(canvas);
        drawHighlight(canvas);
    }

    // ...

	protected void drawHighlight(Canvas canvas) {
        if (mHighlightEnable && mPressed) {
            canvas.drawOval(mBitmapDrawBounds, mPressedPaint);
        }
    }

    protected void drawStroke(Canvas canvas) {
        if (mStrokePaint.getStrokeWidth() > 0f) {
            canvas.drawOval(mStrokeBounds, mStrokePaint);
        }
    }

    protected void drawBitmap(Canvas canvas) {
    	// we draw an oval shape using draw bounds that we have set to always square and it would draw a circle in it
        // also the bitmap paint is set with the bitmap shader so the color
        // of the shape is the bitmap itself
        canvas.drawOval(mBitmapDrawBounds, mBitmapPaint);
    }

    // ...
}

Override onDraw() method, it’ll be called automatically by the framework when we call invalidate() or when it needs to be redrawn.

Touch event

Override onTouchEvent() method to receive events when view is touched.

public class CircleImageView extends ImageView {

    // ...

	@Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean processed = false;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!isInCircle(event.getX(), event.getY())) {
                    return false;
                }
                processed = true;
                mPressed = true;
                invalidate();
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                processed = true;
                mPressed = false;
                invalidate();
                if (!isInCircle(event.getX(), event.getY())) {
                    return false;
                }
                break;
        }
        return super.onTouchEvent(event) || processed;
    }

	// ...

    private boolean isInCircle(float x, float y) {
        // find the distance between center of the view and x,y point
        double distance = Math.sqrt(
                Math.pow(mBitmapDrawBounds.centerX() - x, 2) + Math.pow(mBitmapDrawBounds.centerY() - y, 2)
        );
        return distance <= (mBitmapDrawBounds.width() / 2);
    }
}

isInCircle() method is used to check if the touch coordinate x,y inside the circle view (bitmap) that we have drawn, in other words it’ll emit touch event when someone touch inside the circle not anywhere on the view.

PREVIOUSAndroid Custom View Tutorial: Scroll Parallax Image View
NEXTHow Asset/Vehicle Tracking App Works? - In a Nutshell