Android 手把手進階自定義View(十六)- 滑動衝突

一、前言


在界面中,只要內外兩層同時可以滑動就會產生滑動衝突。而滑動衝突的解決都是由固定的套路的,下面我們來學習一下。

 

二、常見的滑動衝突場景


常見的滑動衝突場景可以簡單的分爲如下三種:

場景1、外部滑動方向與內部滑動方向不一致

比如 ViewPager 中有多個 Fragment,而 Fragment 往往有一個 ListView。這時 ViewPager 可以左右滑動,而 ListView 可以上下滑動,這就造成了滑動衝突。注意這裏只是舉個例子,事實上 ViewPager 內部已經處理了這種滑動衝突,在採用 ViewPager時,我們無需關注這個問題。但如果我們採用的是 ScrollView 等,那就必須手動處理滑動衝突了。否則就會造成內外兩層只有一層能夠滑動。

場景2、外部滑動方向和內部滑動方向一致

這種情況稍微複雜一點,因爲當我們的手指開始滑動的時候,系統無法知道用戶到底是想讓哪一層滑動,所以就會出現問題,要麼只有一層能夠滑動,要麼就是內外兩層都滑動得很卡頓。

場景3、上面兩種場景的嵌套

看起來更復雜,但是它是幾個單一得滑動衝突得疊加,因此只要分別處理即可。

 

三、處理規則


一般來說,不管滑動衝突多麼複雜,它都有既定得規則,根據這些我們就可以選擇合適的方法去處理。

對於場景1,當用戶左右滑動時,讓外部的 View 攔截點擊事件,當用戶上下滑動時,讓內部的View攔截點擊事件。這個時候我們就可以根據它們的特徵來解決滑動衝突,具體來講就是根據滑動是水平滑動還是豎直滑動來判斷到底由誰來攔截事件。我們可以這麼判斷用戶的滑動方向,如果用戶手指滑動的水平距離大於垂直距離,則左右滑動,反之則上下滑動。還可以根據滑動的角度、速度差來做判斷。

場景2的處理規則比較特殊,無法根據滑動的角度、距離差、速度差來判斷。因爲場景2內部、外部的滑動方向一致。這時候一般都能在業務上找到突破點,如業務上規定:當處於某種狀態需要外部View響應用戶的滑動,而處於另一種狀態時則需要內部View響應用戶的滑動,所以我們可以根據業務的需求得出相應的處理規則。

場景3的處理規則就是將場景1的處理規則和場景2的處理規則一起使用。

 

四、解決方式


4.1、外部攔截法

所謂外部攔截法是指點擊事件都先經過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要此事件就不攔截,這樣就可以解決滑動衝突的問題,這種事件比較符合點擊事件的分發機制。外部攔截法需要重寫父容器的 onInterceptTouchEvent 方法,在內部做相應的攔截即可,這種方法的僞代碼如下:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int)ev.getX();
        int y = (int)ev.getY();
 
        switch (ev.getAction()){ 
            case MotionEvent.ACTION_DOWN:
                //父容器不需要攔截
                intercepted = false;
                break; 
            case MotionEvent.ACTION_MOVE:
                //在此判斷父容器是否需要攔截
                if (父容器需要當前點擊事件){
                    intercepted = true;
                }else {
                    //父容器不需要當前的點擊事件
                    intercepted = false;
                }
                break; 
            case MotionEvent.ACTION_UP:
                //父容器不需要攔截
                intercepted = false;
                break;
            default:
                break;
        }
        //重置手指的起始位置
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

上述代碼是外部攔截法的典型邏輯,針對不同的滑動衝突,只需要修改父容器需要當前點擊事件這個條件即可。這裏再描述一下上述代碼:

在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 事件,父容器必須返回 false,即父容器不攔截 ACTION_DOWN 事件。因爲一旦父容器攔截了 ACTION_DOWN 事件,那麼後續的 ACTION_MOVE、ACTION_UP 事件都會直接交給父容器處理,這個時候事件無法傳遞給子元素了。其次是 ACTION_MOVE 事件,在這裏可以根據我們的需求來決定父容器是否需要攔截事件,需要則返回 true,否則返回 false。最後是 ACTION_UP 事件,這裏必須返回 false,即父容器不攔截 ACTION_UP 事件,首先我們要知道 onClick 事件是在 ACTION_UP 事件之後執行的,當子元素有一個onClick事件,而這時候父容器攔截了 ACTION_UP 事件,那子元素的 onClick 事件就無法執行了。

4.1、內部攔截法

首先我們瞭解下 ViewGroup.requestDisallowInterceptTouchEvent(boolean) 方法,此方法就是在子 View 中通知父容器攔截或不攔截點擊事件,false - 攔截,true - 不攔截。

內部攔截法的思想是指父容器先不攔截任何事件,即所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗掉,否則就交由父容器進行處理。這種方法和 Android 中的事件分發機制不一致,需要配合 requestDisallowInterceptTouchEvent 方法。具體做法是我們重寫子 View 的 dispatchTouchEvent 方法,僞代碼如下:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //獲得當前的位置座標
        int x = (int) ev.getX();
        int y = (int) ev.getY();
 
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN: 
                //通知父容器不要攔截事件
                panrentLayout.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (父容器需要此事件){
                    //通知父容器攔截此事件
                    parentLayout.requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
 
        //重置手指的初始位置
        mLastY = y;
        mLastX = x; 
        return super.dispatchTouchEvent(ev);
    }

