Android View 滑動衝突解決方式以及原理

上一篇文章講了View分發機制的源碼,此次來說講解決View滑動衝突的方式和原理。java

一. 滑動衝突場景以及產生緣由

產生滑動衝突的場景主要有兩種:android

  • 父ViewGroup和子View的滑動方向一致
  • 父ViewGroup和子View的滑動方向不一致

那爲何會產生滑動衝突呢,例如在父ViewGroup和子View的滑動方向一致的狀況,我須要讓二者均可以滑動。在上篇博客中咱們分析了事件分發機制,其中提到ViewGroup的onInterceptTouchEvent方法默認狀況下是返回false,也就是ViewGroup默認狀況下是不會攔截事件的。當ViewGroup接收到事件時,因爲不攔截事件,會去尋找可以處理事件的子View。此時,一旦子View處理了DOWN事件,默認狀況下接下來同一事件序列的其餘事件都交由子View處理,此時能夠看到的效果是子View能夠滑動,可是父ViewGroup始終滑動不了,此時滑動衝突就出現了。ide

二. 滑動衝突的解決方式

滑動衝突主要有兩種解決方式:外部攔截法和內部攔截法源碼分析

2.1 ViewPager 滑動衝突

例如咱們使用ViewPager時,每每會結合Fragment,而後Fragment內部爲一個ListView。這裏ViewPager已經爲咱們解決了滑動衝突,所以在使用時並不會衝突。試想下,若ViewPager未解決滑動衝突,默認狀況下ViewPager的onInterceptTouchEvent方法返回false,因爲ListView能夠滾動,表明ListView能夠處理事件,因此全部事件都被ListView處理了,所以咱們看到的效果會是ListView能夠在豎直方向上滾動,可是ViewPager在水平方向上沒法滑動。this

能夠重寫ViewPager,讓ViewPager的onInterceptTouchEvent方法返回默認狀態下的false,ViewPager內部是多個ListView。spa

public class MyViewPager extends ViewPager {
    public MyViewPager(@NonNull Context context) {
        super(context);
    }

    public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return false;
    }

}
複製代碼

運行效果如圖3d

因此ViewPager是如何解決這樣的滑動衝突的呢,由此引出外部攔截法。rest

2.2 外部攔截法

2.2.1 原理

所謂外部攔截法,就是當事件傳遞到父容器時,經過父容器去判斷本身是否須要此事件,若須要則攔截事件,不須要則不攔截事件,將事件傳遞給子View。 上述MyViewPager和ListView顯然產生了滑動衝突,咱們來分析下。咱們要的效果是在水平方向上滑動時ViewPager能夠水平滾動,在豎直方向上滑動時,ListView能夠滾動但ViewPager不動,所以咱們須要爲ViewGroup指定事件處理的條件,因而就有了下面的僞代碼。code

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_MOVE:
            if (ViewPager須要此事件) {
                return true;
            }
            break;
        default:
            break;
    }
    return false;
}
複製代碼

如今咱們來分析下爲何這段代碼能夠解決滑動衝突。 orm

這邊首先要注意一點,外部攔截時在重寫ViewGroup的onInterceptTouchEvent方法時,ViewGroup不能攔截DOWN事件和UP事件。由於一旦ViewGroup攔截了DOWN事件,也就是和mFirstTouchTarget始終爲空,同一事件序列中的其餘事件都不會再往下傳遞;若ViewGroup攔截了UP事件,則子View就不會觸發單擊事件,由於子View的單擊事件是在UP事件時被觸發的。

  • 當ViewPager接收到DOWN事件,ViewPager默認不攔截DOWN事件,DOWN事件交由ListView處理,因爲ListView能夠滾動,便可以消費事件,則ViewPager的mFirstTouchTarget會被賦值,即找處處理事件的子View。而後ViewPager接收到MOVE事件,
  • 若此事件是ViewPager不須要,則一樣會將事件交由ListView去處理,而後ListView處理事件;
  • 若此事件ViewGroup須要,由於DOWN事件被ListView處理,mFirstTouchEventTarget會被賦值,也就會調用onInterceptedTouchEvent,此時因爲ViewPager對此事件感興趣,則onInterceptedTouchEvent方法會返回true,表示ViewPager會攔截事件,此時當前的MOVE事件會消失,變爲CANCEL事件,往下傳遞或者本身處理,同時mFirstTouchTarget被重置爲null。
  • 當MOVE事件再次來到時,因爲mFristTouchTarget爲null,因此接下來的事件都交給了ViewPager。

