View的滑動衝突的分析和處理實踐

轉載請以連接形式標明出處:java

本文出自:103style的博客android

《Android開發藝術探索》 學習記錄git

base on Android-29github

文中有用到 Scroller 來實現彈性滑動,不瞭解的能夠先看下 View的滑動實現方式bash

demo源碼地址app


目錄

  • 常見的滑動衝突場景
  • 滑動衝突的處理規則
  • 滑動衝突的解決方式
  • 實例驗證
    • 處理水平滑動和豎直滑動衝突
    • 處理水平滑動、豎直滑動、水平滑動一塊兒出現的狀況

常見的滑動衝突場景

主要的衝突場景有:ide

  • 外部滑動方向和內部滑動方向不一致
  • 外部滑動方向和內部滑動方向一致
  • 以上兩種狀況嵌套

如圖: 學習

滑動衝突場景.png

  • 第一個場景 外部滑動方向和內部滑動方向不一致,目前主要出如今:測試

    • 主頁 ViewPager 和 Fragment 配合使用組成的頁面滑動效果。 這種狀況下,經過左右滑動切換 Fragment,而 Fragment 中基本上都是 RecyclerView。
    • 豎直滑動的 RecyclerView 的 item 裏面 嵌套 水平滑動的 RecyclerView.

    上面這兩種本應該會有滑動衝突的,只是 ViewPager 和 RecyclerView 幫咱們處理了而已。ui

  • 第二個場景 外部滑動方向和內部滑動方向一致,這種狀況則稍微複雜一點,兩層都是水平滑動 或者 都是豎直滑動的話,手指滑動的時候,並不知道用戶到底想要滑動那一層,因此滑動的時候就會有問題,要麼只有一層滑動,要麼兩層都在滑動。

  • 第三個場景,外部滑動方向和內部滑動方向不一致 和 外部滑動方向和內部滑動方向一致 的嵌套,這就更加複雜了。 就像如今的 「手機QQ」 Android端 的消息欄目, 有上下滑動的消息列表,每一條消息又能左滑刪除,消息列表右滑又能拉出用戶菜單。 雖然看起來很複雜,實際上仍是幾個單一的衝突疊加的,咱們只要逐一擊破便可。


滑動衝突的處理規則

通常來講,無論滑動衝突多麼複雜,都有既定的規則,從而咱們能夠選擇合適的方法去處理。

對於上面的場景一:外部滑動方向和內部滑動方向不一致,我麼只需在左右滑動時讓外部的View上攔截點擊事件,當用戶上下滑動時,則讓內部View攔截處理。就是說 根據滑動過程當中兩個點之間的座標得出滑動方向來判斷到底由誰來攔截。

對於場景二:外部滑動方向和內部滑動方向一致,比較特殊,由於內外部滑動方向一致,咱們就不能像場景一那樣處理了,這就須要咱們從業務上找突破點了,根據業務的具體要求來決定是外部仍是內部的View來攔截處理事件。

而場景三則是場景一和場景二的混合,直接參考場景一和二的處理規則便可。


滑動衝突的解決方式

解決方式主要有兩種: 外部攔截法 和 內部攔截法。

外部攔截法

就是指點擊事件都先通過父容器的攔截處理,若是父容器須要此事件則攔截,即重寫父容器的 onInterceptTouchEvent 方法,示例以下:

private float lastEventX,lastEventY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercept = false;
    float x = ev.getX();
    float y = ev.getY();
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            intercept = false;
            break;
        case MotionEvent.ACTION_MOVE:
            if (父容器須要當前點擊事件) {
                intercept = true;
            } else {
                intercept = false;
            }
            break;
        case MotionEvent.ACTION_UP:
            intercept = false;
            break;
        default:
            intercept = false;
            break;
    }
    lastEventX = x;
    lastEventY = y;
    return intercept;
}
複製代碼

不過咱們要注意一點, 以前在 Android事件分發機制驗證示例 咱們測試過,當父容器只要在 onInterceptTouchEvent 中攔截了事件(返回true),後續的事件都不會傳到子View了。 可是若是咱們在 dispatchTouchEvent 中直接消耗了 MOVE 事件,以前處理 DOWN 事件的子元素仍是能收到 UP 事件的。

內部攔截法

就是指父容器不攔截任何事件,全部事件都傳遞給子元素,若是子元素要處理就直接消耗掉,不然再傳遞給父容器,這裏子元素須要配合 requestDisallowInterceptTouchEvent(true) 才能正常工做,使用稍微複雜一點,示例以下:

