打造極致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萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章