Android 事件分發實例之右滑結束Activity(一) 總結

前言

以前就想着做一系列實際項目中用到關於事件分發的例子,以前研究過壁紙,有一種壁紙是音樂鎖屏壁紙,音樂鎖屏就需要用到右滑結束activity,遂想着研究事件分發不錯的例子,當時是使用ViewDraghelper實現的,不過部分事件分發沒處理好,此次正好處理一下。其實五一之前就做好一部分了,某一天清桌面誤刪文件,導致現在做的是重新做的,還有另一種方式放下一篇敘述。

分析階段

  • 一:需要一個通用的ViewGroup隨着手勢右滑滾動
  • 二:在Move事件結束之後,如果超過閾值就關閉當前ViewGroup,否者回歸到默認位置
  • 三:滑動結束之後通過所處位置來判定當前activity的是否結束狀態
  • 四:需要當前ViewGroup包裹activity的佈局一起滑動
  • 五:滑動結束之後需要符合系統默認動畫,平滑過渡
  • 六:滑動過程中需要下層activity顯示
  • 七:右滑事件處理和事件衝突處理

通過以上七點分析,因此可以指定步驟,按照步驟一步一步解決即可。需要平滑滾動,選擇Scroller開實現平滑滾動效果,第四點的話,需要把自定義的ViewGroup添加到DecorView第一個位置,這樣即可實現包裹activity的佈局。具體步驟如下:

具體步驟

事件分發與消費

@Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean interceptd = false;

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                interceptd = false;
                mDownX = event.getX();
                mDownY = event.getY();
                break;

            case MotionEvent.ACTION_MOVE:
                //計算移動距離 判定是否滑動
                float dx = event.getX() - mDownX;
                float dy = event.getY() - mDownY;
                if (dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop ) {
                    interceptd = true;
                } else {
                    interceptd = false;
                }

                break;

            case MotionEvent.ACTION_UP:
                interceptd = false;
                break;
        }

        return interceptd;
    }
@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                float dx = event.getX() - mDownX;
                if (getScrollX() - dx >= 0) {
                    scrollTo(0, 0);
                } else {
                    scrollBy((int) -dx, 0);
                }

                mDownX = event.getX();
                break;
            case MotionEvent.ACTION_UP:
                // 根據手指釋放時的位置決定回彈還是關閉
                int scrollX = getScrollX();
                if (-scrollX < getWidth() * mSlideFinishRadio) {
                    smoothScrollX(scrollX, -scrollX, smoothscrollTime, mScroller);
                } else {
                    smoothScrollX(scrollX, -scrollX - getWidth(), smoothscrollTime, mScroller);
                }
                break;
        }
        return true;
    }
/**
     * 平滑的滾動到某個位置
     *
     * @param startX    開始位置
     * @param endX      結束位置
     * @param duration  時間
     * @param mScroller
     */
    private void smoothScrollX(int startX, int endX, int duration, Scroller mScroller) {
        mScroller.startScroll(startX, 0, endX, 0, duration);
        invalidate();
    }

說明:1、dx > minTouchSlop:這個條件判定右滑,並且超過系統能檢測到的最小滑動距離
2、dx - Math.abs(dy) > minTouchSlop:右滑優先級高於上下滑動,(亦可dx - Math.abs(dy) > 0)
3、getScrollX() - dx >= 0:判定滑動是否超越左邊界,超過的話滾動到默認位置(0,0)
4、scrollBy((int) -dx, 0):通過不斷修改當前的位置去滾到相應的位置
5、-scrollX < getWidth() * mSlideFinishRadio:判斷當前的滾動的位置與設置的閾值大小來斷定最終位置
6、mScroller.startScroll(startX, 0, endX, 0, duration):開啓鬆手之後滾動到指定位置(具體參數意思已經註釋)

下面方法主要是處理Scroller平滑滾動過程,

@Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), 0);
            postInvalidate();
        } else if (-getScrollX() >= getWidth()) {
            mActivity.finish();
        }
    }