private float lastEventX,lastEventY;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    float x = ev.getX();
    float y = ev.getY();
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            float dx = x - lastEventX;
            float dy = y - lastEventY;
            if(父容器須要處理){
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
        default:
            break;
    }
    lastEventX = x;
    lastEventY = y;
    return super.dispatchTouchEvent(ev);
}
複製代碼

以前在 驗證和分析Android的事件分發機制 中分析過,「FLAG_DISALLOW_INTERCEPT 在 DOWN事件的時候也會被重置,所以,對於 DOWN 事件,ViewGroup 老是經過 onInterceptTouchEvent 來判斷是否攔截。因此不能 攔截 DOWN 事件。

接下來咱們經過實例來驗證上面這兩種方法.


實例驗證

咱們來簡單實現一個能夠水平滑動的 HorizontalScrollerView
和 一個能夠豎直滑動的 VerticalScrollerView 來驗證下。

首先咱們來簡單的實現下 HorizontalScrollerViewVerticalScrollerView, 下面就貼下事件處理的邏輯,完整源碼能夠點上面這 兩個連接

//HorizontalScrollerView.java
public class HorizontalScrollerView  extends ViewGroup {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        ...
        switch (event.getAction()) {
            ...
            case MotionEvent.ACTION_MOVE:
                int dx = (int) (x - lastX);
                //跟隨手指滑動
                scrollBy(-dx, 0);
                break;
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();
                //計算1s內的速度
                velocityTracker.computeCurrentVelocity(1000);
                //獲取水平的滑動速度
                float xVelocity = velocityTracker.getXVelocity();

                if (Math.abs(xVelocity) > 50) {
                    childIndex = xVelocity > 0 ? childIndex - 1 : childIndex + 1;
                } else {
                    childIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                childIndex = Math.max(0, Math.min(childIndex, mChildSize - 1));
                //計算還需滑動到整個child的偏移
                int sx = childIndex * mChildWidth - scrollX;
                //經過Scroller來平滑滑動
                smoothScrollBy(sx); 
                //清除
                velocityTracker.clear();
                break;
            default:
                break;
        }
        return true;
    }
}
複製代碼
//VerticalScrollerView.java
public class VerticalScrollerView extends ViewGroup {
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!scroller.isFinished()) {
                    scroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int dy = (int) (y - lastY);
                //跟隨手指滑動
                scrollBy(0, -dy);
                break;
            case MotionEvent.ACTION_UP:
                int scrollY = getScrollY();
                if (scrollY < 0) {
                    smoothScrollBy(-scrollY);
                } else if (mContentHeight <= mHeight) {
                    smoothScrollBy(-scrollY);
                } else if (mContentHeight - scrollY < mHeight) {
                    smoothScrollBy(mContentHeight - scrollY - mHeight);
                } else {
                    //慣性滑動效果
                }
                break;
            default:
                break;
        }
        lastX = x;
        lastY = y;
        return true;
    }
}
複製代碼

兩個基本都相似,都是處理滑動的邏輯。

而後咱們配置寫到xml中:

<com.lxk.slidingconflictdemo.HorizontalScrollerView
    android:id="@+id/tvp_test"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginBottom="@dimen/tab_layout_height">
    <com.lxk.slidingconflictdemo.VerticalScrollerView
        android:id="@+id/rsv1"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <com.lxk.slidingconflictdemo.VerticalScrollerView
        android:id="@+id/rsv2"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <com.lxk.slidingconflictdemo.VerticalScrollerView
        android:id="@+id/rsv3"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</com.lxk.slidingconflictdemo.HorizontalScrollerView>
複製代碼

而後動態給每一個 VerticalScrollerView 添加子控件:

private void setupRsv(VerticalScrollerView verticalScrollerView) {
    ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    layoutParams.topMargin = 32;
    for (int i = start; i < count; i++) {
        AppCompatButton button = new AppCompatButton(this);
        button.setLayoutParams(layoutParams);
        button.setText(String.valueOf(i));
        verticalScrollerView.addView(button);
    }
    updateData();
}
複製代碼

運行的效果是這樣的:

默認運行效果

咱們能夠看到它是能夠豎直滑動的,由於事件被裏面的 VerticalScrollerView 消耗了,因此外層的 HorizontalScrollerView 就不能滑動了。

