仿豆瓣弹性滑动控件,史上最全方位讲解事件滑动冲突

3字数 2312阅读 1979
本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

前言

先来一道趣味测试,后面的控件讲解会比较枯燥乏味,看一看你的数学老师是谁教的?

小明向两位朋友各自借了50元,用借来的钱,小明花费97元买了一件格子衫。这时候还剩3元钱,小明还给两个小伙伴各1元,自己还剩下1元。

那么问题来了:小明此时欠两位小伙伴各49元,再加上自己剩下的1元,49+49+1=99元。剩下的1元去哪了?

正文

近日产品提出了一个新需求,在首页列表中新增可以横向滑动的卡片类型,效果类似豆瓣弹性滑动控件,看下最终效果图:

滑动弹性控件

小编刚开始以为只要实现了 豆瓣弹性滑动控件 就万事大吉了,没想到这只是一个开始。滑动控件 只不过是一道开胃菜,事件冲突 才是重头戏。

首先分析下效果图中的布局,典型的 ViewPager + fragment + RecyclerView 布局方式,在垂直的 RecyclerView 中嵌入了 弹性滑动控件item,那么会有哪些事件冲突呢?

  1. 弹性滑动控件 会消费左右滑动事件,内部的卡片 RecyclerView 同时也会消费左右滑动事件,左右滑动事件就会冲突。光是文字的描述,可能不大好理解,结合以下图片加以说明:手指向左滑动,是 RecyclerView 消费左滑的事件呢?还是 弹性滑动控件 消费左滑的事件?
    scr
  2. 垂直的 RecyclerView 会默认消费上下滑动事件, 弹性滑动控件 在左右滑动的同时,y 轴方向的偏移量不会为 0,因为手指的滑动很难保持在一条水平线上,垂直的 RecyclerView 就会消费 y 方向的事件,导致界面抖动,滑动不灵敏。那么 弹性滑动控件 在左右滑动的时候就需要拦截掉垂直的 RecyclerView 的滑动事件消费。
  3. 弹性滑动控件 滑动到左右边缘的时候,最外层的 ViewPager 会默认消费掉左右滑动事件,导致滑向上一个 tab 或下一个 tab ,无任何的弹性效果。处理方式, 在弹性滑动控件 左右滑动的时候,需要禁止掉 ViewPager 的事件消费。

一个滑动控件需要解决这么多事件冲突,想一想,是时候使用抽屉里的菜刀了,但让我没想到的是,我拿着菜刀急冲冲找到产品,他却很淡定的从抽屉里拿出了手枪,拿出了手枪,我内心告诉自己不能怂,嘴上却不争气的说道:没问题,so easy ,给我2天时间,我真想给自己一大嘴巴,那么接下来就开整呗。

豆瓣弹性滑动控件

需要实现 豆瓣弹性滑动控件 的效果,先调研下豆瓣的布局方式:

在这里插入图片描述

uiautomatorviewer.bat 工具中可以分析出,豆瓣是通过自定义 LinearLayout 来实现的,包含了横向的 RecyclerView 与右侧的 释放查看TextView 文本子控件。那么 弹性滑动控件 实现的大概思路如下:RecyclerView 滑动到左右边缘,记录 x 轴方向的偏移量,通过方法 setTranslationX 设置 RecyclerView 的平移量,手指抬起则执行简单的平移动画,接下来会详细讲解,比较乏味,请系好安全带。

分解 弹性滑动 过程,新建HorizontalScrollView继承RelativeLayout,并没有继承LinearLayout,后面会讲到:

  1. RecyclerView 滑动到左边缘,继续向右滑动,HorizontalScrollView 拦截事件,同时记录x方向的偏移量dxRecyclerView 调用 setTranslationX 方法设置平移量 RecyclerView.setTranslationX(dx),这里又分两种情况:第一种手指抬起执行平移动画;第二种向左滑动除了 RecyclerView.setTranslationX(dx) 还需要判定 RecyclerView.getTranslationX() 是否等于 0 ,如果等于 0 则不拦截事件,返回 super.dispatchTouchEvent(ev)
  2. RecyclerView 滑动到右边缘,继续向左滑动,处理同1,还需根据偏移量来判定右侧的文本显示状态。
  3. RecyclerView 未滑动到左右边缘,HorizontalScrollView 不拦截事件,RecyclerView 消费左右滑动事件。