說明:mScroller.computeScrollOffset() :只要scrollTo()的過程沒完成,此方法的回調一直爲true,通過不斷的調用scrollTo()和postInvalidate()(線程安全的方法)去刷新界面,如果滑動結束,並且左邊界滑動的最右邊的時候結束activity

綁定activity

最終我們的效果是實現右滑到一定閾值結束當前的activity,因此需要把當前的viewgroup加入當前activity所在的DecorView 中.

 /**
     * 綁定Activity
     */
    public void attachActivity(Activity activity) {
        mActivity = activity;
        ViewGroup decorView = (ViewGroup) mActivity.getWindow().getDecorView();
        View child = decorView.getChildAt(0);
        child.setBackgroundResource(android.R.color.white);
        decorView.removeView(child);
        addView(child);
        decorView.addView(this);
    }

說明: child.setBackgroundResource(android.R.color.white),爲什麼要設置背景色?暫且留着後面會說因爲說明原因

設置樣式

通過以上步驟已經可以實現滑動結束activity過程,但是滑動過程中,背景一直爲白色,結束之後並且會有突兀的動畫效果。爲了實現滑動過程中有漸變的效果,遂設置當前window背景透明色,但是如果這樣設置的話,當前activity在不滑動過程中也是透明狀態,因此需要給activity佈局設置一個背景色,上面attachActivity()設置白色就是爲了統一設置,避免每次都去設置activity的根佈局顏色。設置樣式如下:

<!--<item name="android:windowFullscreen">true</item>-->
    <style name="SlideTheme" parent="@style/AppTheme">
        <!--Required-->
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:windowNoTitle">true</item>
        <item name="android:windowAnimationStyle">@style/SlideAnimation</item>
    </style>

說明:1、windowAnimationStyle的作用是滑動超過閾值之後結束activity的動畫與設置的滑動效果一致

windowIsTranslucent

這裏單獨說一下這個屬性windowIsTranslucent,先說這個屬性的作用,如果windowIsTranslucent爲false的話,無論windowBackground設置的是什麼顏色,此時window背景都不可能爲透明色,因此兩者要搭配使用纔有效果。windowBackground最終設置方法在源碼DecorView的裏面,也就是當前activity的最底層的背景色。下面是設置DecorView背景色在源碼中的方法。

設置背景色

public void setWindowBackground(Drawable drawable) {
    if (getBackground() != drawable) {
        setBackgroundDrawable(drawable);
        if (drawable != null) {
            mResizingBackgroundDrawable = enforceNonTranslucentBackground(drawable,
                    mWindow.isTranslucent() || mWindow.isShowingWallpaper());
        } else {
            mResizingBackgroundDrawable = getResizingBackgroundDrawable(
                    getContext(), 0, mWindow.mBackgroundFallbackResource,
                    mWindow.isTranslucent() || mWindow.isShowingWallpaper());
        }
        if (mResizingBackgroundDrawable != null) {
            mResizingBackgroundDrawable.getPadding(mBackgroundPadding);
        } else {
            mBackgroundPadding.setEmpty();
        }
        drawableChanged();
    }
}
/**
 * Enforces a drawable to be non-translucent to act as a background if needed, i.e. if the
 * window is not translucent.
 */
private static Drawable enforceNonTranslucentBackground(Drawable drawable,
        boolean windowTranslucent) {
    if (!windowTranslucent && drawable instanceof ColorDrawable) {
        ColorDrawable colorDrawable = (ColorDrawable) drawable;
        int color = colorDrawable.getColor();
        if (Color.alpha(color) != 255) {
            ColorDrawable copy = (ColorDrawable) colorDrawable.getConstantState().newDrawable()
                    .mutate();
            copy.setColor(
                    Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)));
            return copy;
        }
    }
    return drawable;
}

事件衝突處理

