Make your app shine: How to make a button morph into a loading spinner

Almost everybody agrees that a great design helps a lot to make people get interested in you app. If you don't agree, try to publish an app with a awful UI and see what happens…

The huge majority of apps in Play Store uses Progress Dialog to show progress, but some high quality apps innovate with nice animations to show the user that he should wait. Let's see how to make a different loading animation: Morph a button into a loading spinner.

You can check for the code here: https://github.com/leandroBorgesFerreira/LoadingButtonAndroid

Step 1 — Let's extend a button

This morphing button is not so easy, but carry on, you will figure this out!

1.1 — The button's background

First create the background in the drawable folder called shape_default.xml

<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="0dp" />
<solid android:color="#000" />
</shape>

We are going to need this to the next section.

1.2 — Extend de button:

public class CircularProgressButton extends Button {    private enum State {
PROGRESS, IDLE
}
public class LoadingButton extends AppCompatButton {
public LoadingButton(Context context) {
super(context);
init(context);
}

public LoadingButton(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}

public LoadingButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}

public LoadingButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr);
init(context);
}

private void init(Context context) {
}

}

To extend any view, you must override those four constructor. Let's put all our constructor logic in a single method. The init()

private void init(Context context) {
mGradientDrawable = (GradientDrawable)
ContextCompat.getDrawable(context, R.drawable.button_shape);

setBackground(mGradientDrawable);
}

Now you can insert the button in you layout like this:

<your.package.here.LoadingButton
android:id="@+id/btn_morph"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/white"
android:text="Click!"
/>

Step 2 — Let's make the morph animation!

This animation is composed by 3 animations: A corner, width and height animation. We use ObjectAnimator and ValueAnimator. If don't know much about those classes you can read more about it here.

The corner animation:

ObjectAnimator cornerAnimation = ObjectAnimator.ofFloat(mGradientDrawable,
"cornerRadius",
initialCornerRadius,
finalCornerRadius);

And width animation:

ValueAnimator widthAnimation = ValueAnimator.ofInt(fromWidth, toWidth);
widthAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int val = (Integer) valueAnimator.getAnimatedValue();
ViewGroup.LayoutParams layoutParams = getLayoutParams();
layoutParams.width = val;
setLayoutParams(layoutParams);
}
});

The height animation:

ValueAnimator heightAnimation = ValueAnimator.ofInt(fromHeight, toHeight);
heightAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int val = (Integer) valueAnimator.getAnimatedValue();
ViewGroup.LayoutParams layoutParams = getLayoutParams();
layoutParams.height = val;
setLayoutParams(layoutParams);
}
});

Unfortunately you cannot simply use something like: setHeight or setWidth in this case… =/

So now we can put it together in a single method:

/**
* Method called to start the animation. Morphs in to a ball and then starts a loading spinner.
*/
public void startAnimation(){
if(mState != State.IDLE){
return;
}

int initialWidth = getWidth();
int initialHeight = getHeight();

int initialCornerRadius = 0;
int finalCornerRadius = 1000;

mState = State.PROGRESS;
mIsMorphingInProgress = true;
this.setText(null);
setClickable(false);

int toWidth = 300; //some random value...
int toHeight = toWidth; //make it a perfect circle

ObjectAnimator cornerAnimation =
ObjectAnimator.ofFloat(mGradientDrawable,
"cornerRadius",
initialCornerRadius,
finalCornerRadius);

ValueAnimator widthAnimation = ValueAnimator.ofInt(initialWidth, toWidth);
widthAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int val = (Integer) valueAnimator.getAnimatedValue();
ViewGroup.LayoutParams layoutParams = getLayoutParams();
layoutParams.width = val;
setLayoutParams(layoutParams);
}
});

ValueAnimator heightAnimation = ValueAnimator.ofInt(initialHeight, toHeight);
heightAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int val = (Integer) valueAnimator.getAnimatedValue();
ViewGroup.LayoutParams layoutParams = getLayoutParams();
layoutParams.height = val;
setLayoutParams(layoutParams);
}
});

mMorphingAnimatorSet = new AnimatorSet();
mMorphingAnimatorSet.setDuration(300);
mMorphingAnimatorSet.playTogether(cornerAnimation, widthAnimation, heightAnimation);
mMorphingAnimatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mIsMorphingInProgress = false;
}
});
mMorphingAnimatorSet.start();
}

Ok, a lot of code here. But let's get this clear:

1 — I set my corner to go from squared to rounded.