请结合以下代码加以理解:

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (mHorizontalRecyclerView == null) {
            return super.dispatchTouchEvent(ev);
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
               // 重置变量
                mHintLeftMargin = 0;
                mMoveIndex = 0;
                mConsumeMoveEvent = false;
                mLastX = ev.getRawX();
                mLastY = ev.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                // 释放动画
                if (ReboundAnim != null && ReboundAnim.isRunning()) {
                    break;
                }
                float mDeltaX = (ev.getRawX() - mLastX);
                float mDeltaY = ev.getRawY() - mLastY;
                
                mLastX = ev.getRawX();
                mLastY = ev.getRawY();
                mDeltaX = mDeltaX * RATIO;

                // 右滑
                if (mDeltaX > 0) {
                    //  canScrollHorizontally 判定是否滑动到边缘
                    if (!mHorizontalRecyclerView.canScrollHorizontally(-1) || mHorizontalRecyclerView.getTranslationX() < 0) {
                        float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
                        if (mHorizontalRecyclerView.canScrollHorizontally(-1) && transX >= 0) {
                            transX = 0;
                        }
                        mHorizontalRecyclerView.setTranslationX(transX);
                        setHintTextTranslationX(mDeltaX);
                    }
                } else if (mDeltaX < 0) { // 左滑
                    if (!mHorizontalRecyclerView.canScrollHorizontally(1) || mHorizontalRecyclerView.getTranslationX() > 0) {
                        float transX = mDeltaX + mHorizontalRecyclerView.getTranslationX();
                        if (transX <= 0 && mHorizontalRecyclerView.canScrollHorizontally(1)) {
                            transX = 0;
                        }
                        mHorizontalRecyclerView.setTranslationX(transX);
                        setHintTextTranslationX(mDeltaX);
                    }
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                // 释放动画
                if (ReboundAnim != null && ReboundAnim.isRunning()) {
                    break;
                }

                if (mHintLeftMargin <= mOffsetWidth && mListener != null) {
                   // 松手看更多的事件监听
                    mListener.onRelease();
                }
                // 手指抬起动画
                ReboundAnim = ValueAnimator.ofFloat(1.0f, 0);
                ReboundAnim.setDuration(300);
                ReboundAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                    @Override
                    public void onAnimationUpdate(ValueAnimator animation) {
                        float value = (float) animation.getAnimatedValue();
                        mHorizontalRecyclerView.setTranslationX(value * mHorizontalRecyclerView.getTranslationX());
                        mMoreTextView.setTranslationX(value * mMoreTextView.getTranslationX());
                    }
                });
                ReboundAnim.start();
                break;
        }
        return mHorizontalRecyclerView.getTranslationX() != 0 ? true : super.dispatchTouchEvent(ev);
    }

代码逻辑很清晰,有不理解的童鞋,请留言。弹性效果实现了,但右侧还有一个竖直的文本控件,ui 需要的效果如下图,需要实现的功能如下:

dou_4
  • 内容垂直排版
  • 文字间的间距需要可控
  • 可以设置图标
  • 贝塞尔曲线阴影,根据手指偏移量来动态改变贝塞尔曲线的控制点

很遗憾,原生的 TextView 并不支持内容垂直排版,间距也不可控,但欣慰的是支持设置图标,那么重写 onDraw 方法,自己绘制垂直文本,可谓是一个不错的方案。

