-
Notifications
You must be signed in to change notification settings - Fork 6.3k
Progress Bar Custom View
We're going to create our own implementation of a determinate progress bar. The progress bar will have an indicator that represents a goal. We will define custom attributes, handle measuring and drawing, and animations.
Clone this repository, and import the project in Android Studio. Build and run the app to see the following screen:
The skeleton app contains a GoalProgressBar
class which extends Android's ProgressBar, but doesn't add any additional functionality. Clicking on the Reset Progress
button will update the progress to a new value, which will help us test our changes during development.
We won't need to extend android's ProgressBar
as we will be drawing the progress manually. Change GoalProgressBar
to extend View
instead of ProgressBar
, and remove the style
and android:indeterminite
attributes from our custom view's definition in activity_main.xml
.
In order to draw the progress we'll need to use a Paint object. Define a new paint object that is initialized after view creation:
private Paint progressPaint;
public GoalProgressBar(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
progressPaint = new Paint();
progressPaint.setStyle(Paint.Style.FILL_AND_STROKE);
}
It's important that the Paint
is only created once, as reinitializing the object each time it is used in drawing would affect performance.
Our progress bar will have an indicator for a given 'goal'. The goal indicator can appear at any given position on the progress bar, and will have an adjustable size.
Create public setters for an int progress
, as well as an int goal
. When either of these methods are called, we'll update a boolean isGoalReached
method that we'll use to determine which color to draw the progress bar.
public void setProgress(int progress) {
this.progress = progress;
updateGoalReached();
invalidate();
}
public void setGoal(int goal) {
this.goal = goal;
updateGoalReached();
invalidate();
}
private void updateGoalReached() {
isGoalReached = progress >= goal;
}
We're going to expose customizable fields to allow different components in our progress bar. To start out, we'll define member variables for each customizable attribute:
// height of the goal indicator
private float goalIndicatorHeight;
// thickness of the goal indicator
private float goalIndicatorThickness;
// bar color when the goal has been reached
private int goalReachedColor;
// bar color when the goal has not been reached
private int goalNotReachedColor;
// bar color for the unfilled section (remaining progress)
private int unfilledSectionColor;
// thickness of the progress bar
private float barThickness;
In order to be able to set these variables from Java code, create setters for these member variables. When any of these setters are called, be sure to call postInvalidate();
after updating the field so the view is redrawn.
In order to set these variables from XML, we first have to declare a styleable resource in res/values/attrs.xml
:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="GoalProgressBar">
<attr name="goalIndicatorHeight" format="dimension"/>
<attr name="goalIndicatorThickness" format="dimension"/>
<attr name="goalReachedColor" format="color"/>
<attr name="goalNotReachedColor" format="color"/>
<attr name="unfilledSectionColor" format="color"/>
<attr name="barThickness" format="dimension"/>
</declare-styleable>
</resources>
The variable names and defined <attr
names do not have to be the same, but are doing so here for the sake of consistency.
Next we'll add these new attributes to our existing GoalProgressBar
in activity_main.xml
. Before we can start defining custom attributes, we need to add an additional XML namespace on the root element:
xmlns:app="http://schemas.android.com/apk/res-auto"
Once the new app
namespace is defined (we can call it anything, it doesn't have to be app
) we can start adding these attributes to our GoalProgressBar
:
<com.codepath.customprogressbar.GoalProgressBar
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:goalReachedColor="@color/green"
app:goalNotReachedColor="@color/dark_gray"
app:unfilledSectionColor="@color/gray"
app:barThickness="4dp"
app:goalIndicatorHeight="16dp"
app:goalIndicatorThickness="4dp"/>
Modify init
to include an AttributeSet as a parameter. It is from this object that we'll extract the attribute values.
To extract the attributes from the AttributeSet
, we'll use a TypedArray. A TypedArray
is basically a container to hold onto defined attributes. This object must be recycled once we're done using it, so we'll interact with it in a try
block so we can be sure to call recycle()
on it once we're finished:
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.GoalProgressBar, 0, 0);
try {
// extract attributes to member variables from typedArray
} finally {
typedArray.recycle();
}
Extract each of our defined attributes from the typed array by providing the styleable
ID to the typedArray
. Be sure to set each member variable via it's setter for consistent behavior:
setGoalReachedColor(typedArray.getColor(R.styleable.GoalProgressBar_goalReachedColor, Color.BLUE));
Now that we have the framework in place for setting the required fields, we can implement the measuring logic. To do this we will override onMeasure
, so we can tell our parent view what size we'd like the view to be.
For the width of the view, we can use whatever width imposed by our parent (provided in widthMeasureSpec
), but we'll add some custom handling for determining our height.
Since the tallest component of our view will be the goal indicator, we'll use it's height to determine the height of the entire view:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// set width
int width = MeasureSpec.getSize(widthMeasureSpec);
// set height
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int height;
switch (MeasureSpec.getMode(heightMeasureSpec)) {
case MeasureSpec.EXACTLY:
// we must be exactly the given size
height = heightSize;
break;
case MeasureSpec.AT_MOST:
// we can not be bigger than the specified height
height = (int) Math.min(goalIndicatorHeight, heightSize);
break;
default:
// we can be whatever height want
height = (int) goalIndicatorHeight;
break;
}
setMeasuredDimension(width, height);
}
This will properly handle the sizing of our view, even when we allow a customizable sized goal indicator.
Now our view has set it's appropriate height and width, we're ready to draw the content of the view to the screen. We do this by overriding onDraw.
onDraw
provides a Canvas
onto which we can use our Paint
to draw. We'll start with three different components to draw, so we'll have three separate calls to draw onto the Canvas
.
We need to draw the section of the progress bar that is 'filled' - so if the maximum is set to 100, and progress
is set to 70, we'll the filled section will be drawn to 7/10 the width. The color of this section will depend on whether or not progress
meets/exceeds our goal
.
We'll also have to draw the remaining/unfilled section of the progress bar, which in the case of 70% progress would be 3/10 of the width, starting where the filled section left off.
We also have to draw the 'goal' indicator, which will be at a position defined by the user.
@Override
protected void onDraw(Canvas canvas) {
int halfHeight = getHeight() / 2;
int progressEndX = (int) (getWidth() * progress / 100f);
// draw the part of the bar that's filled
progressPaint.setStrokeWidth(barThickness);
progressPaint.setColor(isGoalReached ? goalReachedColor : goalNotReachedColor);
canvas.drawLine(0, halfHeight, progressEndX, halfHeight, progressPaint);
// draw the unfilled section
progressPaint.setColor(unfilledSectionColor);
canvas.drawLine(progressEndX, halfHeight, getWidth(), halfHeight, progressPaint);
// draw the goal indicator
float indicatorPosition = getWidth() * goal / 100f;
progressPaint.setColor(goalReachedColor);
progressPaint.setStrokeWidth(goalIndicatorThickness);
canvas.drawLine(indicatorPosition, halfHeight - goalIndicatorHeight / 2,
indicatorPosition, halfHeight + goalIndicatorHeight / 2, progressPaint);
}
Next we're going to create some different options for the goal indicator. We can allow users to choose between different shapes to use as the goal indicator. We'll do this by creating a new XML attribute that has a predefined set of values, to make the API as user friendly as possible:
We'll need to add a new attr
to our existing declared styleable in attrs.xml
, this time it will be of type enum
:
<attr name="indicatorType" format="enum">
<enum name="line" value="0"/>
<enum name="circle" value="1"/>
<enum name="square" value="2"/>
</attr>
Create an enum
in GoalProgressBar
with the same values as we defined in the new styleable:
public enum IndicatorType {
Line, Circle, Square
}
Add a member variable of this new enum, and allow it to be set via a public setter. Use this setter in extracting the attribute from our typedArray
:
setIndicatorType(IndicatorType.values()[typedArray.getInt(R.styleable.GoalProgressBar_indicatorType, IndicatorType.Line.ordinal())]);
Now we'll need to add logic to onDraw
to draw the differently styled indicator. Use a switch
on the indicatorType
to determine how to draw the goal indicator:
// draw the goal indicator
float indicatorPosition = getIndicatorPosition();
progressPaint.setColor(goalReachedColor);
progressPaint.setStrokeWidth(goalIndicatorThickness);
switch (indicatorType) {
case Line:
canvas.drawLine(indicatorPosition,
halfHeight - goalIndicatorHeight / 2,
indicatorPosition,
halfHeight + goalIndicatorHeight / 2,
progressPaint);
break;
case Circle:
canvas.drawCircle(indicatorPosition,
goalIndicatorHeight / 2,
goalIndicatorHeight / 2,
progressPaint);
break;
case Square:
canvas.drawRect(indicatorPosition - (goalIndicatorHeight / 2),
0,
indicatorPosition + (goalIndicatorHeight / 2),
goalIndicatorHeight,
progressPaint);
break;
}
In order to maintain state across screen rotation, we'll need to handle saving and restoring instance state. To do so we'll override onSaveInstanceState and onRestoreInstanceState:
@Override
public Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
// save our progress
bundle.putInt("progress", progress);
// be sure to save all other instance state that may exist
bundle.putParcelable("otherState", super.onSaveInstanceState());
return bundle;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
// restore our progress
setProgress(bundle.getInt("progress"));
// restore all other state
state = bundle.getParcelable("otherState");
}
super.onRestoreInstanceState(state);
}
To make our progress bar animate when set, we'll overload setProgress
to take an additional boolean flag for whether or not to animate filling of the progress bar.
We'll define a new member ValueAnimator barAnimator;
to animate our progress value. When setProgress
is called, and the flag for animation is set, we'll initialize and start the animator with the new progress value:
barAnimator = ValueAnimator.ofFloat(0, 1);
// animate over the course of 700 milliseconds
barAnimator.setDuration(700);
// reset progress to zero so it can animate up
setProgress(0, false);
// use a decelerate interpolator which will slow down towards the end of animation
barAnimator.setInterpolator(new DecelerateInterpolator());
// call setProgress (without animating) to
// update the progress and invalidate the view
barAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float interpolation = (float) animation.getAnimatedValue();
setProgress((int) (interpolation * progress), false);
}
});
if (!barAnimator.isStarted()) {
barAnimator.start();
}
Created by CodePath with much help from the community. Contributed content licensed under cc-wiki with attribution required. You are free to remix and reuse, as long as you attribute and use a similar license.
Finding these guides helpful?
We need help from the broader community to improve these guides, add new topics and keep the topics up-to-date. See our contribution guidelines here and our topic issues list for great ways to help out.
Check these same guides through our standalone viewer for a better browsing experience and an improved search. Follow us on twitter @codepath for access to more useful Android development resources.