打造极致Material Design动画风格Button

========================================================
作者:qiujuer
博客:blog.csdn.net/qiujuer
网站:www.qiujuer.net
开源库:Genius-Android
转载请注明出处:http://blog.csdn.net/qiujuer/article/details/42471119

——学之开源,用于开源;初学者的心态,与君共勉!
========================================================

在我的文章中曾经有两篇关于Material Design风格的按钮实现。在第一章中只是简单的实现了动画的波纹效果,而在第二篇中对此进行了一定的扩充与优化,最后实现可以自动移动到中心位置的动画;虽然两者都可用,但是在我的使用中却发现了一定的问题,如有些位置点击会出现波纹速度的运算上的问题。

在这一章中将带你打造一个极致的Material Design动画风格Button;至少在我看来与官方的相当接近了。

效果

个人


官方


可以看出其基本上差不多了。

分析

首先我们来解析一下官方的:


在这里我截取了最后一个按钮相应的连续几张图片的情况,从图片我们可以看出以下情况:

  • 官方也是采用圆形水波,非圆角矩形水波(这个与我最开始所想不太一样)
  • 其扩散速度逐渐递减,圆心的时候基本一闪就过
  • 圆形波纹颜色一直没有变化
  • 控件按钮整体背景色逐渐加深
  • 点击位置在右下角,但是从扩散情况来看其水波圆心逐渐向按钮控件中心靠拢
  • 这些也就是我们需要实现的部分。

实现原理

我们第二张中的按钮之所以有很大的差距我总结出以下几点:

  1. 中心靠拢的速度控制上不对
  2. 整体的减速 Interpolator 类设置不对,虽然同样是减速,但是可以看出官方的起步很快,而后递减很慢,这个可以通过初始化的时候传入 Interpolator 参数解决
  3. 水波颜色控制不对,颜色应该不变化,变化的是背景色的颜色
  4. 没有背景色变化的过程,这个过程需要添加,同时这里有一个细节,其最后的颜色并没有加到最深,大约相当于波纹颜色的80%左右
  5. 没有考虑圆角情况,在第二章中如果控件是圆角,其波纹将会超出圆角而后消失。

代码

不知道你们在做的过程中是否想过,我们的动画是在用户点击 onTouch() 的基础上不断的刷新触发 onDraw() 然后绘制来的,与一个按钮的结合点也就是这么两个地方,最多为了方便我们结合的地方还有一个 onMeasure() .所以我们能得出这样一个类:

Class

public class TouchEffectAnimator {


    public TouchEffectAnimator(View mView) {

    }

    public void onMeasure() {

    }

    public void onTouchEvent(final MotionEvent event) {

    }

    public void onDraw(final Canvas canvas) {

    }

    private void startAnimation() {
  
    }

    private void cancelAnimation() {

    }

    private void fadeOutEffect() {
    
    }
}
一个类,这个类作用于一个控件,所以我们需要传入一个 View.

然后我们提供一个 onMeasure() 方法用于初始化高度宽度等数据;onTouchEvent() 当然是用来在控件中触发点击事件所用的;onDraw() 这个无需说也是控件中调用,用来绘制所用;一个动画当然需要启动方法和取消方法,当然在波纹动画后我们还需要的是 "淡出" 的动画。

而后我们想想,其是我们需要的动画类型无非就是那么几种,我们何不合在一起呢?

枚举

public enum TouchEffect {
    Move,
    Ease,
    Ripple,
    None
}
在这个枚举中分别代表:一边扩散一边移动到中心,无波纹只有淡入淡出,纯扩散不移动的类型,没有动画的类型

下面我们来看看主类中的变量情况。

静态变量

    private static final Interpolator DECELERATE_INTERPOLATOR = new DecelerateInterpolator(2.8f);
    private static final Interpolator ACCELERATE_INTERPOLATOR = new AccelerateInterpolator();
    private static final int EASE_ANIM_DURATION = 200;
    private static final int RIPPLE_ANIM_DURATION = 300;
    private static final int MAX_RIPPLE_ALPHA = (int) (255 * 0.8);
分别是:动画减速、加速效果;淡入淡出默认时间200毫秒,扩散时间默认300毫秒,最大的透明度为255的80%用于淡入淡出。主色为255 100%。

在这里,减速效果中之所有一个2.8,其主要作用是使扩散效果在初期尽量的快 (起到隐藏小圆圈),而后期尽量的慢(增强触摸感觉)

