Поиск по этому блогу

вторник, 15 августа 2017 г.

Пример написания большой пульсирующей кнопки

Бывает, заказчики хотят чего-то уникального, чего нету у других, в основном это касается или каких-то кнопок или текствьюх или чего-то стандартного в не стандартной оболочке. Вот и мне как-то довелось с таким встретиться. Заказчик захотел что бы кнопка пульсировала для привлечения внимания и имела ripple эффект во время клика, в принципе ничего такое, это уже есть реализованное в множественном виде, но именно так как он хотел я не нашел, да и тогда когда я это делал еще небыло особо делать популярно такие кнопки в приложениях.


У меня уже были наработки в этой сфере, я делал когда то кнопку у которой волны расходились как на воде по периметру круглой кнопки. Но это было не совсем то, по этому пришлось немного вспомнить математику и покрутить извилинами.


Кнопка будет состоять у нас из двух вьюх — PulsingButtonBackground и PulsingButtonTextView и одной объеденяющей эти две вьюхи вьюхой — PulsingButtonView, ее мы будем использовать как кнопку собственно в активити.

По традиции будем использовать ButterKnife в проекте, так что советую добавить две строчки как написано тут в секции Download в app/build.gradle в секцию dependencies, у меня она выглядит вот так:

app/build.gradle

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:25.3.1'

    compile 'com.jakewharton:butterknife:8.8.1'
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1'
}

Дальше мы начнем с фона, то есть с PulsingButtonBackground в которой у нас будет код пульсирования самой кнопки, тоненького фона как на фоне, и еще после нажатия у нас будет появляться ripple эффект.

PulsingButtonBackground.java
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.DecelerateInterpolator;

import com.project.dajver.pulsingbutton.R;

public class PulsingButtonBackground extends View {

    private Paint solidPaint;
    private Paint strokePaint;
    private Paint ripplePaint;

    private float solidMultiplier;
    private float strokeMultiplier;

    private float duration = 150;
    private int frameRate = 15;

    private float speed = 1;
    private float rippleRadius = 0;
    private float endRippleRadius = 0;
    private float rippleX = 0;
    private float rippleY = 0;
    private int width = 0;
    private int height = 0;
    private int touchAction;

    private Handler handler = new Handler();

    public PulsingButtonBackground(Context context) {
        super(context);
        initialize();
    }

    public PulsingButtonBackground(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize();
    }

