天天看點

Android L中水波紋點選效果的實作

部落客參加了2014 CSDN部落格之星評選,幫我投一票吧。

點選給我投票

前言

前段時間android L(android 5.0)出來了,界面上做了一些改動,主要是添加了若幹動畫和一些新的控件,相信大家對view的點選效果-水波紋很有印象吧,點選一個view,然後一個水波紋就會從點選處擴散開來,本文就來分析這種效果的實作。首先,先說下L上的實作,這種波紋效果,L上提供了一種動畫,叫做Reveal效果,其底層是通過拿到view的canvas然後不斷重新整理view來完成的,這種效果需要view的支援,而在低版本上沒有view的支援,是以,Reveal效果沒法直接在低版本運作。但是,我們了解其效果、其原理後,還是可以通過模拟的方式去實作這種效果,平心而論,寫出一個具有波紋效果的自定義view不難,或者說很簡單,但是,view的子類很多,如果要一一去實作button、edit等控件,這樣比較繁瑣,于是,我們想是否有更簡單的方式呢?其實是有的,我們可以寫一個自定義的layout,然後讓layout中所有可點選的元素都具有波紋效果,這樣做,就大大簡化了整個過程。接下來本文就會分析這個layout的實作,在此之前,我們先看下效果。

Android L中水波紋點選效果的實作

實作思想

首先我們自定義一個layout,這裡我們選取LinearLayout,至于原因,文章下面會進行分析。當使用者點選一個可點選的元素時,比如button,我們需要得到使用者點選的元素的資訊,包含:使用者點選了哪個元素、使用者點選的那個元素的寬、高、位置資訊等。得到了button的資訊後,我就可以确定水波紋的範圍,然後通過layout進行重繪去繪制水波紋,這樣水波紋效果就實作了,當然,這隻是大概步驟,中間還是有一些細節需要處理的。

layout的選取

既然我們打算實作一個自定義layout,那我們要選取那個layout呢,LinearLayout、RelativeLayout、FrameLayout?我這裡選用LinearLayout。為什麼呢?也許有人會問,不應該用RelativeLayout嗎?因為RelativeLayout比較強大,可以實作複雜的布局,但LinearLayout和FrameLayout就不行。沒錯,RelativeLayout是強大,但是考慮到水波效果是通過頻繁重新整理layout來實作的,由于頻繁重繪,是以,我們要考慮性能問題,RelativeLayout的性能是最差的(因為做的事情多),因為,為了性能,我們選擇LinearLayout,至于FrameLayout,它功能太簡單了,不太适合使用。當實作複雜布局的時候,我們可以在具有波紋效果的元素外部包裹LinearLayout,這樣重繪的時候不至于有過重的任務。

根據上面的分析,我們定義如下的layout:

public class RevealLayout extends LinearLayout implements Runnable

實作過程

實作過程主要是如下幾個問題的解決:

1. 如何得知使用者點選了哪個元素

2. 如何取得被點選元素的資訊

3. 如何通過layout進行重繪繪制水波紋

4. 如果延遲up事件的分發

下面一一進行分析

如何得知使用者點選了哪個元素

這個問題好弄,為了得知使用者點選了哪個元素(這個元素一般來說要是可點選的,否則是無意義的),我們要提前攔截所有的點選事件,于是,我們應該重寫layout中的dispatchTouchEvent方法,注意,這裡不推薦用onInterceptTouchEvent,因為onInterceptTouchEvent不是一直會被回調的,具體原因請參看我之前寫的view系統解析系列。然後當使用者點選的時候,會有一系列的down、move、up事件,我們要在down的時候來确定事件落在哪個元素上,down的元素就是使用者點選的元素,當然為了嚴謹,我們還要判斷up的時候是否也落在同一個元素上面,因為,系統click事件的判斷規則就是:down和up同時落在同一個可點選的元素上。

@Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getRawX();
        int y = (int) event.getRawY();
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            View touchTarget = getTouchTarget(this, x, y);
            if (touchTarget.isClickable() && touchTarget.isEnabled()) {
                mTouchTarget = touchTarget;
                initParametersForChild(event, touchTarget);
                postInvalidateDelayed(INVALIDATE_DURATION);
            }
        } else if (action == MotionEvent.ACTION_UP) {
            mIsPressed = false;
            postInvalidateDelayed(INVALIDATE_DURATION);
            mDispatchUpTouchEventRunnable.event = event;
            postDelayed(mDispatchUpTouchEventRunnable, 400);
            return true;
        } else if (action == MotionEvent.ACTION_CANCEL) {
            mIsPressed = false;
            postInvalidateDelayed(INVALIDATE_DURATION);
        }

        return super.dispatchTouchEvent(event);
    }
           

通過上述代碼,我們可以知道,當down的時候,我們取出點選事件的螢幕坐标,然後去周遊view樹找到使用者所點選的那個view,代碼如下,就是判斷事件的坐标是否落在view的範圍内,這個不再多說了,比較好了解。需要注意的是,事件的坐标我們不能用getX和getY,而要用getRawX和getRawY,二者的差別是:前者是相對于被點選view的坐标,後者是相對于螢幕的坐标,而我們的目标view具體位于layout的哪一層我們無法知道,是以,必須用螢幕的絕對坐标來進行計算。而有了事件的坐标,再根據view在螢幕中的絕對坐标,隻要判斷事件的xy是否落在view的上下左右四個角之内,就可以知道事件是否落在view上,進而取出使用者所點選的那個view。

private View getTouchTarget(View view, int x, int y) {
        View target = null;
        ArrayList<View> TouchableViews = view.getTouchables();
        for (View child : TouchableViews) {
            if (isTouchPointInView(child, x, y)) {
                target = child;
                break;
            }
        }

        return target;
    }

    private boolean isTouchPointInView(View view, int x, int y) {
        int[] location = new int[2];
        view.getLocationOnScreen(location);
        int left = location[0];
        int top = location[1];
        int right = left + view.getMeasuredWidth();
        int bottom = top + view.getMeasuredHeight();
        if (view.isClickable() && y >= top && y <= bottom
                && x >= left && x <= right) {
            return true;
        }
        return false;
    }
           

如何取得被點選元素的資訊

這個比較簡單,被點選元素的資訊有:寬、高、left、top、right、bottom,擷取它們的代碼如下:

int[] location = new int[2];
        mTouchTarget.getLocationOnScreen(location);
        int left = location[0] - mLocationInScreen[0];
        int top = location[1] - mLocationInScreen[1];
        int right = left + mTouchTarget.getMeasuredWidth();
        int bottom = top + mTouchTarget.getMeasuredHeight();
           

說明:mTouchTarget指的是使用者點選的那個view

如何通過layout進行重繪繪制水波紋

這個會水波紋比較簡單,隻要用drawCircle繪制一個半透明的圓環即可,這裡主要說下繪制時機。一般來說,我們會選擇在onDraw中去進行繪制,這是沒錯的,但是對于L中的效果不太适合,檢視view的繪制過程,我們會明白,view的繪制大緻遵循如下流程:先繪制背景,再繪制自己(onDraw),接着繪制子元素(dispatchDraw),最後繪制一些裝飾等比如滾動條(onDrawScrollBars),是以,如果我們在onDraw中繪制波紋,那麼由于子元素的繪制在onDraw之後,就會導緻子元素蓋住我們所繪制的圓環,這樣,圓環就有可能看不全了,因為,把我繪制的時機很重要。根據view的繪制流程,我們選擇dispatchDraw比較合适,當所有的子元素都繪制完成後,再進行波紋的繪制。讀到這裡,大家會更加明白,為什麼我們要選擇LinearLayout以及為什麼不建議view的嵌套層級太深,因為如果view本身比較重或者嵌套層級太深,就會導緻dispatchDraw執行的耗時增加,這樣水波的繪制就會收到些許影響。是以,性能的平滑在代碼中也很重要,也是需要考慮的。同時,為了不讓繪制的圓環超出被點選元素的範圍,我們需要對canvas進行clip。為了有波紋效果,我們需要頻繁地進行layout重繪,并且在重繪的過程中改變圓環的半徑,這樣一個動态的水波紋就出來了。仍然,我來性能的考慮,我們選擇用postInvalidateDelayed(long delayMilliseconds, int left, int top, int right, int bottom)來進行view的部分重繪,因為,其他區域是不需要重繪的,僅僅是被點選的元素所在的區域需要重繪。為什麼要采用Delayed這個方法,原因是我們不能一直進行重新整理,必須有一點點時間間隔,這樣做的好處是:避免view的重繪搶占過多時間片進而造成潛在的間接棧溢出,因為invalidate會直接導緻draw的調用。

