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.