    public PulsingButtonBackground(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    private void initialize() {
        solidPaint = new Paint();
        solidPaint.setColor(getResources().getColor(R.color.colorPrimary));
        solidPaint.setAntiAlias(true);

        strokePaint = new Paint();
        strokePaint.setColor(getResources().getColor(R.color.colorPrimary));
        strokePaint.setStyle(Paint.Style.STROKE);
        strokePaint.setStrokeWidth(getResources().getDimension(R.dimen.ring_width));
        strokePaint.setAntiAlias(true);

        ripplePaint = new Paint();
        ripplePaint.setColor(Color.WHITE);
        ripplePaint.setAlpha(51);
        ripplePaint.setAntiAlias(true);

        resetAnimatedValues();
    }

    public Animator getAnimator() {
        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(getSolidAnimator(), getStrokeAnimator(), getStrokeAlphaAnimator(), getRefreshAnimator());
        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                resetAnimatedValues();
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                resetAnimatedValues();
                invalidate();
            }
        });
        return animatorSet;
    }

    private void resetAnimatedValues() {
        solidMultiplier = 1.0f;
        strokeMultiplier = 1.0f;
        strokePaint.setAlpha(0);
    }

    private Animator getSolidAnimator() {
        ValueAnimator solidShrink = ValueAnimator.ofFloat(1.0f, 0.96f).setDuration(250);
        solidShrink.addUpdateListener(mSolidUpdateListener);
        ValueAnimator solidGrow = ValueAnimator.ofFloat(0.96f, 1.0f).setDuration(450);
        solidGrow.addUpdateListener(mSolidUpdateListener);
        solidGrow.setInterpolator(new DecelerateInterpolator());
        AnimatorSet solidAnimation = new AnimatorSet();
        solidAnimation.playSequentially(solidShrink, solidGrow);
        return solidAnimation;
    }

    private ValueAnimator.AnimatorUpdateListener mSolidUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            solidMultiplier = (float) valueAnimator.getAnimatedValue();
        }
    };

    private Animator getStrokeAnimator() {
        ValueAnimator strokeGrow = ValueAnimator.ofFloat(0.80f, 1.1f).setDuration(800);
        strokeGrow.addUpdateListener(mStrokeUpdateListener);
        strokeGrow.setInterpolator(new DecelerateInterpolator());
        strokeGrow.setStartDelay(250);
        return strokeGrow;
    }

    private ValueAnimator.AnimatorUpdateListener mStrokeUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            strokeMultiplier = (float) valueAnimator.getAnimatedValue();
        }
    };

    private Animator getStrokeAlphaAnimator() {
        ValueAnimator alphaAnimator = ValueAnimator.ofInt(255, 0).setDuration(650);
        alphaAnimator.addUpdateListener(mStrokeAlphaUpdateListener);
        alphaAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                strokePaint.setAlpha(255);
            }
        });
        alphaAnimator.setStartDelay(500);
        return alphaAnimator;
    }

    private ValueAnimator.AnimatorUpdateListener mStrokeAlphaUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            strokePaint.setAlpha((int) valueAnimator.getAnimatedValue());
        }
    };

    private Animator getRefreshAnimator() {
        ValueAnimator refreshAnimator = ValueAnimator.ofFloat(0.0f, 1.0f).setDuration(1200);
        refreshAnimator.addUpdateListener(mRefreshUpdateListener);
        return refreshAnimator;
    }

    private ValueAnimator.AnimatorUpdateListener mRefreshUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            invalidate();
        }
    };

    @Override
    protected void onDraw(Canvas canvas) {
        int width = canvas.getWidth();
        float halfWidth = width / 2.0f;
        float solidHalfWidth = halfWidth * solidMultiplier;
        float strokeHalfWidth = halfWidth * strokeMultiplier;

        int height = canvas.getHeight();
        float halfHeight = height / 2.0f;
        float solidHalfHeight = halfHeight * solidMultiplier;
        float strokeHalfHeight = halfHeight * strokeMultiplier;

        canvas.drawARGB(0, 0, 0, 0);

        double strokeRadius =  0.515 * Math.sqrt(strokeHalfWidth * strokeHalfWidth + strokeHalfHeight * strokeHalfHeight);
        canvas.drawCircle(halfWidth, halfHeight, (float) strokeRadius, strokePaint);

        double solidRadius =  0.5 * Math.sqrt(solidHalfWidth * solidHalfWidth + solidHalfHeight * solidHalfHeight);
        canvas.drawCircle(halfWidth, halfHeight, (float) solidRadius, solidPaint);

        if(rippleRadius > 0 && rippleRadius < endRippleRadius) {
            canvas.drawCircle(rippleX, rippleY, rippleRadius, ripplePaint);
            if(touchAction == MotionEvent.ACTION_UP) {
                invalidate();
            }
        }
    }

    @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        rippleX = event.getX();
        rippleY = event.getY();

        touchAction = event.getAction();
        switch(event.getAction()) {
            case MotionEvent.ACTION_UP: {
                getParent().requestDisallowInterceptTouchEvent(false);

                rippleRadius = 1;
                endRippleRadius = Math.max(Math.max(Math.max(width - rippleX, rippleX), rippleY), height - rippleY);
                speed = endRippleRadius / duration * frameRate;
                handler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        if(rippleRadius < endRippleRadius) {
                            rippleRadius += speed;
                            ripplePaint.setAlpha(90 - (int) (rippleRadius / endRippleRadius * 90));
                            handler.postDelayed(this, frameRate);
                        }
                    }
                }, frameRate);
                break;
            }
            case MotionEvent.ACTION_CANCEL: {
                getParent().requestDisallowInterceptTouchEvent(false);
                break;
            }
            case MotionEvent.ACTION_DOWN: {
                getParent().requestDisallowInterceptTouchEvent(true);
                return true;
            }
            case MotionEvent.ACTION_MOVE: {
                rippleRadius = 0;
                if(rippleX < 0 || rippleX > width || rippleY < 0 || rippleY > height) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                    touchAction = MotionEvent.ACTION_CANCEL;
                    break;
                } else {
                    invalidate();
                    return true;
                }
            }
        }
        invalidate();
        return false;
    }
}

И так что же у нас тут по порядку.
В шапке класса мы задали кучу переменных, переменные которые являются объектами Paint у нас будут использоваться для задавания стиля той или иной части кнопки. Переменные solidMultiplier и strokeMultiplier мы будем использовать для создания эффекта движения кнопки, слоя с тонкими гранями и слоя с основной кнопкой. Все остальные переменные мы будем использовать для создания ripple эффекта, а это скорость, радиус и т.д. Еще чуть ниже мы проинициализировали Handler, он нам понадобится для создания анимации нажатия на кнопку и ripple эффекта.

Дальше у нас идут конструкторы в которых мы вызываем метод инициализации наши Paint переменных, задаем толщину линий, цвет, прозрачность и тип.

Дальше у нас идет метод getAnimator() который создает объект AnimatorSet с колбеком который вызывает метод который «обнуляет» все параметры, это мы делаем для того что бы у нас анимация циклилась и проигрывалась бесконечно.