2 — I set the state of the button to Progress and set the morphing to true.

3 — I erase my texts and make the button unclickable.

4 — I set the final width of my button when I make sure the height matches it, so I get a circle. You can change the width to fill your needs.

5 — I create all my animations

6 — I create an AnimatorSet and then play all my animation together with it.

So that's what we have so far:

=D

I won't post the reversal animation so this article doesn't get enormous, but I am sure you can figure it out.

Step 3 — Let's make the loading animation — Part 1

First, let's make sure that, if the button is in progress state and the button is not morphing anymore, we draw the indeterminate progress.

@Override
protected void onDraw(Canvas canvas){
super.onDraw(canvas);

if (mState == State.PROGRESS && !mIsMorphingInProgress) {
drawIndeterminateProgress(canvas);
}
}

The method is:

private void drawIndeterminateProgress(Canvas canvas) {
if (mAnimatedDrawable == null || !mAnimatedDrawable.isRunning()) {

int arcWidth = 15;

mAnimatedDrawable = new CircularAnimatedDrawable(this,
arcWidth,
Color.WHITE);

int offset = (getWidth() - getHeight()) / 2;

int left = offset;
int right = getWidth() - offset;
int bottom = getHeight();
int top = 0;

mAnimatedDrawable.setBounds(left, top, right, bottom);
mAnimatedDrawable.setCallback(this);
mAnimatedDrawable.start();
} else {
mAnimatedDrawable.draw(canvas);
}
}

We are going to handle the animation of the progress in a different class. All we have to do for now is to set the bounds of the animation, the width of the arc and the color of the arc.

Step 4— Let’s make the loading animation — Part 2

Now the CircularAnimatedDrawable.

public class CircularAnimatedDrawable extends Drawable implements Animatable {
private View mAnimatedView;
private float mBorderWidth;
private Paint mPaint;
public CircularAnimatedDrawable(View view, float borderWidth, int arcColor) {
mAnimatedView = view;

mBorderWidth = borderWidth;

mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(borderWidth);
mPaint.setColor(arcColor);

setupAnimations();
}
@Override
public void start() {

}

@Override
public void stop() {

}

@Override
public boolean isRunning() {
return false;
}

@Override
public void draw(@NonNull Canvas canvas) {

}

@Override
public void setAlpha(@IntRange(from = 0, to = 255) int alpha) {

}

@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {

}

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

We need the implement setupAnimations(). This is where we define how the loading animation behaves. Like this:

private void setupAnimations() {
mValueAnimatorAngle = ValueAnimator.ofFloat(0, 360f);
mValueAnimatorAngle.setInterpolator(ANGLE_INTERPOLATOR);
mValueAnimatorAngle.setDuration(ANGLE_ANIMATOR_DURATION);
mValueAnimatorAngle.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimatorAngle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurrentGlobalAngle = currentGlobalAngle;
mAnimatedView.invalidate();
}
});

mValueAnimatorSweep = ValueAnimator.ofFloat(0, 360f - 2 * MIN_SWEEP_ANGLE);
mValueAnimatorSweep.setInterpolator(SWEEP_INTERPOLATOR);
mValueAnimatorSweep.setDuration(SWEEP_ANIMATOR_DURATION);
mValueAnimatorSweep.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimatorSweep.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurrentSweepAngle = currentSweepAngle;
invalidateSelf();
}
});
mValueAnimatorSweep.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationRepeat(Animator animation) {
toggleAppearingMode();
}
});
}

The first ValueAnimator is setting the angle of the loading progress, the second one is responsible for the little sweep. We need to call invalidate in every animationUpdate so our view can redraw itself.

Then, we set the bounds of our animation:

    @Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
fBounds.left = bounds.left + mBorderWidth / 2f + .5f;
fBounds.right = bounds.right - mBorderWidth / 2f - .5f;
fBounds.top = bounds.top + mBorderWidth / 2f + .5f;
fBounds.bottom = bounds.bottom - mBorderWidth / 2f - .5f;

}

We have the start, stop and isRunning methods, so the view can start and stop to loading animation in the proper moment:

    public void start() {
if (mRunning) {
return;
}

mRunning = true;
mValueAnimatorAngle.start();
mValueAnimatorSweep.start();
}

public void stop() {
if (!mRunning) {
return;
}

mRunning = false;
mValueAnimatorAngle.cancel();
mValueAnimatorSweep.cancel();
}
@Override
public boolean isRunning() {
return mRunning;
}

