RippleDrawable для Pre-L устройств

  • Tutorial
image

Доброго времени суток!


Те, кто следил за Google IO/2014, знают о новом Material Design и новых фишках. Одной из них является пульсирующий эффект при нажатии. Вчера я решил его портировать для старых устройств.

В Android L перешли на новый эффект — пульсирование, он используется по умолчанию в ответной реакции на касание. То есть при касании на экран появляется большой исчезающий (fades) овал с размером родительского слоя и вместе с ним растет круг в точке прикосновения. Эта анимация меня вдохвновила использовать в своем проекте и я решил попробовать его сделать.



Примеры анимации на Google Design.

Создадим класс RippleDrawable со вспомогательным классом Circle, который будет помогать нам рисовать круги:

    class RippleDrawable extends Drawable{

        final static class Circle{
            float cx; // x координата центра круга
            float cy; // y координата центра круга
            float radius; // радиус круга

            /**
            * Рисуем круг
            * 
            * @param canvas Canvas для рисования
            * @param paint Paint с описанием как стилизировать наш круг
            */
            public void draw(Canvas canvas, Paint paint){
                canvas.drawCircle(cx, cy, radius, paint);
            }
        }
    }


Вспомогательный элемент Circle нам понадобится для сохранения точки касания. Теперь нам понадобится два круга: фоновой круг, который покроет всего родителя и круг поменьше, для отображения точки касания. Ах, да, и еще объявим константы, значение анимации по умолчанию будет 250мс, радиус круга по умолчанию в 150px. Во сколько раз увеличивать фоновой круг, примечания, все цифры взяты на глаз.

	class RippleDrawable extends Drawable{
		
		final static int DEFAULT_ANIM_DURATION = 250;
	    final static float END_RIPPLE_TOUCH_RADIUS = 150f;
	    final static float END_SCALE = 1.3f;
		
		// Круг для касания
		Circle mTouchRipple;
		// Фоновой круг
		Circle mBackgroundRipple;
	
		// Стили для прорисовки "круга для касания"
	    Paint mRipplePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
	    // Стили для фонового круга
	    Paint mRippleBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);


Флаг Paint.ANTI_ALIAS_FLAG предназначен для сглаживания, чтобы круги были кругами, а не фиг пойми мазней какой-то, теперь инициализируем наши переменные в отдельном методе, укажем что стиль окраски «заливка» и создадим круги, далее вызовем его в конструкторе:


    void initRippleElements(){
        mTouchRipple = new Circle();
        mBackgroundRipple = new Circle();

        mRipplePaint.setStyle(Paint.Style.FILL);
        mRippleBackgroundPaint.setStyle(Paint.Style.FILL);
    }


Готово, перейдем к наверное самому интересному обработке касаний, добавим в наш класс интерфейс OnTouchListener:

	class RippleDrawable extends Drawable implements OnTouchListener{

		...
		
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        // Сохраняем совершенное действие
        final int action = event.getAction();
        // и в зависимости от действия выполняем методы
        switch (action){
            // Пользователь коснулся экрана
            case MotionEvent.ACTION_DOWN:
                onFingerDown(v, event.getX(), event.getY());
                // Для того что бы события View срабатывали нам нужно его вызывать
                return v.onTouchEvent(event);
            // Пользователь двигает пальцем по экрану (это продолжения касания)
            case MotionEvent.ACTION_MOVE:
                onFingerMove(event.getX(), event.getY());
                break;
            // Пользователь убал свой пальчик
            case MotionEvent.ACTION_UP:
                onFingerUp();
                break;
        }
        return false;
    }
		
		...
	