2.2.2 解決方式

這邊ViewPager處理事件的條件能夠有多種方法,例如水平方向和豎直方向上的滑動速度、水平方向和豎直方向的滑動距離等。這邊根據滑動距離判斷,當水平方向的滑動距離大於豎直方向的滑動距離,則ViewPager處理事件,反之則將事件傳遞給ListView。

public class MyViewPager extends ViewPager {
    private int mLastX;
    private int mLastY;

    public MyViewPager(@NonNull Context context) {
        super(context);
    }

    public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //一些ViewPager拖拽的標誌位要設置,必調super,不然看不到效果
        super.onInterceptTouchEvent(ev);

        boolean isIntercepted = false;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                if (needEvent(ev)) {
                    isIntercepted = true;
                }
                break;
            default:
        }
        mLastX = (int) ev.getX();
        mLastY = (int) ev.getY();

        LogUtils.d(" lastX = " + mLastX + " lastY = " + mLastY);
        return isIntercepted;
    }

    private boolean needEvent(MotionEvent ev) {
        //水平滾動距離大於垂直滾動距離則將事件交由ViewPager處理
        return Math.abs(ev.getX() - mLastX) > Math.abs(ev.getY() - mLastY);
    }

}
複製代碼

運行效果:

2.2.3 總結

  • 外部攔截法主要是父容器去控制事件的攔截,若事件是父容器須要的,則進行攔截,不須要的則向下傳遞。
  • 父容器不能攔截DOWN事件或者UP事件。

2.2.4 通用模板

public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean isIntercept = false;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                //DOWN事件不能攔截,不然事件將沒法分發到子View
                isIntercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                //根據條件判斷是否攔截事件
                isIntercept = needThisEvent();
                break;
            case MotionEvent.ACTION_UP:
                //一旦父容器攔截了UP事件,子View將沒法觸發點擊事件
                isIntercept = false;
                break;
            default:
                break;
        }
        return isIntercept;
    }
複製代碼

2.3 內部攔截法

2.3.1 衝突場景

下面講一種稍微複雜一點的同向滑動衝突。ScrollView內部的內部的LinearLayout存在三個子View,從上到下分別爲ImageView、ListView以及TextView。

同向滑動衝突場景

先上下效果圖:

能夠看到如今須要的效果是觸摸ListView外部的區域,ScrollView的滑動不受限制。當觸摸ListView區域時,存在多種狀況。當ListView滾動到頂部時(ListView處於初始狀態),此時若手指往下滑動,則ScrollView往下滑動;當ListView滾動到底部時,若此時手指往上滑動,則ScrollView往上滑動,其他狀況下ListView滾動。

2.3.2 原理

內部攔截法: ViewGroup默認狀況下不攔截事件,由子View去控制事件的處理,若子View須要此事件,則本身處理,不然交由父容器處理。

使用內部攔截須要同時重寫父ViewGroup的onInterceptTouchEvent和ViewGroup中須要解決衝突的子View的dispatchTouchEvent方法,和上面同樣,先上僞代碼。

子View僞代碼

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //禁止父容器攔截事件
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if (當期View不須要此事件) {
                // 容許父容器攔截事件
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        default:
            break;
    }
    return super.dispatchTouchEvent(ev);
    }
複製代碼

ViewGroup 僞代碼

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            return false;
        default:
            return true;
    }
}
複製代碼

這邊咱們結合ScrollView和ListView這個具體實例和流程圖進行分析。

首先父容器ScrollView不能攔截DOWN事件,必須將DOWN事件分發至子View,這邊子View是 ListView,由於父容器一旦攔截DOWN事件,同一事件序列中的其餘事件都不會傳遞到子View,這點在事件分發源碼分析時已經分析了,這裏再也不贅述。

因爲內部攔截是將事件交由子View,由子View去控制事件的處理,因此事件在一開始不能被父ViewGroup直接攔截,因爲DOWN事件被子View處理,此時mFristTonchTarget不爲null,在默認狀況下會去調用onInterceptedTouchEvent,若針對該事件該方法返回true,則事件就會被父容器攔截了,事件顯然不會傳遞到子View,可是咱們須要將事件傳遞到子View,讓子View去控制事件的處理。那咱們要怎麼將事件傳遞到子View呢?從源碼能夠看到在調用onInterceptedTouchEvent方法前還有一個判斷。