下面咱們就用上面說的 外部攔截法 和 內部攔截法 來處理下這個衝突。


外部攔截法處理衝突

咱們首先經過外部攔截法來解決這個問題,重寫 HorizontalScrollerView 的 onInterceptTouchEvent 方法,在滑動的時候,若是水平滑動的距離大於豎直滑動的距離就攔截事件,以下:

public class HorizontalScrollerView extends ViewGroup {
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept;
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                ...
                break;
            case MotionEvent.ACTION_MOVE:
                float dx = x - lastInterceptX;
                float dy = y - lastInterceptY;
                //水平滑動距離大於豎直滑動
                intercept = Math.abs(dx) > Math.abs(dy);
                break;
            case MotionEvent.ACTION_UP:
            default:
                intercept = false;
                break;
        }
        ...
        return intercept;
    }

}
複製代碼

運行程序:

添加外部攔截事件邏輯
咱們能夠看到就能正常的水平 和 豎直 滑動了。


內部攔截法處理衝突

而後咱們在經過 內部攔截法 來試試, 因此咱們的重寫 VerticalScrollerView 的 dispatchTouchEvent 方法,在 ACTION_DOWN 的時候設置不容許父控件攔截事件, 而後在水平滑動距離大於豎直滑動距離必定數值時,容許父控件攔截,這裏設置爲 50。

public class VerticalScrollerView extends ViewGroup{
    private float lastX, lastY;
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                float dx = x - lastX;
                float dy = y - lastY;
                if (Math.abs(dx) > Math.abs(dy) + 50) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
            default:
                break;
        }
        return super.dispatchTouchEvent(ev);
    }
}
複製代碼

以及修改 HorizontalScrollerView 的 onInterceptTouchEvent 方法,只有在 ACTION_DOWN 事件時不攔截。

public class HorizontalScrollerView extends ViewGroup {
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!scroller.isFinished()) {
                    scroller.abortAnimation();
                    return true;
                }
                return false;
            case MotionEvent.ACTION_MOVE:
            case MotionEvent.ACTION_UP:
            default:
                return true;
        }
    }
}
複製代碼

運行效果:

內部攔截法
滑動效果也能正常處理。

接下來咱們看看 有水平方向衝突 又有 豎直方向衝突 的場景。


模擬內外滑動不一致 而且也有外部和內部滑動一致的場景

下面咱們來模擬內外滑動不一致 而且也有外部和內部滑動一致的場景,咱們給 VerticalScrollerView 添加一個 能夠水平滑動的 子View 爲 ItemHorizontalScrollerView,代碼和 HorizontalScrollerView 差很少, 這裏就不貼了, 源碼地址點我

而後咱們在 HomeActivity 中把他添加到原有列表的第一格,這裏禁用掉裏面子View的事件處理便於測試。

private void addItemHorizontalScrollerView(VerticalScrollerView verticalScrollerView) {
    ViewGroup.MarginLayoutParams layoutParams = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    ItemHorizontalScrollerView itemHorizontalScrollerView = new ItemHorizontalScrollerView(this);
    itemHorizontalScrollerView.setLayoutParams(layoutParams);
    int itemCount = 10;
    ViewGroup.MarginLayoutParams itemLP = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    for (int i = 0; i < itemCount; i++) {
        AppCompatButton button = new AppCompatButton(this);
        button.setLayoutParams(itemLP);
        button.setText(String.valueOf(i));
        button.setClickable(false);
        button.setLongClickable(false);
        itemHorizontalScrollerView.addView(button);
    }
    verticalScrollerView.addView(itemHorizontalScrollerView);
}
複製代碼

運行程序:

添加能夠水平滑動的item
能夠明顯看到 外層的水平滑動和 內層的水平滑動有衝突。

那咱們一塊兒來處理下這個衝突吧,這個咱們得用 內部攔截法 來處理這個問題。

首先咱們先來定義下規則:在滑動內部能夠水平滑動的子View時,先讓內部的子View水平滑動,當滑動到 最左邊 或者 左右邊的時候,再把事件交給上層去處理

接下來咱們從外向內一步步來處理:

首先咱們來看看 HorizontalScrollerView, 這裏不須要修改,直接攔截除 ACTION_DOWN 以外的事件。

public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!scroller.isFinished()) {
                scroller.abortAnimation();
                return true;
            }
            return false;
        case MotionEvent.ACTION_MOVE:
        case MotionEvent.ACTION_UP:
        default:
            return true;
    }
}
複製代碼

