TaskRabbit is Hiring!

We’re a tight-knit team that’s passionate about building a solution that helps people by maximizing their time, talent and skills. We are actively hiring for our Engineering and Design teams. Click To Learn more

Colin Madere

A Custom Android Progress View

@ 07 Nov 2014

android UI design


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.

Comments

Coments Loading...