像 QQ 一样处理滑动冲突

在项目中,如果要用到滑动控件嵌套滑动控件,总会让人很心塞。因为很可能会出现冲突的问题。这里举个例子,利用事件分发机制,处理侧滑菜单控件和列表中的侧滑删除控件间的冲突。

分析

提到侧滑删除,一个经典的例子就是 QQ 了。QQ 的首页是一个大的侧滑菜单控件,嵌套一个列表,列表里面再嵌套侧滑删除的控件。我们就仿照这个样式,看看能不能做一个和它类似的效果。

这里关注的重点是在滑动手势的处理上,简单分析一下需要做什么处理:

(下面把侧滑菜单控件称作菜单控件,列表侧滑删除控件称作删除控件。)

  1. 在首页上下滑动时,滚动列表。

  2. 菜单控件关闭的情况下,如果列表里面没有展开的删除项,则手指向右滑动是滑动菜单控件,向左滑动是滑动删除控件。

  3. 如果列表里面有展开的删除控件,则菜单控件和列表项都不可滑动。除了删除按键,点击其他区域,都是将展开项关闭。

  4. 当手指滑动删除控件时,手指滑动到屏幕的任意区域都可以滑动展开项。

  5. 菜单控件打开的情况下,点击右边主页区域,将菜单控件关闭。

有点复杂的感觉啊,我们一个个来解决。

我自定义了上面说到的三个控件,根据嵌套关系,从大到小分别是:

  • 菜单控件 SwipeMenuLayout
  • 列表控件 MyRecyclerView
  • 删除控件 SwipeDeleteLayout

其中,SwipeMenuLayout 和 SwipeDeleteLayout 都是继承自 FrameLayout,用 ViewDragHelper 实现滑动效果。MyRecyclerView 则继承自 RecyclerView。

我们知道事件分发和三个方法有关:

  • 负责分发的 dispatchTouchEvent
  • 负责拦截的 onInterceptTouchEvent
  • 负责消费的 onTouchEvent

简单概括一下这个机制就是:分发从父到子,消费从子到父。

一般我们不对分发做特殊处理,下面按执行顺序看看三个控件的 onInterceptTouchEvent 和 onTouchEvent 方法是怎么写的。

onInterceptTouchEvent

onInterceptTouchEvent 方法的返回值决定是否拦截事件。

菜单控件

这部分要稍微啰嗦一点。我们先看看菜单关闭的情况,这时如果手指向右滑且没有展开的删除控件,我们就可以把事件拦截了,所以 onInterceptTouchEvent 可以写成这样:

if (mState == State.CLOSE) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            mDownX = ev.getRawX();
            mDownY = ev.getRawY();
        }
        break;
        case MotionEvent.ACTION_MOVE: {
            float deltaX = ev.getRawX() - mDownX;
            float deltaY = ev.getRawY() - mDownY;

            //向右滑动且列表没有展开项且横向滑动距离比竖向滑动距离大,则拦截
            if (deltaX > 0 &&
                    MainAdapter.mOpenItems.size() == 0 &&
                    Math.abs(deltaY / deltaX) < 1) {
                return true;
            }
        }
        break;
    }
}

mState 代表当前侧滑控件的状态,MainAdapter.mOpenItems 保存的是当前打开的删除控件。我使用 Math.abs(deltaY / deltaX) 是否小于1来判断手指的滑动方向。

这里还有两种不拦截的情况,向左滑动或者有展开项的话,都是和侧滑菜单没关系的,滑动事件里面再加入以下代码:

//如果是向左滑,且竖直滑动距离大于横向滑动距离,不拦截
//MainPage打开的item个数大于0,不拦截
if ((deltaX < 0 && Math.abs(deltaY / deltaX) > 1) ||
    MainAdapter.mOpenItems.size() > 0) {
    return false;
}

接下来是菜单打开的情况。这时候当手指点击了右侧的主页面区域是需要拦截并且将菜单关闭。如果手指向右滑动则不需要拦截:

if (mState == State.OPEN) {
    //完全展开时并且点到主页面,拦截并关闭菜单
    if (mMainContent.getLeft() <= mRange && ev.getRawX() > mRange) {
        return true;
    }
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mDownX = ev.getRawX();
            break;
        case MotionEvent.ACTION_MOVE:
            //如果是向右滑,不拦截
            float deltaX = ev.getRawX() - mDownX;
            if (deltaX > 0) {
                return false;
            }
            break;
    }
}

mRange 是侧滑出来的菜单宽度,关闭菜单的操作可以放在 ViewDragHelper 的 Callback 方法处理。

除了上面这些情况,默认情况下是否拦截交给 ViewDragHelper 处理就好了,调用它的 shouldInterceptTouchEvent 方法。

完整代码如下:

public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (mState == State.CLOSE) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mDownX = ev.getRawX();
                mDownY = ev.getRawY();
            }
            break;
            case MotionEvent.ACTION_MOVE: {
                float deltaX = ev.getRawX() - mDownX;
                float deltaY = ev.getRawY() - mDownY;
                //向右滑动且列表没有展开项且横向滑动距离比竖向滑动距离大,则拦截
                if (deltaX > 0 &&
                    MainAdapter.mOpenItems.size() == 0 &&
                    Math.abs(deltaY / deltaX) < 1) {
                    return true;
                }

                //如果是向左滑,且竖直滑动距离大于横向滑动距离,不拦截
                //MainPage打开的item个数大于0,不拦截
                if ((deltaX < 0 && Math.abs(deltaY / deltaX) > 1) ||
                        MainAdapter.mOpenItems.size() > 0) {
                    return false;
                }
            }
            break;
        }
    } else if (mState == State.OPEN) {
        //完全展开时并且点到主页面,拦截并关闭菜单
        if (mMainContent.getLeft() <= mRange && ev.getRawX() > mRange) {
            return true;
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = ev.getRawX();
                break;
            case MotionEvent.ACTION_MOVE:
                //如果是向右滑,不拦截
                float deltaX = ev.getRawX() - mDownX;
                if (deltaX > 0) {
                    return false;
                }
                break;
        }
    }
    return mDragHelper.shouldInterceptTouchEvent(ev);
}

列表控件

列表里面其实只做了一个处理,就是判断上下滑动的时候就把事件拦截了:

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    switch (e.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mDownX = e.getRawX();
            mDownY = e.getRawY();
            break;
        case MotionEvent.ACTION_MOVE:
            //竖向滑动时拦截事件
            float deltaX = e.getRawX() - mDownX;
            float deltaY = e.getRawY() - mDownY;
            if (deltaY != 0.0 &&
                Math.abs(deltaX / deltaY) < 1) {
                return true;
            }
            break;
    }
    return super.onInterceptTouchEvent(e);
}

删除控件

这里什么都不用做,交给 ViewDragHelper 就好了:

public boolean onInterceptTouchEvent(MotionEvent ev) {
    return mDragHelper.shouldInterceptTouchEvent(ev);
}

onTouchEvent

onTouchEvent 方法的返回值决定是否消费事件。

删除控件

删除控件的 onTouchEvent 又有几个地方要做特殊处理的。当有展开的删除项时,点击别的删除项时就将展开的关闭。这样就可以了:

//存在已展开的控件且当前控件为关闭状态,则将所有展开控件关闭
if (MainAdapter.mOpenItems.size() > 0 && mState == State.CLOSE) {
    return false;
}

这里我没有消费事件,也没有进行关闭的操作,因为我把关闭的操作交给父控件去处理了,否则会有卡顿的现象(QQ 就有这个问题)。

如果点击的是展开的删除项左边区域,这个又比较特殊了。因为手指按下之后,有可能是滑动,也可能是点击。滑动的话是滑动删除项,点击则是将删除项关闭。所以我们要判断一下用户是否有滑动的操作:

switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        mDownX = event.getRawX();
        break;
    case MotionEvent.ACTION_MOVE:
        float deltaX = event.getRawX() - mDownX;
        if (Math.abs(deltaX) > 50) {
            isDrag = true;
        }
        break;
    case MotionEvent.ACTION_UP:
        if (!isDrag &&
                event.getRawX() <= mWidth - mBackWidth) {
            close();
            return true;
        }
        isDrag = false;
        break;
}

当滑动距离大于 50 时,我就把它当做是一个滑动操作,这时候把滑动交给 ViewDragHelper 处理,否则就将当前控件关闭。

最后还有一个,当我滑动删除控件时,如果手指滑到了别的地方,滑动的依然是当前这个删除控件。换一个说法,其实就是一旦滑动了,父控件就不能再拦截我的滑动事件了。其实 ViewGroup 里面有一个 requestDisallowInterceptTouchEvent 方法,传 true 的时候,相当于通知它的所有父控件不要再拦截了。所以可以这样来处理:

switch (event.getAction()) {
    case MotionEvent.ACTION_MOVE:
        requestDisallowInterceptTouchEvent(true);
        break;
    case MotionEvent.ACTION_CANCEL:
        requestDisallowInterceptTouchEvent(false);
        break;
    case MotionEvent.ACTION_UP:
        requestDisallowInterceptTouchEvent(false);
        break;
}

完整代码如下:

public boolean onTouchEvent(MotionEvent event) {
    //存在已展开的控件且当前控件为关闭状态,则将所有展开控件关闭
    if (MainAdapter.mOpenItems.size() > 0 && mState == State.CLOSE) {
        MainAdapter.closeAll();
        return true;
    }

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mDownX = event.getRawX();
            break;
        case MotionEvent.ACTION_MOVE:
            requestDisallowInterceptTouchEvent(true);
            float deltaX = event.getRawX() - mDownX;
            if (Math.abs(deltaX) > 50) {
                isDrag = true;
            }
            break;
        case MotionEvent.ACTION_CANCEL:
            requestDisallowInterceptTouchEvent(false);
            break;
        case MotionEvent.ACTION_UP:
            requestDisallowInterceptTouchEvent(false);
            if (!isDrag &&
                    event.getRawX() <= mWidth - mBackWidth) {
                //展开状态下,点击左侧部分将其关闭
                close();
                return true;
            }
            isDrag = false;
            break;
    }

    mDragHelper.processTouchEvent(event);
    return true;
}

列表控件

当有展开删除项且点击了别的删除项的时候,把关闭的操作继续往父控件抛就好了:

public boolean onTouchEvent(MotionEvent e) {
    return MainAdapter.mOpenItems.size() == 0 && super.onTouchEvent(e);
}

菜单控件

在这里处理一下上面说的那种情况:

public boolean onTouchEvent(MotionEvent event) {
    if (MainAdapter.mOpenItems.size() > 0) {
        MainAdapter.closeAll();
        return true;
    }
    mDragHelper.processTouchEvent(event);
    return true;
}

效果

扯了这么多,看下效果吧:

搞半天其实也就这样而已。

小结

这篇有点啰嗦啊,里面涉及到的细节比较多。最后可能还会存在一些问题,这里主要是提供利用事件分发机制,处理手势冲突的思路。

写这个的时候发现 QQ 也有一些小问题,比如 QQ 在删除控件展开的情况下,按住删除控件左边区域下滑后,再左右滑,会出现列表跳动的问题。

大家可以点下面去看源码。就到这吧,妥妥的。

源码地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,128评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,316评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,737评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,283评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,384评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,458评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,467评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,251评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,688评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,980评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,155评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,818评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,492评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,142评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,382评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,020评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,044评论 2 352

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,949评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,079评论 4 62
  • 1. 舒叶比萧瑟大两岁、早一年入职,研究生毕业。鹅蛋儿脸,棕红色柔顺的长发, 腰肢纤细, 双腿修长,全是加分项。她...
    木子一叶阅读 643评论 0 0
  • 1 [我饿了啊!] 为了凑齐500字,我必须在这里唠叨唠叨,今天的饭菜太咸了,吃到嘴里,咸在心里,这还让我想起了我...
    谌舜贤阅读 209评论 8 2