View的滑动冲突的分析和处理实践

3,527 阅读10分钟

转载请以链接形式标明出处:

本文出自:103style的博客

《Android开发艺术探索》 学习记录

base on Android-29

文中有用到 Scroller 来实现弹性滑动,不了解的可以先看下 View的滑动实现方式

demo源码地址


目录

  • 常见的滑动冲突场景
  • 滑动冲突的处理规则
  • 滑动冲突的解决方式
  • 实例验证
    • 处理水平滑动和竖直滑动冲突
    • 处理水平滑动、竖直滑动、水平滑动一起出现的情况

常见的滑动冲突场景

主要的冲突场景有:

  • 外部滑动方向和内部滑动方向不一致
  • 外部滑动方向和内部滑动方向一致
  • 以上两种情况嵌套

如图:

滑动冲突场景.png

  • 第一个场景 外部滑动方向和内部滑动方向不一致,目前主要出现在:

    • 主页 ViewPager 和 Fragment 配合使用组成的页面滑动效果。 这种情况下,通过左右滑动切换 Fragment,而 Fragment 中基本上都是 RecyclerView。
    • 竖直滑动的 RecyclerView 的 item 里面 嵌套 水平滑动的 RecyclerView.

    上面这两种本应该会有滑动冲突的,只是 ViewPager 和 RecyclerView 帮我们处理了而已。

  • 第二个场景 外部滑动方向和内部滑动方向一致,这种情况则稍微复杂一点,两层都是水平滑动 或者 都是竖直滑动的话,手指滑动的时候,并不知道用户到底想要滑动那一层,所以滑动的时候就会有问题,要么只有一层滑动,要么两层都在滑动。

  • 第三个场景,外部滑动方向和内部滑动方向不一致 和 外部滑动方向和内部滑动方向一致 的嵌套,这就更加复杂了。 就像现在的 “手机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