上述步驟已經可以實現右滑結束activity效果了,但是未提及分析過程中的第七條。主要是因爲事件滑動衝突問題處理是Android系統事件分發中最難處理的一塊,因此放在最後處理,即嵌套滑動衝突問題。關於嵌套滑動,大家應該都不默認,剛接觸那會估計都被ScrollView裏面嵌套ListView或者RecyclerView困擾過,因爲兩者都是可以滾動的,到底滑動事件分發應該怎麼做呢,理想情況下是列表滾動到頭部或者尾部再把事件交給ScrollView處理,但是實際情況是,要麼是兩者同時滾動要麼是列表只顯示一部分。這是因爲ScrollView源碼裏也是使用Scroller來實現滑動,因此ScrollView的第一層子類只能有一個,通過遍歷,測量所有子View的寬和高,而ListView和RecyclerView內部都是使用緩存複用機制,因此ScrollView並不能一次性測量到所有的ListView或RecyclerView的item。網上有很多關於解決ScrollView嵌套ListView或RecyclerView的方案,其核心思想還是測量出所有ListView或RecyclerView的子類的寬高,因此導致ListView或RecyclerView緩存複用機制無效,谷歌也是不建議這樣做,因此最好不要做嵌套。迴歸主題,如果側滑的activity裏面有ViewPager會怎麼樣?沒錯,因爲自定義的攔截條件約束在:

dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop

假如現在ViewPager不是處於第一項,自定義的側滑ViewGroup如果滿足上面判定攔截條件,ViewPager的滾動機制一定會被攔截掉,一直響應側滑。但是理想情況下,希望ViewPager右滑的過程中滑動到左邊第一項的時候再被攔截。因爲ViewPager內部肯定是處理了滑動事件,因此可以參ViewPager內部怎麼處理方式。ViewPager內部也是通過Scroller來處理滑動過程的,查看ViewPager源碼有沒有檢測滑動的方法,如下所示:

protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {
        if (v instanceof ViewGroup) {
            final ViewGroup group = (ViewGroup) v;
            final int scrollX = v.getScrollX();
            final int scrollY = v.getScrollY();
            final int count = group.getChildCount();
            // Count backwards - let topmost views consume scroll distance first.
            for (int i = count - 1; i >= 0; i--) {
                // TODO: Add versioned support here for transformed views.
                // This will not work for transformed views in Honeycomb+
                final View child = group.getChildAt(i);
                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight()
                        && y + scrollY >= child.getTop() && y + scrollY < child.getBottom()
                        && canScroll(child, true, dx, x + scrollX - child.getLeft(),
                                y + scrollY - child.getTop())) {
                    return true;
                }
            }
        }

        return checkV && v.canScrollHorizontally(-dx);
    }

ViewPager內部也是通過遍歷所有子View的滾動方向,然後調用v.canScrollHorizontally(-dx)來判定水平方向上是否有可以滾動的子View。主要研究v.canScrollHorizontally(-dx),此方法是View的可重寫方法。

 /**
     * Check if this view can be scrolled horizontally in a certain direction.
     *
     * @param direction Negative to check scrolling left, positive to check scrolling right.
     * @return true if this view can be scrolled in the specified direction, false otherwise.
     */
    public boolean canScrollHorizontally(int direction) {
        final int offset = computeHorizontalScrollOffset();
        final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }

說明:1、參數direction的意思是:檢查向左滾動爲負,檢查向右滾動爲正。(左滾動是從左到右,scrollx爲負值)
2、返回值的意思:如果這個視圖可以在指定的方向上滾動,則返回true,否則返回false。
3、由於computeHorizontalScrollRange() 與computeHorizontalScrollExtent()方法的返回值調用同一個方法,因此方法返回值默認爲false,後面會用到。