而後是 VerticalScrollerView,咱們以前處理和 HorizontalScrollerView 的衝突時,在 dispatchTouchEvent 中處理了 ACTION_DOWN 時不容許父View攔截事件,而後在 ACTION_MOVE 當水平滑動的距離大於豎直滑動時,容許父View攔截事件。 顯然這裏是不合理的,由於咱們要先讓 ItemHorizontalScrollerView 優先處理事件。因此咱們修改成只有在 ACTION_DOWN 設置不容許父View攔截事件。

public boolean dispatchTouchEvent(MotionEvent ev) {
    x = ev.getX();
    y = ev.getY();
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        getParent().requestDisallowInterceptTouchEvent(true);
    }
    boolean res = super.dispatchTouchEvent(ev);
    lastX = x;
    lastY = y;
    return res;
}
複製代碼

最後咱們來看 ItemHorizontalScrollerView,首先和 VerticalScrollerView 同樣,在 dispatchTouchEvent 中設置、 ACTION_DOWN 時不容許父View攔截事件。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        getParent().requestDisallowInterceptTouchEvent(true);
    }
    return super.dispatchTouchEvent(ev);
}
複製代碼

而後咱們要在 onTouchEvent 來處理何時把事件交給父View去處理:

  • 首先咱們得消耗掉 ACTION_DOWN,要不後續的事件都不傳過來了。
  • 而後咱們要在 ACTION_MOVE 的時候處理 在最左邊再往左滑在最右邊再往右滑 的狀況,將事件交給父View去處理。其餘狀況咱們就讓 ItemHorizontalScrollerView 本身滑動。

這裏直接用 getScrollX() 來判斷,當在最左邊的時候 getScrollX() 爲 0,當在最右邊的時候 getScrollX()內容的寬度 減去 當前View的寬度(這裏設定內容寬度大於View的寬度)。

因此咱們修改 onTouchEventACTION_MOVE 事件時的代碼以下:

//ItemHorizontalScrollerView.java   刪減了部分代碼
public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX();
    int scrollX = getScrollX();
    boolean used = false;
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            ....
            break;
        case MotionEvent.ACTION_MOVE:
            int dx = (int) (x - lastX);
            if (scrollX <= 0 && dx > 0) {
                //在最左邊而且左滑時
                if (scrollX == 0) {
                    dx = 0;
                } else {
                    dx += scrollX;
                }
            } else if (scrollX + mWidth >= mContentWidth && dx < 0) {
                //在最右邊而且右滑時
                if (scrollX + mWidth >= mContentWidth) {
                    dx = 0;
                } else {
                    dx += scrollX + mWidth - mContentWidth;
                }
            } else {
                used = true;
            }
            //跟隨手指滑動
            scrollBy(-dx, 0);
            //在不須要在左滑和右滑的時候 事件交給父控件處理
            if (dx == 0 && !used) {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
    }
    lastX = x;
    return used;
}
複製代碼

這裏先運行程序看下:

運行示例

這裏咱們看到 裏面的item能正常滑動了,可是有個問題,外層水平滑動的View卻滑不動了。

這裏由於咱們在 ItemHorizontalScrollerView 把事件交給了 VerticalScrollerView 去處理了, 可是 VerticalScrollerView 並無容許 父View 攔截, 因此咱們只要在 onTouchEvent 時候加上以前在 dispatchTouchEvent 時處理 ACTION_MOVE 的邏輯便可:

public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            ...
            break;
        case MotionEvent.ACTION_MOVE:
            int dx = (int) (x - lastX);
            int dy = (int) (y - lastY);
            //跟隨手指滑動
            scrollBy(0, -dy);
            //在水平滑動距離 大於 豎直滑動時 容許 父View攔截
            if (Math.abs(dx) > Math.abs(dy) + 50) {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            ...
            break;
        default:
            break;
    }
    return true;
}
複製代碼

運行程序:

運行示例

咱們能夠看到滑動效果基本都正常了。

你們能夠試試本身處理下 外層豎直方向 和 內層豎直方向上的衝突練練手。

demo源碼地址


若是有描述錯誤的,請提醒我,感謝!

以上

若是以爲不錯的話,請幫忙點個讚唄。


掃描下面的二維碼,關注個人公衆號 Android1024, 點關注,不迷路。

Android1024
相關文章
相關標籤/搜索