必须变量

    private View mView;
    private int mClipRadius;
    private int mAnimDuration = RIPPLE_ANIM_DURATION;
    private TouchEffect mTouchEffect = TouchEffect.Move;
    private Animation mAnimation = null;
一个View,一个圆角弧度,一个动画时间,一个动画类型,最后一个动画类(在这里没有使用属性动画,而是准备采用最基本的动画,采用回调来直接设置参数)

圆形半径变量

    private float mMaxRadius;
    private float mRadius;
一个最大半径,一个当前半径;之所以有最大半径,在我看来有多种情况:如果是移动模式那么其最大半径扫过地区域能达到最长边的75%就行了;如果是纯扩散,如果用户点击的是最右下角,那么其扫过区域最好能达到其对角的长度;更具勾股定理可以得出其为最长边的1.25倍。

座标变量

    private float mDownX, mDownY;
    private float mCenterX, mCenterY;
    private float mPaintX, mPaintY;
点击座标,中心座标,当前圆心座标

画笔变量

    private Paint mPaint = new Paint();
    private RectF mRectRectR = new RectF();
    private Path mRectPath = new Path();
    private int mRectAlpha = 0;
一只画笔,一个区域,一个区域所生成的Path路径,一个区域透明度

淡出控制变量

    private boolean isTouchReleased = false;
    private boolean isAnimatingFadeIn = false;
这两个变量主要用于控制淡出动画触发的时机,我们可以这么想:

在用户一直按着控件的时候就算扩散动画完成了也不进行淡出动画,该动画在用户释放时触发;如果用户点击后立刻擡起那么在擡起时肯定不能触发淡出动画,要等到扩散动画完成后才触发;所以一个变量是是否释放按钮,另外一个是是否动画结束。

动画监听

    private Animation.AnimationListener mAnimationListener = new Animation.AnimationListener() {
        @Override
        public void onAnimationStart(Animation animation) {
            isAnimatingFadeIn = true;
        }

        @Override
        public void onAnimationEnd(Animation animation) {
            isAnimatingFadeIn = false;
            // Is un touch auto fadeOutEffect()
            if (isTouchReleased) fadeOutEffect();
        }

        @Override
        public void onAnimationRepeat(Animation animation) {
        }
    };
上面刚刚说了,控制其释放触发淡出动画,那么这里这个监听器就是用来监听其开始动画状态的,结束后调整值,如果此时用户释放了按钮则触发淡出效果。OK,继续!

初始化

    public TouchEffectAnimator(View mView) {
        this.mView = mView;
        onMeasure();
    }

    public void onMeasure() {
        mCenterX = mView.getWidth() / 2;
        mCenterY = mView.getHeight() / 2;

        mRectRectR.set(0, 0, mView.getWidth(), mView.getHeight());

        mRectPath.reset();
        mRectPath.addRoundRect(mRectRectR, mClipRadius, mClipRadius, Path.Direction.CW);
    }
在控件触发 onMeasure() 方法的时候回调该类的 onMeasure() 方法,在该方法中我们得出其中心座标,初始化一个长方形区域,然后根据区域与圆角半径初始化一个Path路径。

参数设置

    public void setAnimDuration(int animDuration) {
        this.mAnimDuration = animDuration;
    }

    public TouchEffect getTouchEffect() {
        return mTouchEffect;
    }

    public void setTouchEffect(TouchEffect touchEffect) {
        mTouchEffect = touchEffect;
        if (mTouchEffect == TouchEffect.Ease)
            mAnimDuration = EASE_ANIM_DURATION;
    }

    public void setEffectColor(int effectColor) {
        mPaint.setColor(effectColor);
    }

    public void setClipRadius(int mClipRadius) {
        this.mClipRadius = mClipRadius;
    }
既然上面有那么多的变量,那么这里提供了一些方法用于初始化使用,分别是:

动画时间,获取动画类型,设置动画类型,设置颜色,设置控件的圆角弧度。