VerticalTextView 继承 AppCompatTextView ,通过 setVerticalText() 方法设置绘制文本:

    public void setVerticalText(CharSequence text) {
        mDefaultText = text;
        invalidate();
    }

通过获取基线 baseline 坐标,以及整个字符的高度,来调整文本居中对齐,然后根据每个字符的高度,累加绘制文本:

    @Override
    protected void onDraw(Canvas canvas) {
        mPaint.setTextSize(getTextSize());
        mPaint.setColor(getCurrentTextColor());
        mPaint.setTypeface(getTypeface());
        CharSequence text = mDefaultText;
        if (getText() != null && !text.toString().trim().equals("")) {
            Rect bounds = new Rect();
            mPaint.getTextBounds(text.toString(), 0, text.length(), bounds);

            // 最开始就忘记 + getPaddingLeft 导致绘制的文本偏左
            float startX = getLayout().getLineLeft(0) + getPaddingLeft();

            if (getCompoundDrawables()[0] != null) {
                Rect drawRect = getCompoundDrawables()[0].getBounds();
                // 减去图标的宽度
                startX += (drawRect.right - drawRect.left);
            }

            startX += getCompoundDrawablePadding();

            float startY = getBaseline();

            int cHeight = (bounds.bottom - bounds.top + mCharSpacing);

            // 居中对齐
            startY -= (text.length() - 1) * cHeight / 2;

            for (int i = 0; i < text.length(); i++) {
                String c = String.valueOf(text.charAt(i));

                canvas.drawText(c, startX, startY + i * cHeight, mPaint);
            }
        }
        super.onDraw(canvas);
        // 绘制贝塞尔阴影
        if (mIsDrawShadow) {
            mShadowPath.reset();
            mShadowPath.moveTo(getWidth(), getHeight() / 4);
            mShadowPath.quadTo(mShadowOffset, getHeight() / 2, getWidth(), getHeight() / 4 * 3);
            canvas.drawPath(mShadowPath, mShadowPaint);
        }
    }

突然有个想法,如果以路径 Path 来绘制文本,岂不更棒,有兴趣的小伙伴可以下来试一试。弹性滑动控件 到这里就告一段落了,接下来主要处理集成到项目中的滑动事件冲突。

垂直RecyclerView滑动冲突

垂直 RecyclerView 会消费上下滑动事件,导致 弹性滑动控件 在水平方向滑动的时候,y 轴方向产生的偏移量被垂直 RecyclerView 消费,请看下图:

src_5

那么怎么来处理与垂直 RecyclerView 产生的事件冲突呢?处理事件冲突的方式有两种:

  1. View 禁止父 View 拦截 Touch 事件,在分析 ViewGroupdispatchTouchEvent() 源码时,我们知道:Touch 事件是由父 View 分发的。如果一个 Touch 事件是子 View 需要的,但是被其父 View 拦截了,子 View 就无法处理该 Touch 事件了。在此情形下,子 View 可以调用 requestDisallowInterceptTouchEvent( ) 禁止父 ViewTouch 的拦截
  2. 在父 View 中准确地进行事件分发和拦截 ,我们可以重写父 View 中与 Touch 事件分发相关的方法,比如onInterceptTouchEvent( )。这些方法中摒弃系统默认的流程,结合自身的业务逻辑重写该部分代码,从而使父View 放行子 View 需要的 Touch

这里以第一种的方式解决与垂直方向的 RecyclerView 滑动冲突,第二种方式解决与 ViewPager 的滑动冲突。原理非常的简单,判定 x 方向的偏移量是否大于 y 方向的偏移量,大于则禁止父 View 拦截 Touch 事件,反之则不拦截,具体代码如下:

    float mDeltaX = (ev.getRawX() - mLastX);
    float mDeltaY = ev.getRawY() - mLastY;
    if (!mConsumeMoveEvent) {
        // 处理事件冲突
        if (Math.abs(mDeltaX) > Math.abs(mDeltaY)) {
            getParent().requestDisallowInterceptTouchEvent(true);
        } else {
            getParent().requestDisallowInterceptTouchEvent(false);
        }
    }
    mMoveIndex++;
    if (mMoveIndex > 2) {
        mConsumeMoveEvent = true;
    }
    mLastX = ev.getRawX();
    mLastY = ev.getRawY();