При касании по экрану сначала мы сохраняем координаты касания по кругам и размер View (для фонового круга), затем стартуем анимашку, если она ранее не стартовала. Кстати говоря, у обоих кругов имеется opacity (прозрачность), я их определил как 100 для фонового круга и от 160 до 40 для маленьго кружочка. Все цифры опять же были взяты из потолка (зоркий глаз) (если кто не понял, цифры от 0 до 255 argb).

    int mViewSize = 0;

    void onFingerDown(View v, float x, float y){
        mTouchRipple.cx = mBackgroundRipple.cx = x;
        mTouchRipple.cy = mBackgroundRipple.cy = y;
        mTouchRipple.radius = mBackgroundRipple.radius = 0f;
        mViewSize = Math.max(v.getWidth(), v.getHeight());

        // Если прошлая анимация закончилась создадим новую
        if(mCurrentAnimator == null){
            // Укажем состояние по умолчанию для нашего фонового круга
            // тоесть восстановим его прозрачность на дефолтный
            mRippleBackgroundPaint.setAlpha(RIPPLE_BACKGROUND_ALPHA);

            // Создадим анимашку, здесь константа CREATE_TOUCH_RIPPLE это геттеры и сеттеры
            // для отправки состояния анимации
            mCurrentAnimator = ObjectAnimator.ofFloat(this, CREATE_TOUCH_RIPPLE, 0f, 1f);
            mCurrentAnimator.setDuration(DEFAULT_ANIM_DURATION);
        }

        // Если анимация играет ничего не делаем ждем пока закончится
        if(!mCurrentAnimator.isRunning()){
            mCurrentAnimator.start();
        }
    }
    
    // Сохранение состояния, необходимо для ObjectAnimator
    float mAnimationValue;
    
    /**
     * ObjectAnimator вызывает эту функции
     * 
     * @param value состояние анимации от 0 до 1
     */
    void createTouchRipple(float value){
        mAnimationValue = value;

        // step by step увеличиваем круги, минимальный радиус 40px 
        mTouchRipple.radius = 40f + (mAnimationValue * (END_RIPPLE_TOUCH_RADIUS - 40f));
        mBackgroundRipple.radius = mAnimationValue * (mViewSize * END_SCALE);

        // и плавное исчезновние еще не появивщихся кругов,
        // тоесть при старте анимации их opacity максимальная, 
        // и в конце она падает до минимального значения
        int min = RIPPLE_TOUCH_MIN_ALPHA;
        int max = RIPPLE_TOUCH_MAX_ALPHA;
        int alpha = min + (int) (mAnimationValue * (max - min));
        mRipplePaint.setAlpha((max + min) - alpha);

        // Перерисовываем
        invalidateSelf();
    }



Теперь, если пользователь коснулся, у нас появляются 2 круга, пользовательский и фоновой, но не уходят, и даже не двигаются при движении пальца, пора это исправлять:

    void onFingerMove(float x, float y){
        mTouchRipple.cx = x;
        mTouchRipple.cy = y;

        invalidateSelf();
    }


Проверьте, двигается теперь кружочек-то, а?

Логика при снятии пальца с курка, то есть с экрана. Если анимация была запущена, мы должны ее закончить и привести к конечному состоянию, далее запустить анимацию, исчезновения кругов, где пользовательский круг будет увеличиваться и исчезать одновременно, приступим:


void onFingerUp(){
        // Заканчиваем анимацию
        if(mCurrentAnimator != null) {
            mCurrentAnimator.end();
            mCurrentAnimator = null;
            createTouchRipple(1f);
        }

        // Создаем новую, и при завершении очищаем ее
        mCurrentAnimator = ObjectAnimator.ofFloat(this, DESTROY_TOUCH_RIPPLE, 0f, 1f);
        mCurrentAnimator.setDuration(DEFAULT_ANIM_DURATION);
        mCurrentAnimator.addListener(new SimpleAnimationListener(){
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mCurrentAnimator = null;
            }
        });
        mCurrentAnimator.start();
    }

    void destroyTouchRipple(float value){
        // Сохраняем состояние анимации
        mAnimationValue = value;

        // Увеличиваем радиус круга до фонового радиуса
        mTouchRipple.radius = END_RIPPLE_TOUCH_RADIUS + (mAnimationValue * (mViewSize * END_SCALE));

        // и одновременно у обоих кругов создаем эффект затухания
        mRipplePaint.setAlpha((int) (RIPPLE_TOUCH_MIN_ALPHA - (mAnimationValue * RIPPLE_TOUCH_MIN_ALPHA)));
        mRippleBackgroundPaint.setAlpha
                ((int) (RIPPLE_BACKGROUND_ALPHA - (mAnimationValue * RIPPLE_BACKGROUND_ALPHA)));

        // ну и как же без перерисовки?
        invalidateSelf();
    }


