Доброго времени суток!
Те, кто следил за 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.