We also must implement the methods of Drawable class"

    @Override
public void setAlpha(int alpha) {
mPaint.setAlpha(alpha);
}

@Override
public void setColorFilter(ColorFilter colorFilter) {
mPaint.setColorFilter(colorFilter);
}

@Override
public int getOpacity() {
return PixelFormat.TRANSPARENT;
}

So, the whole class is:

public class CircularAnimatedDrawable extends Drawable implements Animatable {
private ValueAnimator mValueAnimatorAngle;
private ValueAnimator mValueAnimatorSweep;
private static final Interpolator ANGLE_INTERPOLATOR = new LinearInterpolator();
private static final Interpolator SWEEP_INTERPOLATOR = new DecelerateInterpolator();
private static final int ANGLE_ANIMATOR_DURATION = 2000;
private static final int SWEEP_ANIMATOR_DURATION = 900;
private static final Float MIN_SWEEP_ANGLE = 30f;

private final RectF fBounds = new RectF();
private Paint mPaint;
private View mAnimatedView;

private float mBorderWidth;
private float mCurrentGlobalAngle;
private float mCurrentSweepAngle;
private float mCurrentGlobalAngleOffset;

private boolean mModeAppearing;
private boolean mRunning;


/**
*
* @param view View to be animated
* @param borderWidth The width of the spinning bar
* @param arcColor The color of the spinning bar
*/
public CircularAnimatedDrawable(View view, float borderWidth, int arcColor) {
mAnimatedView = view;

mBorderWidth = borderWidth;

mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(borderWidth);
mPaint.setColor(arcColor);

setupAnimations();
}

@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
fBounds.left = bounds.left + mBorderWidth / 2f + .5f;
fBounds.right = bounds.right - mBorderWidth / 2f - .5f;
fBounds.top = bounds.top + mBorderWidth / 2f + .5f;
fBounds.bottom = bounds.bottom - mBorderWidth / 2f - .5f;
}
public void start() {
if (mRunning) {
return;
}

mRunning = true;
mValueAnimatorAngle.start();
mValueAnimatorSweep.start();
}


public void stop() {
if (!mRunning) {
return;
}

mRunning = false;
mValueAnimatorAngle.cancel();
mValueAnimatorSweep.cancel();
}

public boolean isRunning() {
return mRunning;
}

/**
* Method called when the drawable is going to draw itself.
* @param canvas
*/
@Override
public void draw(Canvas canvas) {
float startAngle = mCurrentGlobalAngle - mCurrentGlobalAngleOffset;
float sweepAngle = mCurrentSweepAngle;
if (!mModeAppearing) {
startAngle = startAngle + sweepAngle;
sweepAngle = 360 - sweepAngle - MIN_SWEEP_ANGLE;
} else {
sweepAngle += MIN_SWEEP_ANGLE;
}

canvas.drawArc(fBounds, startAngle, sweepAngle, false, mPaint);
}

@Override
public void setAlpha(int alpha) {
mPaint.setAlpha(alpha);
}

@Override
public void setColorFilter(ColorFilter colorFilter) {
mPaint.setColorFilter(colorFilter);
}

@Override
public int getOpacity() {
return PixelFormat.TRANSPARENT;
}

private void setupAnimations() {
mValueAnimatorAngle = ValueAnimator.ofFloat(0, 360f);
mValueAnimatorAngle.setInterpolator(ANGLE_INTERPOLATOR);
mValueAnimatorAngle.setDuration(ANGLE_ANIMATOR_DURATION);
mValueAnimatorAngle.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimatorAngle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurrentGlobalAngle = currentGlobalAngle;
mAnimatedView.invalidate();
}
});

mValueAnimatorSweep = ValueAnimator.ofFloat(0, 360f - 2 * MIN_SWEEP_ANGLE);
mValueAnimatorSweep.setInterpolator(SWEEP_INTERPOLATOR);
mValueAnimatorSweep.setDuration(SWEEP_ANIMATOR_DURATION);
mValueAnimatorSweep.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimatorSweep.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurrentSweepAngle = currentSweepAngle;
invalidateSelf();
}
});
mValueAnimatorSweep.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationRepeat(Animator animation) {
toggleAppearingMode();
}
});

}

After this class definition, we get

And that's it! If you enjoyed reading this, please click the clap icon below. This will help to share the story with others.

Reference: https://github.com/dmytrodanylyk/android-morphing-button

Software developer at GetStream.io — I love Android =]