Анимация готова, можем смело проверять.

Исходный код

import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.Property;
import android.view.MotionEvent;
import android.view.View;

public class RippleDrawable extends Drawable implements View.OnTouchListener{

    final static Property<RippleDrawable, Float> CREATE_TOUCH_RIPPLE =
            new FloatProperty<RippleDrawable>("createTouchRipple") {
        @Override
        public void setValue(RippleDrawable object, float value) {
            object.createTouchRipple(value);
        }

        @Override
        public Float get(RippleDrawable object) {
            return object.getAnimationState();
        }
    };

    final static Property<RippleDrawable, Float> DESTROY_TOUCH_RIPPLE =
            new FloatProperty<RippleDrawable>("destroyTouchRipple") {
        @Override
        public void setValue(RippleDrawable object, float value) {
            object.destroyTouchRipple(value);
        }

        @Override
        public Float get(RippleDrawable object) {
            return object.getAnimationState();
        }
    };

    final static int DEFAULT_ANIM_DURATION = 250;
    final static float END_RIPPLE_TOUCH_RADIUS = 150f;
    final static float END_SCALE = 1.3f;

    final static int RIPPLE_TOUCH_MIN_ALPHA = 40;
    final static int RIPPLE_TOUCH_MAX_ALPHA = 120;
    final static int RIPPLE_BACKGROUND_ALPHA = 100;

    Paint mRipplePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    Paint mRippleBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    Circle mTouchRipple;
    Circle mBackgroundRipple;

    ObjectAnimator mCurrentAnimator;

    Drawable mOriginalBackground;

    public RippleDrawable() {
        initRippleElements();
    }

    public static void createRipple(View v, int primaryColor){
        RippleDrawable rippleDrawable = new RippleDrawable();
        rippleDrawable.setDrawable(v.getBackground());
        rippleDrawable.setColor(primaryColor);
        rippleDrawable.setBounds(v.getPaddingLeft(), v.getPaddingTop(),
                v.getPaddingRight(), v.getPaddingBottom());

        v.setOnTouchListener(rippleDrawable);
        if(Build.VERSION.SDK_INT >= 16) {
            v.setBackground(rippleDrawable);
        }else{
            v.setBackgroundDrawable(rippleDrawable);
        }
    }

    public static void createRipple(int x, int y, View v, int primaryColor){
        if(!(v.getBackground() instanceof RippleDrawable)) {
            createRipple(v, primaryColor);
        }
        RippleDrawable drawable = (RippleDrawable) v.getBackground();
        drawable.setColor(primaryColor);
        drawable.onFingerDown(v, x, y);
    }

    /**
     * Set colors of ripples
     *
     * @param primaryColor color of ripples
     */
    public void setColor(int primaryColor){
        mRippleBackgroundPaint.setColor(primaryColor);
        mRippleBackgroundPaint.setAlpha(RIPPLE_BACKGROUND_ALPHA);
        mRipplePaint.setColor(primaryColor);

        invalidateSelf();
    }

    /**
     * set first layer you background drawable
     *
     * @param drawable original background
     */
    public void setDrawable(Drawable drawable){
        mOriginalBackground = drawable;

        invalidateSelf();
    }

    void initRippleElements(){
        mTouchRipple = new Circle();
        mBackgroundRipple = new Circle();

        mRipplePaint.setStyle(Paint.Style.FILL);
        mRippleBackgroundPaint.setStyle(Paint.Style.FILL);
    }

    @Override
    public void draw(Canvas canvas) {
        if(mOriginalBackground != null){
            mOriginalBackground.setBounds(getBounds());
            mOriginalBackground.draw(canvas);
        }

        mBackgroundRipple.draw(canvas, mRippleBackgroundPaint);
        mTouchRipple.draw(canvas, mRipplePaint);
    }

    @Override public void setAlpha(int alpha) {}

    @Override public void setColorFilter(ColorFilter cf) {}