因爲之前研究刷新控件知道的這個方法,當然如果activity裏面只是ViewPager,通過調ViewPager重寫的canScrollHorizontally(int direction) 即可實現想要的效果,但是activity裏面也肯存在其他列表(ListView、RecyclerView、ScrollView等)),因此需要遍歷activity裏面View樹,只要有一個View或者ViewGroup可以在從左到右的方向上滾動,就不去攔截子類的右滑事件。所有的View或者ViewGroup的返回值都爲false才把右滑事件交給SlideLayout處理。因此採用遞歸方式遍歷所有的View樹結構:

/**
     * 是否左右可以滾動
     *
     * @param direction
     * @param view
     */
    private boolean canScrollHorizontally(int direction, View view) {
        if (view.canScrollHorizontally(direction)) {
            return true;
        } else {
            if (view instanceof ViewGroup) {
                ViewGroup viewParent = (ViewGroup) view;
                int childCount = viewParent.getChildCount();

                for (int i = 0; i < childCount; i++) {
                    View chideView = viewParent.getChildAt(i);
                    boolean childCanScroll = canScrollHorizontally(direction, chideView);
                    if (childCanScroll) {
                        return true;
                    }
                }
            }

            return false;
        }
    }

說明:因爲要遍歷所有的View樹,並且canScrollHorizontally()方法默認返回值是false,因此必須重寫。(記得要再加個判空)

測試適配

完成上述步驟,遂做各種情況的適配,Android系統可以滾動的列表基本都實現了canScrollHorizontally(),目前爲止測試瞭如下圖所示的情況:

如上圖所示,通過上圖所有測試,發現兩個不適配,倒數第二個是前陣子做的Android 事件分發實例之可拖動的ViewGroup,因爲之前沒想到做這個側滑適配,因此不匹配,適配方案如下:
下面是onTouchEvent()中的Down事件,如果處於右邊緣,canScrollHorizontally()方法右滑返回值爲false,如果處於左邊緣,

case MotionEvent.ACTION_DOWN:
                float rightX = mParentWidth - getWidth();
                float x = getX();
                canScrollH = x != rightX;
                canScrollH2 = x != 0;

                break;

目前已經更新,可適配。另一個不適配的是最後一個DrawerLayout,這個較爲特殊,內部維持了一個WindowInsets集合,不同的View可以通過注入的方式添加到DrawerLayout裏面,這個瞭解的不多,這個只支持SDK21以上版本,WindowInsetsCompat是其兼容版本,有空研究一下這個。接着說DrawerLayout是通過ViewDragHelper處理事件分發。

