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.
Nesse artigo, você vai ver como fazer um botão se transformar em um loading spinner (igual a esses aí embaixo). O artigo é baseado neste repositório.
Passo 01. Vamos implementar um botão
Esse artigo não é tão simples, mas vamos que vamos que você vai entender tudo.
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.
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 aí, né? Deixando tudo claro:
- Temos que setar o estado do botão para em progress e se transformando;
- Vamos apagar os textos e deixar o botão desabilitado;
- Escolhemos a largura final do botão e setamos a largura iguais para que o resultado seja um círculo. Você pode mudar os valores de acordo com sua necessidade;
- Criamos nossas animações;
- Criamos um AnimatorSet e disparamos todas as animações em conjunto.
Eis o que conseguimos fazer até então:
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 os passos deste artigo, pode escrever no comentários que eu dou uma força.