具體代碼如下:

protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        if (!mShouldDoAnimation || mTargetWidth <= 0 || mTouchTarget == null) {
            return;
        }

        if (mRevealRadius > mMinBetweenWidthAndHeight / 2) {
            mRevealRadius += mRevealRadiusGap * 4;
        } else {
            mRevealRadius += mRevealRadiusGap;
        }
        int[] location = new int[2];
        mTouchTarget.getLocationOnScreen(location);
        int left = location[0] - mLocationInScreen[0];
        int top = location[1] - mLocationInScreen[1];
        int right = left + mTouchTarget.getMeasuredWidth();
        int bottom = top + mTouchTarget.getMeasuredHeight();

        canvas.save();
        canvas.clipRect(left, top, right, bottom);
        canvas.drawCircle(mCenterX, mCenterY, mRevealRadius, mPaint);
        canvas.restore();

        if (mRevealRadius <= mMaxRevealRadius) {
            postInvalidateDelayed(INVALIDATE_DURATION, left, top, right, bottom);
        } else if (!mIsPressed) {
            mShouldDoAnimation = false;
            postInvalidateDelayed(INVALIDATE_DURATION, left, top, right, bottom);
        }
    }
           

到此為止,這個layout我們已經實作了,但是細心的你,一定會發現,還有什麼不妥的地方。比如,你可以給button加一個點選事件,當button被點選的時候起一個activity,很快你就會發現問題所在了:水波還沒播完呢,activity就起來了,導緻水波效果大打折扣,而仔細觀察android L的效果,我們發現,L中總是要等到水波效果播放完畢才會進行下一步的行為。是以,最後一個待解決的問題也就出來了,請看下面的分析

如何延遲up事件的分發

針對上面所說的問題,如果我們能夠延遲up時間的分發,比如延遲400ms,這樣水波就有足夠的時間去播放完畢,然後再分發up事件,這樣就可以解決問題。最開始,我的确是這樣做的,先看如下的代碼:

else if (action == MotionEvent.ACTION_UP) {
            mIsPressed = false;
            postInvalidateDelayed(INVALIDATE_DURATION);
            mDispatchUpTouchEventRunnable.event = event;
            postDelayed(mDispatchUpTouchEventRunnable, 400);
            return true;
        } 
           

可以發現,當up的時候,我并沒有直接走系統的分發流程,隻是強行消耗點up事件然後再延遲分發,請看代碼:

private class DispatchUpTouchEventRunnable implements Runnable {
        public MotionEvent event;

        @Override
        public void run() {
            if (mTouchTarget == null || !mTouchTarget.isEnabled()) {
                return;
            }

            if (isTouchPointInView(mTouchTarget, (int)event.getRawX(), (int)event.getRawY())) {
                mTouchTarget.dispatchTouchEvent(event);
            }
        }
    };
           

到此為止,上述幾個問題都已經分析完畢了,我們就可以輕易地實作水波紋的點選效果了。

源碼下載下傳

本文中的demo源碼暫時未開放到網際網路上,請加群 215680213 ,在群共享中下載下傳源碼。