if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {
    //是否禁止攔截事件,默認爲false
    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
    if (!disallowIntercept) {
        intercepted = onInterceptTouchEvent(ev);
        ev.setAction(action); // restore action in case it was changed
    } else {
        intercepted = false;
    }
} else {
    intercepted = true;
}
複製代碼

從源代碼能夠看到,會根據disallowIntercept的值判斷是否要調用onInterceptTouchEvent這個方法,disallowIntercept默認爲false。此時若能夠將disallowIntercept的值變爲true,就能夠繞過onIntercepted方法,將事件傳遞到子View了,也就是咱們須要在MOVE事件到來以前給mGroupFlags設置FLAG_DISALLOW_INTERCEPT標誌位,設置好後,若MOVE事件到來,disallowIntercept的值就會變爲true,就會繞過onInterceptedTouchEvent方法的執行,將事件傳遞到子View了,那如何在MOVE事件到來以前給ViewGroup設置這個標誌位呢?咱們能夠在ViewGroup中看到這個方法。

public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
        return;
    }

    if (disallowIntercept) {
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Pass it up to our parent
    if (mParent != null) {
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
    }
}
複製代碼

能夠看到,若在調用requestDisallowInterceptTouchEvent方法時,參數爲true,則mGroupFlags設置了FLAG_DISALLOW_INTERCEPT標誌位,也就是disallowIntercept的值就會變爲true。至於調用時機,咱們只須要在子View接收到DOWN事件時調用該方法便可,此後父ViewGroup會直接將事件傳遞給處理DOWN事件的子View。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //禁止父容器攔截事件
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
            ...
        }
        ...
    }
}
複製代碼

若接下來的事件是子View感興趣的,則直接處理掉,若是子View對事件不感興趣,則將事件交還給父View,讓它去處理。那麼問題又來了,如何將子View不須要的事件從新交還給父View處理?此時可能有人會說,在事件分發中,子View處理不了的事件,不是自動會交給父ViewGroup處理嗎?咱們說的子View處理不了的事件會傳遞給父ViewGroup處理,這個是針對默認的DOWN事件分發流程,可是在這不是DOWN事件且這裏存在人工干預的狀況,真的會是這樣嗎,咱們來看看源碼。

先明確下當前的情景,子View處理了DOWN事件和部分MOVE事件,此時父ViewGroup的mFirstTouchEvent確定是不爲null的。接下來的MOVE事件子View不須要,也就是子View不作處理,那麼子View的dispatchTouchEvent方法會返回false。

public boolean dispatchTouchEvent(MotionEvent ev) {
    
    ...

    if (mFirstTouchTarget == null) {
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
    } else {
        TouchTarget predecessor = null;
        TouchTarget target = mFirstTouchTarget;
        while (target != null) {
            final TouchTarget next = target.next;
            if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                handled = true;
            } else {
                final boolean cancelChild = resetCancelNextUpFlag(target.child)
                        || intercepted;

                //子View 不處理事件, 子View的dispatchTouchEvent返回false,dispatchTransformedTouchEvent爲false
                if (dispatchTransformedTouchEvent(ev, cancelChild,
                        target.child, target.pointerIdBits)) {
                    handled = true;
                }
                if (cancelChild) {
                    if (predecessor == null) {
                        mFirstTouchTarget = next;
                    } else {
                        predecessor.next = next;
                    }
                    target.recycle();
                    target = next;
                    continue;
                }
            }
            predecessor = target;
            target = next;
        }
    }

    if (canceled
            || actionMasked == MotionEvent.ACTION_UP
            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
        resetTouchState();
    } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
        final int actionIndex = ev.getActionIndex();
        final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
        removePointersFromTouchTargets(idBitsToRemove);
    }

    if (!handled && mInputEventConsistencyVerifier != null) {
        mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
    }

    //直接返回false
    return handled;
}
複製代碼

從源代碼能夠看到,在這個情景下,ViewGroup的dispatchTouchEvent方法會直接返回false,不處理當前子View不感興趣的MOVE事件,父ViewGroup的父容器也是這樣直接返回false,直到傳遞給Activity,事件被Activity處理或者消失。而且當再一個MOVE事件來臨時,MOVE仍是會傳遞到子View,可是子View對當前MOVE事件不感興趣,也就是說以後的全部MOVE事件都不會被父ViewGroup處理,這樣明顯是存在問題的。因此子View在對事件不感興趣時,要如何事件處理權交給父ViewGroup?咱們在子View 經過調用ViewGroup的requestDisallowInterceptTouchEvent方法,禁止父ViewGroup攔截事件,一樣也能夠在子View對事件不感興趣時,調用ViewGroup的requestDisallowInterceptTouchEvent方法,容許父容器去攔截事件。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_MOVE:
            if (當期View不須要此事件) {
                // 容許父容器攔截事件
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        default:
            break;
    }
    return super.dispatchTouchEvent(ev);
    }
