Faça seu app brilhar: Como transformar um botão em um load spinner

Hoje em dia quase todo mundo concorda que um bom design ajuda muito a fazer as pessoas se interessarem pelo seu aplicativo. Se você não concorda, então tente publicar um app com uma interface pobre e veja o que acontece com a conversão…

Um boa parte dos aplicativos de Android hoje em dia usa o Progress Dialog. Mas apps de alta qualidade sempre inovam nas animações e dá para ver que muitas empresas já mudaram a maneira de mostrar ao usuário que o aplicativo está executando alguma tarefa e ele deve esperar.

Nesse tutorial você vai ver como fazer um botão se transformar em um loading spinner (igual a esses aí em cima). O tutorial é baseado neste repositório: https://github.com/leandroBorgesFerreira/LoadingButtonAndroid.


Passo 1 — Vamos implementar um botão:

Esse tutorial não é tão simples, mas vamos que vamos que você vai entender tudo.

É só fazer uma forcinha =]

1.1 — O background do botão

Primeiro, crie um fundo na pasta drawable chamado button_shape:

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

Assim, o botão vai ser retangular com o fundo preto.

1.2 — Implementando o botão

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) {
}

}

Em qualquer View customizada você deve sobrescrever esses quatro construtores, então vamos colocar toda nossa lógica de criação em um só método, o init.

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

setBackground(mGradientDrawable);
}

Uma observação: Você deve ter percebido que o nome das variáveis está em inglês, certo? É uma boa prática programar em inglês, assim devs do mundo inteiro entendem seu código. Desse jeito, o código vai ser em inglês aqui, ok?

Bom agora você pode colocar o seu botão no seu XML como um outro botão qualquer. Desse jeito:

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

Passo 2 — Vamos fazer a animação de transformação

A animação inteira é composta por três animações menores: uma animação dos cantos, uma animação na largura do botão e uma animação na altura. Será usado o ObjectAnimator, ValueAnimator e o AnimatorSet. Se você não sabe muita coisa sobre essas classes, você pode dar uma lida aqui.

A animação dos cantos:

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

A animação da largura:

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);
}
});

A animação da altura:

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);
}
});

Seria bom se pudéssemos utilizar setWidth e setHeight certo? Pois é, não dá… =/. Mas os snipets acima resolvem o problema.

Então podemos colocar isso tudo em um método apenas:

public void startAnimation(){
if(mState != State.IDLE){
return;
}

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

int initialCornerRadius = 0;
int finalCornerRadius = 1000;
    //1
mState = State.PROGRESS;
mIsMorphingInProgress = true;
    //2
this.setText(null);
setClickable(false);
    //3
int toWidth = 300; //some random value...
int toHeight = toWidth; //make it a perfect circle

//4
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);
}
});
    //5
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();
}

Muita coisa ai né? Deixando tudo claro:

1 — Temos que setar o estado do botão para em progress e se transformando.

2 — Vamos apagar os textos e deixar o botão desabilitado

3— Escolhemos a largura final do botão e setamos a largura iguais para que o resultado ser um círculo. Você pode mudar os valores de acordo com sua necessidade.

4— Criamos nossas animações

5 — Criamos um AnimatorSet e disparamos todas as animações em conjunto.

Eis o que conseguimos fazer até então:

=D

Eu não vou colocar o código para reverter a animação para esse artigo não ficar enorme, mas basta fazer um método com as animações inversas.


Passo 3— Agora, a animação de loading — Parte 1

Bom, primeiro precisamos garantir que, se a transformação acabou, precisamos desenhar nossa animação de progresso.

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

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

O método onDraw vai ser chamado toda vez que um frame for desenhado pelo botão. Então precisamos chamar o método drawIndeterminateProgress para que nosso botão saiba como fazer o desenho que queremos.

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);
}
}

Nossa animação está definida na classe CircularAnimatedDrawable. Caso ela não tenha sido instanciada ainda criamos a instância, e se foi instanciada, fazemos o desenho. É importante criar essa classe dentro do onDraw porque as medidas do botão (getWidth e getHeight) já são conhecidos. No onCreate eles retornariam zero.

Agora só definimos os limites da animação, a espessura do arco e a sua cor.

Passo 4 — Agora, a animação de loading— Parte 2

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;
}
}

Precisamos implementar o setupAnimations(). Esse é o método que define como a animação de carregamento se desenha. Assim:

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();
}
});
}

O primeiro ValueAnimator está setando ângulo da animação, já o segundo é responsável pelo translado que o loading faz. É necessário chamar o invalidate() nos métodos para que o botão possa se redesenhar.

Definimos os limites da animação:

    @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;

}

Nós escrevemos os métodos para começar, parar e saber se animação está acontecendo. Assim podemos começar e terminar as animações nos momentos necessários:

    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;
}

Também temos que escrever os métodos da classe Drawable:

    @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;
}

Então, a classe toda é:

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();
}
});

}

Depois dessa definição, temos o seguinte resultado:

E a animação está feita!

Então é isso, caso você tenha alguma dúvida para implementar esse tutorial pode escrever no comentários que eu dou uma força.

Se você achou esse tutorial útil para você, então clica no coraçãozinho aí embaixo. Desse jeito você ajuda a compartilhar essa história com outros desenvolvedores.