    @Override public int getOpacity() {
        return 0;
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        // Сохраняем совершенное действие
        final int action = event.getAction();
        // и в зависимости от действия выполняем методы
        switch (action){
            // Пользователь коснулся экрана
            case MotionEvent.ACTION_DOWN:
                onFingerDown(v, event.getX(), event.getY());
                // Для того что бы события View срабатывали нам нужно его вызывать
                return v.onTouchEvent(event);
            // Пользователь двигает пальцем по экрану (это продолжения касания)
            case MotionEvent.ACTION_MOVE:
                onFingerMove(event.getX(), event.getY());
                break;
            // Пользователь убал свой пальчик
            case MotionEvent.ACTION_UP:
                onFingerUp();
                break;
        }
        return false;
    }

    int mViewSize = 0;

    void onFingerDown(View v, float x, float y){
        mTouchRipple.cx = mBackgroundRipple.cx = x;
        mTouchRipple.cy = mBackgroundRipple.cy = y;
        mTouchRipple.radius = mBackgroundRipple.radius = 0f;
        mViewSize = Math.max(v.getWidth(), v.getHeight());

        // Если прошлая анимация закончилась создадим новую
        if(mCurrentAnimator == null){
            // Укажем состояние по умолчанию для нашего фонового круга
            // тоесть восстановим его прозрачность на дефолтный
            mRippleBackgroundPaint.setAlpha(RIPPLE_BACKGROUND_ALPHA);

            // Создадим анимашку, здесь константа CREATE_TOUCH_RIPPLE это геттеры и сеттеры
            // для отправки состояния анимации
            mCurrentAnimator = ObjectAnimator.ofFloat(this, CREATE_TOUCH_RIPPLE, 0f, 1f);
            mCurrentAnimator.setDuration(DEFAULT_ANIM_DURATION);
        }

        // Если анимация играет ничего не делаем ждем пока закончится
        if(!mCurrentAnimator.isRunning()){
            mCurrentAnimator.start();
        }
    }

    float mAnimationValue;

    /**
     * ObjectAnimator вызывает эту функции
     *
     * @param value состояние анимации от 0 до 1
     */
    void createTouchRipple(float value){
        mAnimationValue = value;

        // step by step увеличиваем круги, минимальный радиус 40px
        mTouchRipple.radius = 40f + (mAnimationValue * (END_RIPPLE_TOUCH_RADIUS - 40f));
        mBackgroundRipple.radius = mAnimationValue * (mViewSize * END_SCALE);

        // и плавное исчезновние еще не появивщихся кругов,
        // тоесть при старте анимации их opacity максимальная,
        // и в конце она падает до минимального значения
        int min = RIPPLE_TOUCH_MIN_ALPHA;
        int max = RIPPLE_TOUCH_MAX_ALPHA;
        int alpha = min + (int) (mAnimationValue * (max - min));
        mRipplePaint.setAlpha((max + min) - alpha);

        // Перерисовываем
        invalidateSelf();
    }


    void destroyTouchRipple(float value){
        // Сохраняем состояние анимации
        mAnimationValue = value;

        // Увеличиваем радиус круга до фонового радиуса
        mTouchRipple.radius = END_RIPPLE_TOUCH_RADIUS + (mAnimationValue * (mViewSize * END_SCALE));

        // и одновременно у обоих кругов создаем эффект затухания
        mRipplePaint.setAlpha((int) (RIPPLE_TOUCH_MIN_ALPHA - (mAnimationValue * RIPPLE_TOUCH_MIN_ALPHA)));
        mRippleBackgroundPaint.setAlpha
                ((int) (RIPPLE_BACKGROUND_ALPHA - (mAnimationValue * RIPPLE_BACKGROUND_ALPHA)));

        // ну и как же без перерисовки?
        invalidateSelf();
    }

    float getAnimationState(){
        return mAnimationValue;
    }