动画部分

    private void startAnimation() {
        Animation animation = new Animation() {
            @Override
            protected void applyTransformation(float interpolatedTime, Transformation t) {
                if (mTouchEffect == TouchEffect.Move) {
                    mRadius = mMaxRadius * interpolatedTime;
                    mPaintX = mDownX + (mCenterX - mDownX) * interpolatedTime;
                    mPaintY = mDownY + (mCenterY - mDownY) * interpolatedTime;
                } else if (mTouchEffect == TouchEffect.Ripple) {
                    mRadius = mMaxRadius * interpolatedTime;
                }

                mRectAlpha = (int) (interpolatedTime * MAX_RIPPLE_ALPHA);
                mView.invalidate();
            }
        };
        animation.setInterpolator(DECELERATE_INTERPOLATOR);
        animation.setDuration(mAnimDuration);
        animation.setAnimationListener(mAnimationListener);
        mView.startAnimation(animation);
    }

    private void cancelAnimation() {
        if (mAnimation != null) {
            mAnimation.cancel();
            mAnimation.setAnimationListener(null);
        }
    }

    private void fadeOutEffect() {
        Animation animation = new Animation() {
            @Override
            protected void applyTransformation(float interpolatedTime, Transformation t) {
                mRectAlpha = (int) (MAX_RIPPLE_ALPHA - (MAX_RIPPLE_ALPHA * interpolatedTime));
                mView.invalidate();
            }
        };
        animation.setInterpolator(ACCELERATE_INTERPOLATOR);
        animation.setDuration(EASE_ANIM_DURATION);
        mView.startAnimation(animation);
    }

  • 三个方法中,取消最简单了,调用时判断,然后取消,并把监听器设置为 null.
  • 淡出动画中:我们在其方法回调中设置我们的透明度为递减的形式,从最大递减到最小;每次都刷新一次界面;后面是设置其时间,动画为先慢然后一下变快消失掉,然后启动动画。
  • 在开始动画方法中:我们同样在回调中除了我们的变量数据;在这里我们需要判断,如果是普通扩散,那么我们就扩散到对应的半径就OK,如果是Move 类型我们则需要变化其座标。其公式为 C = A+(B-A)*T;而后设置透明度逐渐增加到最大,该透明度是用于全部区域非圆形区域。


触发方法

    public void onTouchEvent(final MotionEvent event) {

        if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
            isTouchReleased = true;
            if (!isAnimatingFadeIn) {
                fadeOutEffect();
            }
        }
        if (event.getActionMasked() == MotionEvent.ACTION_UP) {
            isTouchReleased = true;
            if (!isAnimatingFadeIn) {
                fadeOutEffect();
            }
        } else if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
            // Gets the bigger value (width or height) to fit the circle
            mMaxRadius = mCenterX > mCenterY ? mCenterX : mCenterY;
            // This circle radius is 75% or fill all
            if (mTouchEffect == TouchEffect.Move)
                mMaxRadius *= 0.75;
            else
                mMaxRadius *= 2.5;

            // Set default operation to fadeOutEffect()
            isTouchReleased = false;
            isAnimatingFadeIn = true;

            // Set this start point
            mPaintX = mDownX = event.getX();
            mPaintY = mDownY = event.getY();

            // This color alpha
            mRectAlpha = 0;

            // Cancel and Start new animation
            cancelAnimation();
            startAnimation();
        }
    }
在触发方法中,我们分别需要判断是:取消/擡起/按下 操作。

  1. 在取消和擡起操作中 我们都进行了:变化按钮状态变量 isTouchReleased 为释放,而后判断是否结束动画,如果结束则触发淡出动画
  2. 按下操作:计算出最长半径,其中 0.75 代表上面说的:75%;2.5代表的是上面说的 1.25倍,这里因为是一半,所以乘2 了;其是这一部分应该放在 onMeasure() 方法中。
  3. 而后我们设置 释放按钮变量 isTouchReleased 为 false,设置动画开始 isAnimatingFadeIn 为 true。得到点击座标,设置透明度为0,然后进行一次取消,然后开始动画


onDraw()

    public void onDraw(final Canvas canvas) {
        // Draw Area
        mPaint.setAlpha(mRectAlpha);
        canvas.drawPath(mRectPath, mPaint);

        // Draw Ripple
        if (isAnimatingFadeIn && (mTouchEffect == TouchEffect.Move
                || mTouchEffect == TouchEffect.Ripple)) {
            // Canvas Clip
            canvas.clipPath(mRectPath);
            mPaint.setAlpha(MAX_RIPPLE_ALPHA);
            canvas.drawCircle(mPaintX, mPaintY, mRadius, mPaint);
        }
    }
这个方法是最后一个方法,也是较核心的一个地方,我们的成果就靠这个方法了。

首先当然是画出背景部分,在画之前当然就是设置背景色;该背景色是一个随动画时间变化的量,具体详见上面动画部分。