Ниже идет метод resetAnimatedValues() который мы вызываем для обнуления анимации что бы она начинала играть с начала.

Еще ниже идет метод getSolidAnimator() который мы используем для создания анимации для главного круга который у нас является кнопкой с залитым фоном. В нем мы задаем продолжительность проигрывания анимации, колбеки в которых у нас обновляется анимация и т.д.

Чуть ниже у нас идет лисенер в котором мы обновляем анимацию с нужной продолжительностью которую мы задали в методе вышел.

То же самое у нас в методах getStrokeAnimator(), getStrokeAlphaAnimator() и getRefreshAnimator и с их колбеками, они повторяют почти те же самые функции просто немного с разными параметрами.

А вот дальше начинается интересное, у нас есть метод onDraw() который мы используем для отрисовки всех элементов. В начале этого класса мы проводим нетривиальные математические операции для выведения нужных нам параметров для рисования кнопки и обводки для нее, плюс тут же мы просчитываем пульс анимации умножая halfWidth на strokeMultiplier для получения высоты нашего круга который будет обрамлять наш основной круг кнопки.

Дальше мы расчитываем радиус нашего круга судя из высоты и ширины обрамляющей вьюхи. И создаем круг. Так же делаем еще раз чуть ниже только для цельного круга. А еще ниже у нас идет проверка на отрисовку ripple эффекта. Эта проверка нам нужна для того что бы наш экран чистился когда нажатие было окончено и радиус был достигнут своего предела.

А в самом низу класса идет метод onTouchEvent(). В нем мы отслеживаем нажатия на вьюхе и в зависимости от нажатия рисуем наш ripple эффект на ней. 

Еще у нас тут используется один параметер для установки ширины слоя обводки. Так вот он у нас находится в файле dimens. 

dimens.xml
<dimen name="ring_width">2dp</dimen>

С этим классом мы закончили, теперь давайте рассмотрим класс PulsingButtonTextView который мы будем использовать для отображения текста на кнопке.

PulsingButtonTextView.java
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.util.AttributeSet;

public class PulsingButtonTextView extends android.support.v7.widget.AppCompatTextView {

    public PulsingButtonTextView(Context context) {
        super(context);
    }

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

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

    public Animator getAnimator() {
        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(getTextAnimator(), getRefreshAnimator());
        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                setScaleX(1.0f);
                setScaleY(1.0f);
            }
        });
        return animatorSet;
    }

    private Animator getTextAnimator() {
        ValueAnimator solidShrink = ValueAnimator.ofFloat(1.0f, 0.96f).setDuration(250);
        solidShrink.addUpdateListener(mTextAnimatorUpdateListener);
        ValueAnimator solidGrow = ValueAnimator.ofFloat(0.96f, 1.0f).setDuration(450);
        solidGrow.addUpdateListener(mTextAnimatorUpdateListener);
        AnimatorSet solidAnimation = new AnimatorSet();
        solidAnimation.playSequentially(solidShrink, solidGrow);
        return solidAnimation;
    }

    private ValueAnimator.AnimatorUpdateListener mTextAnimatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            setScaleX((float) valueAnimator.getAnimatedValue());
            setScaleY((float) valueAnimator.getAnimatedValue());
        }
    };

    private Animator getRefreshAnimator() {
        ValueAnimator refreshAnimator = ValueAnimator.ofFloat(0.0f, 1.0f).setDuration(1200);
        refreshAnimator.addUpdateListener(mRefreshUpdateListener);
        return refreshAnimator;
    }

    private ValueAnimator.AnimatorUpdateListener mRefreshUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator valueAnimator) {
            invalidate();
        }
    };
}

В этом классе мы делаем простую манипуляцию. У нас как и в предыдущем классе есть метод getAnimator() который мы используем для создания эффекта пульсации. Тут мы этот эффект применяем к всей вьюхе текста которая у нас есть.

В методе getTextAnimator() мы задаем продолжительность пулсирования текста и задаем колбек который обновляет нашу анимацию вместе с тем что увеличивает и уменьшает размер текста на определенные шаги.

Далее нам нужно объеденить эта два класса в одном что бы мы могли использовать его как кнопку. Это мы будем делать в классе PulsingButtonView. Но для начала нам нужно сделать верстку этой вьюхи, в ней нас будут подключаться два класса которые мы подключили выше.

view_pulsing_button.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/pulsing_button"
    android:layout_width="@dimen/pulsing_button_width"
    android:layout_height="@dimen/pulsing_button_height">

    <com.project.dajver.pulsingbutton.view.PulsingButtonBackground
        android:id="@+id/pulsing_background"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

    <com.project.dajver.pulsingbutton.view.PulsingButtonTextView
        android:id="@+id/pulsing_text"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/click_me"
        android:textAllCaps="false"
        android:textColor="@android:color/white"
        android:textSize="24sp"/>