上述代碼是內部攔截法的典型代碼,當面對不同的滑動策略時只需要修改裏面的條件即可。除了子元素需要做處理以外,父元素也要默認攔截除了 ACTION_DOWN 以外的其他事件,這樣當子元素調用 parentLayout.requestDisallowInterceptTouchEvent(false) 方法時,父元素才能繼續攔截所需的事件。

那父容器爲什麼不能攔截 ACTION_DOWN 事件呢?這是因爲 ACTION_DOWN 事件不受 FLAG_DISALLOW_INTERCEPT 這個標記位的控制,所以一旦父容器攔截了 ACTION_DOWN 事件,那麼所有的事件都無法傳遞給子元素了,這樣內部攔截就起不到作用了。父容器需要做的修改如下:

public boolean onInterceptTouchEvent(MotionEvent event){
    int action = event.getAction();
    if(action == MotionEvent.ACTION_DOWN){
        return false;
    }else{
        return true;
    }
}

 

五、實例說明


下面通過一個實例來分別介紹這兩種寫法。我們來實現一個類似於 ViewPager 中嵌套 ListView 的效果,爲了製造滑動衝突,我們寫一個類似於 ViewPager 的控件即可。爲了實現 ViewPager 的效果,我們定義了一個類似於水平的 LinearLayout 的 ViewGroup,只不過它可以水平滑動,初始化時我們在它的內部添加若干個豎直滾動的 ListView,因此一個典型的滑動衝突場景就出現了。根據滑動策略,我們可以選擇水平和豎直的滑動距離差來解決滑動衝突。

我們先來看看外部攔截法實現:

public class HorizontalScrollLayout extends ViewGroup {
    private static final String TAG = "HorizontalScrollLayout";
    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;
    // 分別記錄上次滑動的座標
    private int mLastX = 0;
    private int mLastY = 0;
    // 分別記錄上次滑動的座標(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;
    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
 
    public HorizontalScrollLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
 
    private void init() {
        mScroller = new Scroller(getContext());
        mVelocityTracker = VelocityTracker.obtain();
    }
 
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        return super.dispatchTouchEvent(ev);
    }
 
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
 
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                intercepted = false;
                if (!mScroller.isFinished()) {
                    //優化滑動體驗
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    //水平滾動,攔截
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                intercepted = false;
                break;
            }
            default:
                break;
        }
 
        Log.d(TAG, "intercepted=" + intercepted);
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
 
        return intercepted;
    }
 
 
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                if (!mScroller.isFinished()) {
                    //優化滑動體驗
                    mScroller.abortAnimation();
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);
                break;
            }
            case MotionEvent.ACTION_UP: {
                //根據擡起時的速度判斷是否是fling,來自動切換或者歸位
                int scrollX = getScrollX();
                int scrollToChildIndex = scrollX / mChildWidth;
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
            }
            default:
                break;
        }
 
        mLastX = x;
        mLastY = y;
        return true;
    }
 
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);
 
        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth, heightSpaceSize);
        } else {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth, measuredHeight);
        }
    }
 
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;
 
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }
 
    private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
        invalidate();
    }
 
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }
 
    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }
}

在滑動過程中,當水平方向的距離大時就判斷爲水平滑動,爲了能夠水平滑動所以讓父容器攔截事件。而豎直距離大時父容器就不攔截事件,於是事件就傳遞給了 ListView,所以 ListView 也能上下滑動,如此滑動衝突了。

考慮一種情況,如果此時用戶正在水平滑動,但是在水平滑動停止前如果用戶再迅速進行豎直滑動,就會導致界面在水平方向無法滑動到終點從而處於一種中間狀態。爲了避免這種不好的體驗,當水平方向正在滑動時,下一個序列的點擊事件仍然交給父容器處理,這樣水平方向就不會停留在中間狀態了。

如果採用內部攔截法也是可以的,按照前面對內部攔截法的分析,我們只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的攔截邏輯,同時讓父容器攔截 ACTION_MOVE 和 ACTION_UP 事件即可。

/**
 * 採用內部攔截法解決滑動衝突
 */
public class MyListView extends ListView {
 
    private final static String TAG = "MyListView";
    //listView的容器
    private HorizontalScrollLayout horizontalScrollLayout;
    //記錄上次滑動的位置
    private int mLastX = 0;
    private int mLastY = 0;

    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
    public void setHorizontalScrollLayout(HorizontalScrollLayout horizontalScrollLayout) {
        this.horizontalScrollLayout = horizontalScrollLayout;
    }
 
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //獲得當前的位置座標
        int x = (int) ev.getX();
        int y = (int) ev.getY();
 
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                //通知父容器不要攔截事件
                horizontalScrollLayout.requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                int delatX = x-mLastX;
                int delatY = y - mLastY;
                if (Math.abs(delatX) > Math.abs(delatY)){
                    //通知父容器攔截此事件
                    horizontalScrollLayout.requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
 
        //重置手指的初始位置
        mLastY = y;
        mLastX = x;
 
        return super.dispatchTouchEvent(ev);
    }
}

除了上面對 ListView 所做的修改,我們還需要修改 HorizontalScrollLayout 的 onInterceptTouchEvent 方法:

/**
 * 內部攔截法
 */
public class HorizontalScrollLayout2 extends ViewGroup {
 
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
 
        if (action == MotionEvent.ACTION_DOWN) {
            mLastX = x;
            mLastY = y;
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
                return true;
            }
            return false;
        } else {
            return true;
        }
    }
}

從實現上來看,內部攔截法要複雜一些,因此推薦採用外部攔截法來解決常見的滑動衝突。

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