@SuppressWarnings("ShortCircuitBoolean")
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = ev.getActionMasked();

        // "|" used deliberately here; both methods should be invoked.
        final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev)
                | mRightDragger.shouldInterceptTouchEvent(ev);

        boolean interceptForTap = false;

        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                final float x = ev.getX();
                final float y = ev.getY();
                mInitialMotionX = x;
                mInitialMotionY = y;
                if (mScrimOpacity > 0) {
                    final View child = mLeftDragger.findTopChildUnder((int) x, (int) y);
                    if (child != null && isContentView(child)) {
                        interceptForTap = true;
                    }
                }
                mDisallowInterceptRequested = false;
                mChildrenCanceledTouch = false;
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                // If we cross the touch slop, don't perform the delayed peek for an edge touch.
                if (mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL)) {
                    mLeftCallback.removeCallbacks();
                    mRightCallback.removeCallbacks();
                }
                break;
            }

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP: {
                closeDrawers(true);
                mDisallowInterceptRequested = false;
                mChildrenCanceledTouch = false;
            }
        }

        return interceptForDrag || interceptForTap || hasPeekingDrawer() || mChildrenCanceledTouch;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        mLeftDragger.processTouchEvent(ev);
        mRightDragger.processTouchEvent(ev);

        final int action = ev.getAction();
        boolean wantTouchEvents = true;

        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN: {
                final float x = ev.getX();
                final float y = ev.getY();
                mInitialMotionX = x;
                mInitialMotionY = y;
                mDisallowInterceptRequested = false;
                mChildrenCanceledTouch = false;
                break;
            }

            case MotionEvent.ACTION_UP: {
                final float x = ev.getX();
                final float y = ev.getY();
                boolean peekingOnly = true;
                final View touchedView = mLeftDragger.findTopChildUnder((int) x, (int) y);
                if (touchedView != null && isContentView(touchedView)) {
                    final float dx = x - mInitialMotionX;
                    final float dy = y - mInitialMotionY;
                    final int slop = mLeftDragger.getTouchSlop();
                    if (dx * dx + dy * dy < slop * slop) {
                        // Taps close a dimmed open drawer but only if it isn't locked open.
                        final View openDrawer = findOpenDrawer();
                        if (openDrawer != null) {
                            peekingOnly = getDrawerLockMode(openDrawer) == LOCK_MODE_LOCKED_OPEN;
                        }
                    }
                }
                closeDrawers(peekingOnly);
                mDisallowInterceptRequested = false;
                break;
            }

            case MotionEvent.ACTION_CANCEL: {
                closeDrawers(true);
                mDisallowInterceptRequested = false;
                mChildrenCanceledTouch = false;
                break;
            }
        }

        return wantTouchEvents;
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        if (CHILDREN_DISALLOW_INTERCEPT
                || (!mLeftDragger.isEdgeTouched(ViewDragHelper.EDGE_LEFT)
                        && !mRightDragger.isEdgeTouched(ViewDragHelper.EDGE_RIGHT))) {
            // If we have an edge touch we want to skip this and track it for later instead.
            super.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
        mDisallowInterceptRequested = disallowIntercept;
        if (disallowIntercept) {
            closeDrawers(true);
        }
    }

因此DrawerLayout並不需要處理canScrollHorizontally(direction)這個方法,爲了兼容,需要自行處理如下:

自定義LeftDrawerLayout繼承DrawerLayout,在分發事件處理。

@Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                if (mDownX <= mEdgeSize) {
                    canScrollH = !isDrawerOpen(GravityCompat.START);
                } else {
                    canScrollH = false;
                }
                break;

        }
        return super.dispatchTouchEvent(event);
    }

疑惑:1、爲什麼在dispatchTouchEvent()方法裏面處理,而不是onInterceptTouchEvent()方法裏處理
2、mDownX <= mEdgeSize爲什麼有這個判斷

解答疑惑:首先說明一個問題,一個事件總是包含三個Down、Move、Up的,偶爾還有Cancle。只要有一個Down流向某一塊,其他Move、Up也會流向到某個,舉個例子:客廳的燈是一條電路線,這條線中包含火線、零線、地線,客廳的燈不需要地線,但是地線也是跟着火線、零線綁定在一起流向客廳的燈的。此時來解釋第一個問題,爲什麼判斷條件放在dispatchTouchEvent()裏面,那是因爲onInterceptTouchEvent()交給ViewDragHelper處理,因此重寫onInterceptTouchEvent()是無法監聽到Move事件的,也就不難作出判斷。
解決第二個疑問,mEdgeSize是ViewDragHelper檢測邊緣的固定值(20dp),isDrawerOpen(GravityCompat.START)這個方法是檢測DrawerLayout的抽屜視圖是否打開狀態,按下位置在左邊緣的話有兩種情況,一種是:如果抽屜視圖是打開狀態,則交給側滑,第二種是:如果關閉狀態則交給ViewDragHelper處理。

特此說明:代碼中暫時只處理左邊抽屜視圖情況,右邊抽屜視圖情況同理解決。

總結

關於本篇實現右滑結束activity的方式,重點有四點,第一點:實現攔截, 第二點實現平滑滾動,第三點:遞歸遍歷子View是否可以右滑,第四點:樣式處理。具體情況還需參考代碼。其他細節,如側滑過程中,右邊緣和背景的繪製等暫時沒做特殊處理,現在還在做進一步的封裝,後續會持續更新最新代碼。
右滑結束Activity

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章