很多时候触摸屏幕会导致第一次 ACTION_MOVE 获取的 mDeltaXmDeltaY 都为 0,导致父 View 拦截了 Touch 事件,弹性效果失效,为了解决这个问题,这里用到了一个小技巧,多判定一次拦截条件。大家发现没有,代码中还有一处优化的地方,getParent() 方法获取的父控件不一定是列表控件,比较合理的方式使用递归去获取,相关代码如下:

    private ViewParent getParentListView(ViewParent viewParent) {
        if (viewParent == null) return null;
        if (viewParent instanceof RecyclerView || viewParent instanceof ListView) {
            return viewParent;
        } else {
            getParentListView(viewParent.getParent());
        }
        return null;
    }

ViewPager滑动冲突

ViewPager 会默认消费左右滑动事件,当 弹性控件 滑动到左右边缘时,继续滑动会触发 ViewPager 的滑动,请看下图:

src_6

这里采用第二种方式处理滑动冲突,在父 View 中准确地进行事件分发和拦截,那么我们什么时候分发?又什么时候拦截呢?如果我们左右滑动的是非 弹性控件 区域,那么 ViewPager 应该拦截事件,反之则分发事件。

那么我们才能知道触摸的是 弹性控件 区域呢?可能在屏幕中的任何位置,我们知道 view 的层级是树形结构,那么针对 ViewPager 的子 view 进行遍历,拿到设有 弹性控件tag 标记,来进行事件的分发和拦截,具体代码如下,不知道小伙伴又没更好的方案:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mInterceptEvent = !childInterceptEvent(this, (int) ev.getRawX(), (int) ev.getRawY());
                break;
        }
        // 拦截与分发
        return mInterceptEvent ? super.onInterceptTouchEvent(ev) : false;
    }

    // 遍历树
    private boolean childInterceptEvent(ViewGroup parentView, int touchX, int touchY) {
        boolean isConsume = false;
        for (int i = parentView.getChildCount() - 1; i >= 0; i--) {
            View childView = parentView.getChildAt(i);
            if (!childView.isShown()) {
                continue;
            }
            boolean isTouchView = isTouchView(touchX, touchY, childView);
            if (isTouchView && childView.getTag() != null && TAG_DISPATCH.equals(childView.getTag().toString())) {
                isConsume = true;
                break;
            }
            if (childView instanceof ViewGroup) {
                ViewGroup itemView = (ViewGroup) childView;
                if (!isTouchView) {
                    continue;
                } else {
                    isConsume |= childInterceptEvent(itemView, touchX, touchY);
                    if (isConsume) {
                        break;
                    }
                }
            }
        }
        return isConsume;
    }
    // 是否在触摸区域内
    private boolean isTouchView(int touchX, int touchY, View view) {
        Rect rect = new Rect();
        view.getGlobalVisibleRect(rect);
        return rect.contains(touchX, touchY);
    }

感兴趣的小伙伴的可以以第一种方式来解决滑动冲突。文中涉及的知识点都是个人的看法,如果你觉得有什么地方不妥,欢迎指出?每个人在开发当中的场景可能都不一样,有时候需要根据特定的规则去处理滑动冲突,但是处理冲突的基本原理和方式是相同的,希望本篇文章对大家有所帮助,想了解更多炫酷控件,别忘了关注小编。

结语

源码小编整理后会上传到 MeiWidgetView ,同时非常希望各位小伙伴能够动手点颗 star ,你的鼓励与支持才是让小编继续创作的源泉。

推荐阅读更多精彩内容