    void onFingerUp(){
        // Заканчиваем анимацию
        if(mCurrentAnimator != null) {
            mCurrentAnimator.end();
            mCurrentAnimator = null;
            createTouchRipple(1f);
        }

        // Создаем новую, и при завершении очищаем ее
        mCurrentAnimator = ObjectAnimator.ofFloat(this, DESTROY_TOUCH_RIPPLE, 0f, 1f);
        mCurrentAnimator.setDuration(DEFAULT_ANIM_DURATION);
        mCurrentAnimator.addListener(new SimpleAnimationListener(){
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mCurrentAnimator = null;
            }
        });
        mCurrentAnimator.start();
    }

    void onFingerMove(float x, float y){
        mTouchRipple.cx = x;
        mTouchRipple.cy = y;

        invalidateSelf();
    }

    @Override
    public boolean setState(int[] stateSet) {
        if(mOriginalBackground != null){
            return mOriginalBackground.setState(stateSet);
        }
        return super.setState(stateSet);
    }

    @Override
    public int[] getState() {
        if(mOriginalBackground != null){
            return mOriginalBackground.getState();
        }
        return super.getState();
    }

    final static class Circle{
        float cx;
        float cy;
        float radius;

        public void draw(Canvas canvas, Paint paint){
            canvas.drawCircle(cx, cy, radius, paint);
        }
    }

}





В итоге:



Проект на Github.
Share post

Comments 16

    –1
    Прикольно реализовано, удобно аттачить.
    FloatProperty — хочет API 14, можно как-нибудь опустить до 11?
      +2
      Можно с помощью NineOldAndroids даже до 1 опустить, если больше другое API не используется.
        0
        А в чем резон поддерживать 11? Девайсов на api [11, 14) почти нет. minSdk 14 — оптимальный вариант
          +1
          14 тоже уже нет. Пора за мин брать 15. тыц
            0
            Но между 14 и 15 API нет новых фишек (только вроде что-то с видео), поэтому проще с 14.
              0
              Не пойму почему поддерживать 6 версий проще чем 5 версий.
              Там же кроме новых фич еще и баги фиксят.
              +1
              Ой не травите мне душу, до сих пор под 10 разрабатываем…
              +1
              я в этом плане пляшу от появления фрагментов, а они с 11 версии. Между 11 и 14 тоже не особо критичные изменения
            0
            Не пробовал еще Android L, но там такой эффект по-умолчанию во всех View?
            Если да, то как ваш эффект будет работать поверх уже имеющегося в Android L?
            Может стоит в createRipple() сразу добавить проверку на Android L и не добавлять ваш эффект?
              0
              Эффект на Android L реализован с помощью (примерно) такого же Drawable.
              Соответственно, проверка должна быть раньше — при установке этого Drawable.
              0
              а можно еще поподробнее рассказать про методы createRipple и onDraw — не до конца понятно как Drawable рисуется поверх view
                0
                Я так понял, Drawable не рисуется поверх View, а классическим образом устанавливается в качестве background.
                Если хочется рисовать Drawable именно поверх View, то вам понадобится ViewOverlay, который, увы, с API 18.
                  0
                  ага, мы в личке уже выяснили.
                  Это не универсальное решение так как найти вьюху (а тем более viewgroup) у которой виден бэкграунд сложно. Многое нарисованно сверху.
                  Там надо ViewOverlay (спасибо почитал про него, не знал раньше), или делать обертку ViewGroup в которой уже сначала рисовать ту вьюху к которой приаттачен эффект, а потом сверху этот класс RippleDrawable.
                  Ну и соответсвенно аттачиться будет это дело примерно так же:
                  RippleDrawableView.createRipple(view, getColor(R.color.material_blue_600));
                  

                  надеюсь автор добьет до конца хорошее начинание )
                  ps: хотя подумал — как подменить в иерархии старый view на нашу обертку? возможно не оч рабочее решение
                    0
                    а если просто устанавливать этот drawable не в background, а в foreground frame layout?
                      0
                      не универсально, получается только для framelayout?
                        0
                        изначально только для него, да. но я нашел для себя неплохую библиотеку
                        github.com/cesards/ForegroundViews
                        она очень простая и содержит только классы FImageView, FLinearLayout, FRelativeLayout, FTextView. соответственно в каждом из них дописана возможность ставить foreground аналогично тому, как это делается во FrameLayout

              Only users with full accounts can post comments. Log in, please.