Design & UX

25 mai, 2017

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

Publicidade

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:

  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 seja 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:

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.