然后判断是否是启动动画,因为淡出时也会触发该方法但是却不绘制圆形区域部分,所以需要判断;之后判断是否是属于需要绘制圆形的动画类型;再然后就是绘制具体的圆形区域了,分别就是座标和半径;但是这里需要注意的是,在绘制前我们调用了 canvas.clipPath(mRectPath); 。

canvas.clipPath(mRectPath):这个的作用就是剪切,意思是剪切画布部分,然后在剪切后的画布上绘制;这样就解决了圆角时溢出的问题,因为剪切后的画布就那么大你就算画到外部也是无法显示的。

使用

public class GeniusButton extends Button implements Attributes.AttributeChangeListener {
    private TouchEffectAnimator touchEffectAnimator = null;

    public void setTouchEffect(TouchEffect touchEffect) {
        if (touchEffect == TouchEffect.None)
            touchEffectAnimator = null;
        else {
            if (touchEffectAnimator == null) {
                touchEffectAnimator = new TouchEffectAnimator(this);
                touchEffectAnimator.setTouchEffect(touchEffect);
                touchEffectAnimator.setEffectColor("this color");
                touchEffectAnimator.setClipRadius(20);
            }
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        if (touchEffectAnimator != null)
            touchEffectAnimator.onMeasure();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (touchEffectAnimator != null)
            touchEffectAnimator.onDraw(canvas);
        super.onDraw(canvas);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (touchEffectAnimator != null)
            touchEffectAnimator.onTouchEvent(event);
        return super.onTouchEvent(event);
    }
}
在你自定义的控件中按着上面的方式进行实例化调用就OK。
其实现在来说该动画类,并不局限于Button,你可以随意的设置到你的控件上面,如TextView 也可以不是自定义的控件,Android 原生的也可以;只需要设置其中的3个方法回调也就OK;大家可以试试;然后把效果分别切换一下;个人感觉很棒的~

附件

算是分析完了,下面附上源码和我分析时画的一些图,辅助解释。






代码

点击查看


==============更新分割线========================================

更新日期:2015-01-10

今天在二次看代码并优化的时候发现一个错误的地方,在此修正一下;对于给大家带来的不便还请谅解;不过也不影响大局的。

就是在上面中,Ripple 扩散模式下的一个关于其最大半径的运算上的问题。

            if (mTouchEffect == TouchEffect.Move)
                mMaxRadius *= 0.75;
            else
                mMaxRadius *= 2.5;

在这里犯了一个数学的错误以及一个体验上的不够细腻的地方

  • 首先说说不够细腻的地方,在上面中关于其最大半径,在上面我说了是直接采用控件的对角线进行获取其最大半径;这里其是不应该采用对角线;因为用户通常情况下不会点击最边缘的地方,而是靠中60%左右区域;所以如果其最大半径取对角线将会有很大的浪费;应该根据具体的点击情况;判断出位置算出其距离最远的点;然后算出其半径。
  • 然后就是数学上的错误,在这里我采用的 2.5倍;也解释了就是1.25*2得来;而1.25是勾三股四玄五中,得出的玄五是股四的1.25倍;但是其实一个按钮控件常常并不是满足这样的特殊情况;而我以偏概全的方式采用了1.25倍来计算;其是应该是(A的平方+B的平方)然后开根号 得到C值


如上图,定点(A B C D)中心点 E(CX,CY)点击区域(F G H I)点击点(DX,DY)

实际操作中,我们需求判断出距离点击位置最远的定点(A B C D)中的哪一个;所以有了下面的 X Y 值的获取,X Y 就是最远点,然后根据下面公式计算两点之间的距离。

更改后的代码为:

                case Ripple:
                    float x = mDownX < mCenterX ? 2 * mCenterX : 0;
                    float y = mDownY < mCenterY ? 2 * mCenterY : 0;
                    mEndRadius = (float) Math.sqrt((x - mDownX) * (x - mDownX) + (y - mDownY) * (y - mDownY));
                    break;
其中:mEndRadius 就是上面的 mMaxRadius ,只不过在最新代码中有开始与结束半径两个值。

要说细腻,其是上面还没有考虑圆角的情况,但是一般来说圆角对此的影响也不是很大了,没有必要为了那么点去耗时计算;对于实际使用来说上面已经足够了。

========================================================
作者:qiujuer
博客:blog.csdn.net/qiujuer
网站:www.qiujuer.net
开源库:Genius-Android
转载请注明出处:http://blog.csdn.net/qiujuer/article/details/42471119

——学之开源,用于开源;初学者的心态,与君共勉!
========================================================

发布了82 篇原创文章 · 获赞 709 · 访问量 66万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章