1. 程式人生 > >Android自定義View實現炫酷的主題切換動畫(仿酷安客戶端)

Android自定義View實現炫酷的主題切換動畫(仿酷安客戶端)

前兩日偶然看到了一個很炫酷的動畫效果:

這裡寫圖片描述

  • 判斷它是不是用的ValueAnimator, 如果是的話, 我們可以在設定-開發者選項裡面設定 “動畫時長縮放”來改變動畫時長.

所以這次我們通過設定這個選項, 把動畫速度降低之後, 很快就看出了其中的奧妙。

初步分析

我們先降低一下它的速度:

這裡寫圖片描述

我們把動畫時長縮放調成10x,看看效果:

這裡寫圖片描述

哈哈,有沒有發現,當動畫在播放的時候,那個列表是不能滑動的,可能有以下三個原因:

  1. 被它所在的ViewGroup打斷或消費了;
  2. 被其他View先消費了;
  3. 列表根據動畫是否正在播放決定能不能滑動;

我們再來仔細看一遍動畫。。。

那個動畫會不會是一個View呢?如果它是一個View的話,那列表不能滑動的原因就可能是2: 被這個View先消費了。

好,我們就先假設動畫是一個獨立的View,那這個View究竟做了什麼呢?
動畫看上去就像是新的顏色把舊的顏色擦掉了一樣。咦?等等,擦掉,哈哈哈,把舊的擦掉,這下是不是有種豁然開朗的感覺?我們就按著這個思路繼續想下去。

構思程式碼

  1. 還記不記得PorterDuff.Mode.CLEAR這個模式?用這個可以實現橡皮擦的效果,我們等下肯定是要用到它了。
  2. 既然是擦掉舊的東西,那就必須要先得到這個舊東西,哈哈,這個可以用getDrawingCache來獲取了。
  3. 我們剛剛假設它是一個View,也就是自定義的View了,那麼我們這個View也要先新增到對應視圖裡面才能顯示。



準備好上面的東西,我們這個動畫的流程也就很清晰了:
這次我們不打算做成寫死在佈局中那種,因為這樣不夠靈活,應該要做成動態新增和移除。

  1. 獲取根佈局的截圖;
  2. 新增自定義View到根佈局中;
  3. 把截圖draw出來;
  4. 把paint的Xfermode設定成PorterDuff.Mode.CLEAR;
  5. 以按鈕被點選的位置為起點,畫圓,並且不斷擴大這個圓的半徑;
  6. 直至這個圓足以覆蓋螢幕,停止動畫、從根佈局中移除、回撥播放完成的介面;



為了程式碼的美觀,我們可以將構造方法私有,然後公開一個靜態的create(View onClickView)方法,哈哈,只要傳入一個被點選的View就可以滿足我們播放動畫所需的條件了。


那麼我們要怎麼計算出這個圓究竟需要多大的半徑才能剛好覆蓋螢幕呢?因為按鈕的位置不可能是固定的,所以要動態地去計算這個所需要的半徑。


我們來看看下面這張圖:
這裡寫圖片描述


這個是開啟了開發者選項中的指標位置之後的效果,可以看到我們手指按下之後,螢幕被藍色線條分成了4個小矩形,我們可以分別計算出這些小矩形的對角線長度,再從中取最長的那條作為我們繪製的圓的半徑。那麼計算圓形半徑這個問題算是解決了。下面基本可以一路暢通地寫程式碼了。

動手寫程式碼

先是構造方法:

    private RippleAnimation(Context context, float startX, float startY, int radius) {
        super(context);
        //獲取activity的根檢視,用來新增本View
        mRootView = (ViewGroup) ((Activity) getContext()).getWindow().getDecorView();
        mStartX = startX;
        mStartY = startY;
        mStartRadius = radius;
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        //設定為擦除模式
        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
        updateMaxRadius();
        initListener();
    }

既然構造方法被私有了,那麼我們就要公開一個靜態的create方法:

    public static RippleAnimation create(View onClickView) {
        Context context = onClickView.getContext();
        int newWidth = onClickView.getWidth() / 2;
        int newHeight = onClickView.getHeight() / 2;
        //計算起點位置
        float startX = getAbsoluteX(onClickView) + newWidth;
        float startY = getAbsoluteY(onClickView) + newHeight;
        //起始半徑
        //因為我們要避免遮擋按鈕
        int radius = Math.max(newWidth, newHeight);
        return new RippleAnimation(context, startX, startY, radius);
    }

我們來看看獲取圓形半徑的程式碼怎麼寫:

    /**
     * 根據起始點將螢幕分成4個小矩形,mMaxRadius就是取它們中最大的矩形的對角線長度
     * 這樣的話, 無論起始點在螢幕中的哪一個位置上, 我們繪製的圓形總是能覆蓋螢幕
     */
    private void updateMaxRadius() {
        //將螢幕分成4個小矩形
        RectF leftTop = new RectF(0, 0, mStartX + mStartRadius, mStartY + mStartRadius);
        RectF rightTop = new RectF(leftTop.right, 0, mRootView.getRight(), leftTop.bottom);
        RectF leftBottom = new RectF(0, leftTop.bottom, leftTop.right, mRootView.getBottom());
        RectF rightBottom = new RectF(leftBottom.right, leftTop.bottom, mRootView.getRight(), leftBottom.bottom);
        //分別獲取對角線長度
        double leftTopHypotenuse = Math.sqrt(Math.pow(leftTop.width(), 2) + Math.pow(leftTop.height(), 2));
        double rightTopHypotenuse = Math.sqrt(Math.pow(rightTop.width(), 2) + Math.pow(rightTop.height(), 2));
        double leftBottomHypotenuse = Math.sqrt(Math.pow(leftBottom.width(), 2) + Math.pow(leftBottom.height(), 2));
        double rightBottomHypotenuse = Math.sqrt(Math.pow(rightBottom.width(), 2) + Math.pow(rightBottom.height(), 2));
        //取最大值
        mMaxRadius = (int) Math.max(
                Math.max(leftTopHypotenuse, rightTopHypotenuse),
                Math.max(leftBottomHypotenuse, rightBottomHypotenuse));
    }