複製代碼

對子View來講,對事件處理的控制邏輯已經完成了,可是對於父ViewGroup來講並無,必需要重寫ViewGroup的onInterceptedTouchEvent方法,讓MOVE和UP事件返回true,表示攔截子View不感興趣的事件,這邊父ViewGroup攔截MOVE事件是能夠理解的,可是爲何要攔截UP事件呢,由於父ViewGroup只有攔截了UP事件才能夠接收單擊事件。

2.3.3 具體實現

上述分析了原理,如今來真正解決一下ScrollView和ListView的滑動衝突。其實內部攔截的模板已經在僞代碼中體現了。只要實現子View 對事件處理的判斷便可。咱們須要監聽ListView滾動到頂部和底部的狀態,當ListView滾動到頂部時且手指觸摸方向向下或者ListView滾動到底部且手機觸摸方向向上,則將事件交由ScrollView處理。

public class MyListView extends ListView implements AbsListView.OnScrollListener {

    private boolean isScrollToTop;
    private boolean isScrollToBottom;

    private int mLastX;
    private int mLastY;

    public MyListView(Context context) {
        this(context, null);
    }

    public MyListView(Context context, AttributeSet attrs) {
        this(context, attrs, -1);
    }

    public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setOnScrollListener(this);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        LogUtils.d("" + Constants.getActionName(ev.getAction()));
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                mLastX = (int) ev.getX();
                mLastY = (int) ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if (superDispatchMoveEvent(ev)) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                LogUtils.d("ACTION_UP");
                break;
            default:
                break;
        }
        return super.dispatchTouchEvent(ev);
    }

    /** * 將事件交由父容器處理 * * @param ev * @return */
    private boolean superDispatchMoveEvent(MotionEvent ev) {
        //下滑
        boolean canScrollBottom = isScrollToTop && (ev.getY() - mLastY) > 0;
        boolean canScrollTop = isScrollToBottom && (ev.getY() - mLastY) < 0;

        return canScrollBottom || canScrollTop;
    }

    @Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {

    }

    @Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        isScrollToBottom = false;
        isScrollToTop = false;

        if (firstVisibleItem == 0) {
            android.view.View firstVisibleItemView = getChildAt(0);
            if (firstVisibleItemView != null && firstVisibleItemView.getTop() == 0) {
                LogUtils.d("##### 滾動到頂部 ######");
                isScrollToTop = true;
            }
        }


        if ((firstVisibleItem + visibleItemCount) == totalItemCount) {
            View lastVisibleItemView = getChildAt(getChildCount() - 1);
            if (lastVisibleItemView != null && lastVisibleItemView.getBottom() == getHeight()) {
                LogUtils.d("##### 滾動到底部 ######");
                isScrollToBottom = true;
            }
        }
    }

}
複製代碼

至於ScrollView,默認在拖拽狀態下會攔截MOVE事件,默認不攔截UP事件,若須要攔截UP事件,可重寫ScrollView的onInterceptTouchEvent方法,但不是必須攔截UP事件,若父ViewGroup不須要觸發單擊事件,則能夠不攔截。

public class MyScrollView extends ScrollView {
    public MyScrollView(Context context) {
        super(context);
    }

    public MyScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted  = super.onInterceptTouchEvent(ev);
        if (ev.getAction() == MotionEvent.ACTION_UP) {
            intercepted = true;
        }
        return intercepted;
    }
}
複製代碼

2.3.4 總結

  • 內部攔截法是將事件控制權交給子View,若子View須要事件,則對事件進行處理,不須要則將事件傳遞給父ViewGroup,讓父ViewGroup處理。
  • 子View經過調用父ViewGroup的requestDisallowInterceptTouchEvent來干預父ViewGroup對事件的攔截情況
  • 父ViewGroup不能攔截DOWN事件,至於MOVE或者UP事件的攔截狀態要根據具體的情景

好了,到這裏兩種解決滑動衝突的方式就介紹完了,但要注意的是解決ViewPager與ListView滑動衝突並非只能用外部攔截,一樣可使用內部攔截實現,第二個情景也是同樣。解決方式並非絕對的,咱們要作的是選擇最方便實現的方案。

相關文章
相關標籤/搜索