</FrameLayout>

Вот что я собственно и говорил, мы будем использовать оба наши класса в одном, положив текстовый поверх фона кнопки.

Еще нам не хватает два dimens. Для установки высоты и ширины вьюхи.

dimens.xml
 <dimen name="pulsing_button_width">300dp</dimen>
 <dimen name="pulsing_button_height">300dp</dimen>

PulsingButtonView.java
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.FrameLayout;

import com.project.dajver.pulsingbutton.R;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;

public class PulsingButtonView extends FrameLayout {

    @BindView(R.id.pulsing_background)
    PulsingButtonBackground pulsingButtonBackground;
    @BindView(R.id.pulsing_text)
    PulsingButtonTextView pulsingButtonTextView;

    private Animator animator;
    private OnPulseButtonClickListener onPulseButtonClick;

    private boolean animationEnabled = true;
    private boolean alreadyAnimating;

    public PulsingButtonView(Context context) {
        super(context);
        initialize();
    }

    public PulsingButtonView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initialize();
    }

    public PulsingButtonView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initialize();
    }

    private void initialize() {
        LayoutInflater.from(getContext()).inflate(R.layout.view_pulsing_button, this);
        ButterKnife.bind(this);
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (getVisibility() == VISIBLE) {
            startAnimation();
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        cancelAnimation();
        super.onDetachedFromWindow();
    }

    @Override
    public void setVisibility(int visibility) {
        if (getVisibility() != visibility) {
            super.setVisibility(visibility);
            updateAnimation();
        }
    }

    private void updateAnimation() {
        if (getVisibility() == VISIBLE && animationEnabled) {
            startAnimation();
        } else {
            cancelAnimation();
        }
    }

    private void cancelAnimation() {
        if (animator != null) {
            alreadyAnimating = false;
            animator.cancel();
            animator = null;
        }
    }

    private void startAnimation() {
        if (alreadyAnimating) {
            return;
        }
        alreadyAnimating = true;
        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(pulsingButtonBackground.getAnimator(), pulsingButtonTextView.getAnimator());
        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (alreadyAnimating) {
                    alreadyAnimating = false;
                    updateAnimation();
                }
            }
        });
        animatorSet.start();
        animator = animatorSet;
    }

    @OnClick(R.id.pulsing_button)
     void onClickThis() {
        onPulseButtonClick.onPulseButtonClick();
    }

    public void setOnPulseButtonClick(OnPulseButtonClickListener onPulseButtonClick) {
        this.onPulseButtonClick = onPulseButtonClick;
    }

    public interface OnPulseButtonClickListener {
        void onPulseButtonClick();
    }
}

В конструкторе мы инициализируем наш леяут который мы будем использовать как родительский и ButterKnife для того что-бы мы могли использовать биндиг с этой библиотеки.

Дальше у нас идет несколько методов onAttachedToWindow() и onDetachedFromWindow() для включения и выключения анимации в зависимости от того когда вьюха на экране и когда нет.

Метод setVisibility() для того что бы когда мы делаем вьюху невидимой мы выключаем ее анимацию что бы она не жрала память.

Метод updateAnimation() мы используем как раз для того что я написал чуть выше, если вьюха в пределах видимости на экране — она у нас играет анимацию, если же нет — тогда мы стопим ее.

cancelAnimation() и startAnimation() у нас очевидно для старта и остановки анимации.

Метод onClickThis() у нас отслеживает клик по кнопку и шлет колбек в активити. Ну и ниже у нас сеттер для колбека и сам интерфейс его же.

Теперь нам осталось только подключить это все в нашей активити и любоваться красотой.

activity_main.xml 
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_vertical|center_horizontal"
   android:background="@android:color/white">

    <com.project.dajver.pulsingbutton.view.PulsingButtonView
        android:id="@+id/pulsing_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

Здесь просто прописываем что будем использовать нашу кнопку. И дальше в нашей MainActivity сетим колбек на клик, что бы отслеживать его.

MainActivity.java
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.widget.Toast;

import com.project.dajver.pulsingbutton.view.PulsingButtonView;

import butterknife.BindView;
import butterknife.ButterKnife;

public class MainActivity extends AppCompatActivity implements PulsingButtonView.OnPulseButtonClickListener {

    @BindView(R.id.pulsing_button)
    PulsingButtonView pulsingButtonView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.bind(this);

        pulsingButtonView.setOnPulseButtonClick(this);
    }

    @Override
    public void onPulseButtonClick() {
        Toast.makeText(getApplicationContext(), getString(R.string.button_clicked), Toast.LENGTH_LONG).show();
    }
}

В onCreate() прописываем леяут, ButterKnife и указываем что будем использовать лисенер клика по кнопке. onPulseButtonClick() же метод который является колбеком в который возвращается клик.


Исходники:

GitHub