create方法裡面有個getAbsoluteX和getAbsoluteY方法,這兩個方法分別是獲取view在螢幕中的x座標和y座標,為什麼要有這兩個方法呢,因為被點選的View所在的ViewGroup不一定top、left都是0的,所以如果我們直接獲取這個View的xy座標的話,是不夠的,還要加上它父容器的xy座標,我們要一直遞迴下去,這樣就能真正獲取到View在螢幕中的絕對座標了:

    /**
     * 獲取view在螢幕中的絕對x座標
     */
    private static float getAbsoluteX(View view) {
        float x = view.getX();
        ViewParent parent = view.getParent();
        if (parent != null && parent instanceof View) {
            x += getAbsoluteX((View) parent);
        }
        return x;
    }

    /**
     * 獲取view在螢幕中的絕對y座標
     */
    private static float getAbsoluteY(View view) {
        float y = view.getY();
        ViewParent parent = view.getParent();
        if (parent != null && parent instanceof View) {
            y += getAbsoluteY((View) parent);
        }
        return y;
    }

我們還要定義一個start方法,用來啟動動畫:

    public void start() {
        if (!isStarted) {
            isStarted = true;
            updateBackground();
            attachToRootView();
            getAnimator().start();
        }
    }

updateBackground方法就是更新螢幕截圖了,這個當然要從DecorView中獲取了:

    /**
     * 更新螢幕截圖
     */
    private void updateBackground() {
        if (mBackground != null && !mBackground.isRecycled()) {
            mBackground.recycle();
        }
        mRootView.setDrawingCacheEnabled(true);
        mBackground = mRootView.getDrawingCache();
        mBackground = Bitmap.createBitmap(mBackground);
        mRootView.setDrawingCacheEnabled(false);
    }

更新完截圖後,我們就要新增自身到根佈局中了:

    /**
     * 新增到根檢視
     */
    private void attachToRootView() {
        setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        mRootView.addView(this);
    }

我們呼叫addView方法之前還set了一個寬高都是MATCH_PARENT的LayoutParams,這樣我們的View就能遮擋住螢幕,然後把剛剛獲取到的截圖draw上去,以假亂真,哈哈:

    @Override
    protected void onDraw(Canvas canvas) {
        //在新的圖層上面繪製
        int layer = canvas.saveLayer(0, 0, getWidth(), getHeight(), null);
        canvas.drawBitmap(mBackground, 0, 0, null);
        canvas.drawCircle(mStartX, mStartY, mCurrentRadius, mPaint);
        canvas.restoreToCount(layer);
    }

我們的start方法中最後是呼叫了getAnimator().start(); 看看getAnimator方法裡面做了什麼:

    private ValueAnimator getAnimator() {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, mMaxRadius).setDuration(mDuration);
        valueAnimator.addUpdateListener(mAnimatorUpdateListener);
        valueAnimator.addListener(mAnimatorListener);
        return valueAnimator;
    }

就建立了一個ValueAnimator,動畫的起始值是0,結束值是mMaxRadius,也就是圓的最大半徑,我們的mCurrentRadius跟著動畫的值更新,那麼當我們的動畫播放完之後,mCurrentRadius就剛好等於mMaxRadius,也就剛好覆蓋螢幕了,這個時候我們也可以將自身從根佈局中移除了:

    private void initListener() {
        mAnimatorListener = new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                //動畫播放完畢, 移除本View
                detachFromRootView();
                if (mOnAnimationEndListener != null) {
                    mOnAnimationEndListener.onAnimationEnd();
                }
                isStarted = false;
            }
        };
        mAnimatorUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //更新圓的半徑
                mCurrentRadius = (int) (float) animation.getAnimatedValue() + mStartRadius;
                postInvalidate();
            }
        };
    }

    /**
     * 從根檢視中移除
     */
    private void detachFromRootView() {
        mRootView.removeView(this);
    }

對了,還應該有一個setDuration方法來設定動畫的時長:

    public RippleAnimation setDuration(long duration) {
        mDuration = duration;
        return this;
    }

哈哈,到這裡我們的RippleAnimation就基本完成了.


說說酷安這個效果的實現原理:

  1. 切換主題前先獲取當前螢幕截圖;
  2. 開始播放動畫;
  3. 切換主題;

哈哈, 不斷擴大的圓形就會把舊的螢幕截圖擦掉, 從而看到下面新的主題顏色, 這樣我們的炫酷效果就出來了, 哈哈哈

因為只能從create方法中獲取到RippleAnimation物件,所以我們的使用方法也是非常的簡單,並且只需一行程式碼就能播放了:

    public void onClick(View view) {
        RippleAnimation.create(view).setDuration(200).start();
        //在這裡切換主題
    }

我們寫一個demo來測試一下我們的勞動成果:
這裡寫圖片描述
哈哈哈,跟酷安客戶端的效果對比下:
這裡寫圖片描述
哈哈,基本就是這個效果了,其實我們的這個效果不一定只能用來做主題切換,還可以用來做其他介面切換的過渡,哈哈哈,這個大家可以發揮一下想象力,做出更多炫酷的動畫效果。

本文到此結束,有錯誤的地方請指出,謝謝大家!