In our effort to bring transparency to Taskers about their performance metrics, Design came up with visuals that included a circular progress indicator.

First Pass
At first, seemed like a no-brainer to use a custom Drawable with the stock ProgressBar widget to make it round. Design was still iterating, but this seemed like a safe bet.
<ProgressBar
android:id="@+id/performance_progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_centerInParent="true"
android:indeterminate="false"
android:max="100"
android:progressDrawable="@drawable/progress_bar_circular" />
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@android:id/background">
<shape
android:innerRadiusRatio="3"
android:shape="ring"
android:useLevel="false"
android:thickness="5dp">
<solid android:color="@color/green_bg"/>
</shape>
</item>
<item android:id="@android:id/progress">
<shape
android:innerRadiusRatio="3"
android:shape="ring"
android:useLevel="true"
android:thickness="5dp">
<solid android:color="@color/green"/>
</shape>
</item>
</layer-list>
However, this defaults to the start/end of the ProgressBar
being the right side of the circle. So to deal with that I rotated the view using the base View’s method:
View.setRotation(-90f)
This moved the start/end where Design wanted it to be.
Snag #1
It was decided that a counter-clockwise meter made more sense. After exploring a few suggestions found on StackOverflow regarding being able to change the clipping direction of drawables, none of which I had success with for the ‘ring’ shape, a little hack came to mind: “Rotate the view so that the start of the drawing was at our chosen ‘end’, making it appear reversed!”
meter.setRotation((-progress / 100f * 360f) - 90f);
I felt clever.
Snag #2
As the view evolved, it became apparent that a secondary value was needed to show the “target” value. This would provide users a quick visual cue of where they wanted to be for each metric. At first, I thought, “Well, no big deal, I’ll use the built-in ‘secondary’ progress that the stock class has. Done.” I quickly realized that given the aforementioned rotation to make it look counter-clockwise, the secondary progress would not show up in the right spot, so I was leaning towards a custom view.
Then design came up with a cool little tic-mark along the circle to show where ‘target’ value was:

This was definitely visually superior to a secondary progress bar, particularly when you were past the mark. So now I was definitely looking at a custom View.
Base Solution
So the base solution for drawing the appropriate Arcs is below.
public class ProgressArcView extends View {
...
private void init(Context ctx) {
mArcPaintBackground = new Paint() {
{
setDither(true);
setStyle(Paint.Style.STROKE);
setStrokeCap(Cap.BUTT);
setStrokeJoin(Join.BEVEL);
setColor(backgroundColor);
setStrokeWidth(backgroundWidthPixels);
setAntiAlias(true);
}
};
mArcPaintPrimary = new Paint() {
{
setDither(true);
setStyle(Paint.Style.STROKE);
setStrokeCap(Cap.BUTT);
setStrokeJoin(Join.BEVEL);
setColor(primaryColor);
setStrokeWidth(primaryWidthPixels);
setAntiAlias(true);
}
};
}
public void setProgress(int progress) {
mProgress = progress;
invalidate();
requestLayout();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// bound our drawable arc to stay fully within our canvas
RectF rect = new RectF(0, 0, canvas.getWidth(), canvas.getHeight());
// background full circle arc
canvas.drawArc(rect, 270, 360, false, mArcPaintBackground);
// draw starting at top of circle in the negative (counter-clockwise) direction
canvas.drawArc(rect, 270, -(360 * (mProgress / 100f)), false, mArcPaintPrimary);
}
...
}
Final Solution
The final solution includes drawing the “target” marker in the right location and orientation to the circle (math provided by my iOS coworker Jim doing a similar job across the yard). Also taking care to pad the drawing to make sure the “target” marker stayed within the canvas.
public class ProgressArcView extends View {
private static double M_PI_2 = Math.PI/2;
...
private void init(Context ctx) {
mContext = ctx;
Resources res = ctx.getResources();
float density = res.getDisplayMetrics().density;
mBackgroundColor = res.getColor(android.R.color.darker_gray);
mBackgroundWidth = (int)(8 * density); // default to 8dp
mPrimaryColor = res.getColor(android.R.color.holo_orange_dark);
mPrimaryWidth = (int)(8 * density); // default to 8dp
mTargetColor = res.getColor(android.R.color.holo_orange_light);
mTargetLength = (int)(mPrimaryWidth * 1.50); // 20% longer than arc line width
mArcPaintBackground = new Paint() {
{
setDither(true);
setStyle(Style.STROKE);
setStrokeCap(Cap.BUTT);
setStrokeJoin(Join.BEVEL);
setAntiAlias(true);
}
};
mArcPaintBackground.setColor(mBackgroundColor);
mArcPaintBackground.setStrokeWidth(mBackgroundWidth);
mArcPaintPrimary = new Paint() {
{
setDither(true);
setStyle(Style.STROKE);
setStrokeCap(Cap.BUTT);
setStrokeJoin(Join.BEVEL);
setAntiAlias(true);
}
};
mArcPaintPrimary.setColor(mPrimaryColor);
mArcPaintPrimary.setStrokeWidth(mPrimaryWidth);
mTargetMarkPaint = new Paint() {
{
setDither(true);
setStyle(Style.STROKE);
setStrokeCap(Cap.BUTT);
setStrokeJoin(Join.BEVEL);
setAntiAlias(true);
}
};
mTargetMarkPaint.setColor(mTargetColor);
// make target tick mark 1/3 width of progress(primary) arc width
mTargetMarkPaint.setStrokeWidth(mPrimaryWidth / 3);
// get widest drawn element to properly pad the rect we draw inside
float maxW = (mTargetLength >= mBackgroundWidth) ? mTargetLength : mBackgroundWidth;
// arc is drawn with it's stroke center at the rect size provided, so we have to pad
// it by half to bring it inside our bounding rect
mPadding = maxW / 2;
mProgress = 0;
}
public void setProgress(int progress) {
mProgress = progress;
invalidate();
requestLayout();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// full circle (start at 270, the "top")
canvas.drawArc(mDrawingRect, 270, 360, false, mArcPaintBackground);
// draw starting at top of circle in the negative (counter-clockwise) direction
canvas.drawArc(mDrawingRect, 270 ,-(360*(mProgress/100f)), false, mArcPaintPrimary);
// draw target mark along, but perpendicular to the arc's line
float radius = mDrawingRect.width() <= mDrawingRect.height()
? mDrawingRect.width()/2 : mDrawingRect.height()/2;
// Shift cos/sin by -90 deg (M_PI_2) to put start at 0 (top) and is in radians
float circleX = mDrawingRect.centerX() + radius *
(float)Math.cos(Math.PI * 2 * - mTarget/100f - M_PI_2);
float circleY = mDrawingRect.centerY() + radius *
(float)Math.sin(Math.PI * 2 * - mTarget/100f - M_PI_2);
float slope = circleX - mDrawingRect.centerX() == 0 ? 999999
: (circleY - mDrawingRect.centerY())/(circleX - mDrawingRect.centerX());
float projectedX = (float)((mTargetLength/2.0)/Math.sqrt(1 + Math.pow(slope, 2.0)));
float projectedY = (float)(((mTargetLength/2.0)*slope)
/Math.sqrt(1 + Math.pow(slope, 2.0)));
canvas.drawLine(circleX - projectedX,
circleY - projectedY,
circleX + projectedX,
circleY + projectedY,
mTargetMarkPaint);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMeasuredWidth();
int height = getMeasuredHeight();
if (0 == height) height = width; // 0 vertical space, make it square
if (0 == width) width = height; // 0 horizontal space, make it square
int widthWithoutPadding = width - getPaddingLeft() - getPaddingRight();
int heigthWithoutPadding = height - getPaddingTop() - getPaddingBottom();
// set the dimensions
int size = 0;
if (widthWithoutPadding > heigthWithoutPadding) {
size = heigthWithoutPadding;
} else {
size = widthWithoutPadding;
}
setMeasuredDimension(size + getPaddingLeft() + getPaddingRight(),
size + getPaddingTop() + getPaddingBottom());
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// bound our drawable arc to stay fully within our canvas
mDrawingRect = new RectF(mPadding + getPaddingLeft(),
mPadding + getPaddingTop(),
w - mSize - mPadding - getPaddingRight(),
h - mSize - mPadding - getPaddingBottom());
}
...
}
You may note the onMeasure()
is using a little hack to make the View square if we’re in a layout that isn’t specifying any space in one of the dimensions. Design really wanted the progress indicator to be 1/2 the screen width, so to accommodate this simply and be able to use LinearLayout’s weighting system, I chose to use the width’s measurement to decide the height, since using weights this way and specifying "wrap_content"
for the height caused the measured height to be 0
.
There’s more to be done here to make this View more universally usable and behave in all Layout scenarios, but this is a good start.