自定义ViewGroup的弹性滑动、多点触控、滑动冲突

719 阅读9分钟

超简自定义ViewGroup

自定义ViewGroup需至少实现:onMeasure方法测量子View的宽高且保存自身宽高,onLayout方法布局子View。

代码如下,会存在一个bug,能看出来吗?代码动态添加3个子View, 子View显示不了。我们的目的是:子View串行显示。

 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int height = MeasureSpec.getSize(heightMeasureSpec);
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    int childWidth = 0;
    mChildCount = getChildCount();
    for (int i = 0; i < mChildCount; i++) {
        View child = getChildAt(i);
        if (child.getVisibility() != View.GONE) {
            mChildWidth = child.getMeasuredWidth();
            child.layout(childWidth + getPaddingLeft(), getPaddingTop(), childWidth + mChildWidth - getPaddingRight(), bottom - getPaddingBottom() - top);
            childWidth += child.getMeasuredWidth();
        }
    }
}

xml父控件:
<com.docwei.myviewdemo.scroll.MyOriginalView
    android:id="@+id/myview"
    android:layout_width="wrap_content"
    android:layout_height="240dp" />
    
//在代码中动态的添加子控件的代码,添加3次。
TextView tv = new TextView(this);
tv.setBackgroundColor(getResources().getColor(R.color.colorAccent2));
tv.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH__PARENT);
containerView.addView(tv);

子View显示不了bug分析:

重写了测量、布局方法, 却不能依次串行显示子View,现在页面显示空白,why ? 从debug日志看,我们发现:setMeasuredDimension(width, height)里面的width是屏幕宽度, 父控件的宽度是屏幕宽度,子View却为0? 翻看源码: measureChildren(widthMeasureSpec, heightMeasureSpec); 这里进去看:spec是父容器的MeasureSpec。

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    switch (specMode) {
    // Parent has imposed an exact size on us
    case MeasureSpec.EXACTLY:
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size. So be it.
            resultSize = size;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;

    // Parent has imposed a maximum size on us
    case MeasureSpec.AT_MOST:
        if (childDimension >= 0) {
            // Child wants a specific size... so be it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size, but our size is not fixed.
            // Constrain child to not be bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
        ......
    }
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

对照源码看这里的父ViewGroup是MeasureSpec.AT_MOST,子View是MATCH_PARENT ,决定了我们的子View的宽度是0到屏幕宽度的范围,实际宽度跟子View内容有关。这里子View内容是空,width是0,height为0,显示不出来没毛病。 但是咱们自定义的ViewGroup又是谁的子View呢,当然是mContentFrameLayout的子View,由于看不到系统的xml,只能假定mContentFrameLayout就是Match_Parent,加上我们给父控件保存宽度时传入的是绝对size, setMeasuredDimension(width, height) 那就能保证父ViewGroup是屏幕宽度。

左右滑动

scrollTo:滑动的是ViewGroup的内容物,移动子View到绝对位置。

scrollBy:滑动的是ViewGroup的内容物,移动子View到相对位置。

getScrollX( ): 右滑负数,左滑正数,以控件的left为原点(0,0),左滑,控件的内容会向左移动,此时原点和内容左缘的dx > 0;右滑,控件的内容会向右移动,此时原点和内容左缘的dx < 0。

当down事件按下时,记录x点,move事件时,二者相减获取scrollBy的值,up事件里需要处理好滑动的最终位置,如果滑动不到一半就回去,超过一半就滑出来,注意边界值判断。

public boolean onTouchEvent(MotionEvent event) {
    float x = event.getX();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //最初down时要保存到
            break;
        case MotionEvent.ACTION_POINTER_DOWN:
            break;
        case MotionEvent.ACTION_MOVE:
            //*0.85f是为了增加阻力f
            scrollBy(-(int) ((x - mLastX)*0.85f), 0);
            break;
        case MotionEvent.ACTION_UP:
            //处理最后定位的位置
            //getScrollX距离View的left滑动了多远
            //>0是左滑 <0是右滑*/
            int scrollX = getScrollX();
            //默认过了中点就算滑动一个子View
            int index = (scrollX + mChildWidth / 2) / mChildWidth;
            if (index > mChildCount - 1) {
                index = mChildCount - 1;
            }
            if (index < 0) {
                index = 0;
            }
            scrollBy(index * mChildWidth - scrollX,0);
            break;
        default:
            break;
    }
    mLastX = x;
    return true;
}

支持弹性滑动,模拟惯性

弹性滑动我们使用Scroller(下面替换成OverScroller也可以),模拟惯性使用VelocityTracker

Scroller的使用

Scroller mScroller = new Scroller(context);
public void startSmoothScrool(int start, int x) {
    mScroller.startScroll(start, 0, x, 0, 500);
    invalidate();
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

VelocityTracker的使用 (RecylcerView的onTouchEvent有使用VelocityTracker,可以参考)

VelocityTracker mVelocityTracker = VelocityTracker.obtain();
mVelocityTracker.addMovement(event); //添加事件
//up事件
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
//依据速度去处理逻辑
//左滑(xVelocity<0) 当速度达到200,此时如果index的float值在1.1——1.5那么给他2
if (Math.abs(xVelocity) >= 200 && left == index && xVelocity < 0) {
    index = (scrollX + mChildWidth) / mChildWidth;
}
//右滑(xVelocity>0) 当速度达到200,此时如果index的float值在3.1——3.4那么给他2
if (Math.abs(xVelocity) >= 200 && right == index && xVelocity > 0) {
    index = index - 1;
}
//清空速度
mVelocityTracker.clear();
//回收
@Override
protected void onDetachedFromWindow() {
    mVelocityTracker.recycle();
    super.onDetachedFromWindow();
}

多点触控

多点触控的必须掌握的要点

  1. 使用event.getActionMasked( ) 用于多点触控获取Action。

  2. 以下api要牢记了:

final  int actionIndex = event.getActionIndex();

final  int pointerId = event.getPointerId(actionIndex);

final  int pointerIndex = event.findPointerIndex(pointerId);

final  float x= event.getX(pointerIndex);

我们需要pointerIndex去获取事件内容。所有的多点触控的处理逻辑都是基于以上api

  1. event.getActionIndex() 获取到的actionIndex仅仅在事件序列中的down和up事件中是准确的。move事件不要用。

  2. PointerId在一次事件序列中是不会变的。

  3. actionIndex和PointerId都会存在补位的情况。

  4. action_pointer_down以及action_pointer_up触发时,一定至少还有一个手指在屏幕上的。

多点触控的场景:

  • 接力型(微信朋友圈下拉) 其本质是只追踪一根手指

    每一次按下新的手指,那么调整它为活动手指,只追踪这个活动手指, 如果是活动的手指pointer_up了,就需要指定还存在屏幕上的手指为活动的手指。

重新指定新的活动手指,这里提供两种方式:

方式一:
Pointer_UP事件:
if (event.getPointerId(actionIndex) == activePointer && event.getPointerCount() > 1) {
    //actionIndex存在补位机制
    int newIndex;
    if (actionIndex == event.getPointerCount() - 1) {
        newIndex = event.getPointerCount() - 2;
    } else {
        newIndex = event.getPointerCount() - 1;
    }
    activePointer = event.getPointerId( newIndex);
    final int newPointerIndex = event.findPointerIndex(activePointer);
    //pointerIndex out of range
    mLastX = event.getX(newPointerIndex);
}

方式二:
//不建议使用如下的方式,可能出现pointerIndex out of range异常
if (event.getPointerId(actionIndex) == activePointer && event.getPointerCount() > 1) {
    //如果当前的点是0,就选择1,因为至少有一个手指在View上 ,actionIndex存在补位机制
    activePointer = event.getPointerId((actionIndex == 0) ? 1 : 0);
    final int newPointerIndex = event.findPointerIndex(activePointer);
    //pointerIndex out of range
    mLastX = event.getX(newPointerIndex);
}
move事件要添加判断,谨防pointerIndex out of range
for (int i = 0; i < event.getPointerCount(); i++) {
    if (event.getPointerId(i) == activePointer) {
        float x = event.getX(event.findPointerIndex(activePointer));
        scrollBy(-(int) (x - mLastX) / 3, 0);
        mLastX = x;
    }
}
  • 互不干扰型(多指同时涂鸦) 其本质是追踪多根手指

    1. 所有的down事件发生时,需要记录所有的pointerId,因为一个事件序列中pointerId不会变。

    2. 在move事件基于pointerId对应的pointerIndex获取历史轨迹点。不用怕,系统提供了对应的api 如:getHistoricalX( )等。

    3. 由于每次up事件都有一个手指抬起,pointerId被回收,出现补位的情况, 要想保留之前绘制的内容,需要在up事件保存已有的path,

case MotionEvent.ACTION_MOVE:
     //拿到记录的PointerId的path
    for(Integer index:paths.keySet()) {
        for (int i = 0; i < event.getPointerCount(); i++) {
            int pointerId = event.getPointerId(i);
            if(index==pointerId){
                //History历史记录是最近一次move产生的,要记录完整的path,需要将每一次lineTo连接
                for(int j=0;j<event.getHistorySize();j++){
                    float x= event.getHistoricalX(event.findPointerIndex(pointerId),j);
                    float y= event.getHistoricalY(event.findPointerIndex(pointerId),j);
                    paths.get(index).lineTo(x,y);
                }
                //也要加入最新的点,跟下一次可能有重复
                paths.get(index).lineTo(event.getX(event.findPointerIndex(index))
                        ,event.getY(event.findPointerIndex(index)));
            }
        }
    }

滑动冲突

一、方向垂直的事件冲突

比如:我们写的自定义ViewGroup支持左右滑动,其3个子view是3个RecyclerView,recyclerView都是只支持上下滑动。 当我们出现这种场景的时候,我们发现左右滑动RecyclerView,竟然不支持左右滑动,需要处理冲突。

为什么会产生冲突?在哪里处理方便?

之前我们在自定义ViewGroup的时候,里面添加3个子View都是TextView,父容器完美的左右侧滑,现在加上RecyclerView为啥就不行了?

先来看下我们自定义的ViewGroup

//拦截的方法默认是false,不拦截

public boolean onInterceptTouchEvent(MotionEvent ev) {
    ....处理鼠标来源事件可忽略...
    return false;
}

其onTouchEvent方法是默认所有事件都返回true,能消费所有的事件。

  • a. 当子View全部是TextView的时候,TextView走了View的onTouchEvent方法,那View的onTouchEvent方法里面,根据clickable来判断能否消费事件, 如果当前控件没设置setOnClickListener,那么clickable是false,也就是说这几个子View不能消费事件。事件往上传递,父ViewGroup重写了onTouchEvent方法。 可以消费事件,那父控件就把事件消费了。(当然你给TextView设置了点击监听,这个自定义ViewGroup也会出现左右滑不动的情况)
  • b. 换成RecyclerView,RecyclerView的onTouchEvent,无任何判断,默认返回true,表示不管怎样都要消费事件,父ViewGroup就没法拿到这个事件,滑不动,正常啊。

既然找到原因了,那处理就很简单:

方式1 . 事件先是父ViewGroup持有,那何不在ViewGroup的拦截事件里面去对左右滑动事件截胡呢,这样的确很方便。

注意一个关键点: 父ViewGroup只能消费左右move事件,其onTouchEvent事件里面的down事件是不走的,但是每一次事件都会经过父ViewGroup的onInterceptTouchEvent判断,所以只能在onInterceptTouchEvent获取down事件,保存按下的点的位置给父ViewGroup的onTouchEvent中的move用。

private float lastDownX;
    private float lastDownY;
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int actionIndex = ev.getActionIndex();
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
            case MotionEvent.ACTION_POINTER_DOWN:
                //第一次按下或者后续按下,都将其作为活动的手指
                activePointer = ev.getPointerId(actionIndex);
                final int pointerIndex = ev.findPointerIndex(activePointer);
                lastDownX = ev.getX(pointerIndex);
                lastDownY = ev.getY(pointerIndex);
                break;
            case MotionEvent.ACTION_MOVE:
                for (int i = 0; i < ev.getPointerCount(); i++) {
                    if (ev.getPointerId(i) == activePointer) {
                        float x = ev.getX(ev.findPointerIndex(activePointer));
                        float y = ev.getY(ev.findPointerIndex(activePointer));
                        float deltaX = x - lastDownX;
                        float deltaY = y - lastDownY;
                        //左右滑动
                        if (Math.abs(deltaX) > Math.abs(deltaY)) {
                            return true;
                        }
                    }
                }
            default:
                break;
        }
        return false;
    }
}

方式2(不可行). 重写RecyclerView的onTouchEvent事件,对左右滑动不消费。貌似也可以。但实操时仅仅对RecyclerView重写onTouchEvent方法是不成功。

二、方向平行的事件冲突

ScrollView嵌套RecyclerView:都是上下滑动,嵌套多个RecyclerView,第二个、第三个RecyclerView滑动卡卡的,事件全部被ScrollView吞了。

能修改ScrollView吗,可以。但是处理很麻烦。

只能让RecyclerView在想要上下滑动事件时老老实实跟Parent说我要这个上下滑动(申请不拦截),但是又有一个问题,RecyclerView把所有的上下滑动都申请不拦截了,那第一个RecylerView滑到底部了, 要滑动第二个RecyclerView,怎么办?只能让第一个RecyclerView滑到底部,将上下滑动事件还给Parent,让他消费。这样才能显示后面的RecyclerView。

在处理前,先看下ViewGroup的dispatchTouchEvent源码

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    .....
    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        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 {
        // There are no touch targets and this action is not an initial down
        // so this view group continues to intercept touches.
        intercepted = true;
    }
.....
    }

这个源码部分要记住,到时候面试可以拿出来说,这个点,决定处理冲突时为啥子View要在down事件里面一定要返回true;

mFirstTouchTarget:不为null,表示有子View处理事件序列中的某个action了。

当down事件传过来时,如果子View没有申请不拦截,那么就走父容器的 onInterceptTouchEvent(ev),由于ViewGroup默认不拦截,自定义的ViewGroup除外,down事件继续传到子View,子View在onTouchEvent 的down返回false,表示不消费down事件,那这个事件序列中的move up 事件通通都不会传给子View了,因为mFirstTouchTarget=null,直接走(intercepted = true;)逻辑。

总结默认情况下的ViewGroup的2个论点:

父ViewGroup拦截down事件,子View是不可能拿到任何事件的。因为事件序列以down事件开始。

父ViewGroup不拦截down事件,如果子View不消费down事件,那后续move、 up事件都不经过父容器的onInterceptTouchEvent(ev)判断直接就不分发给子View了;子View消费down事件,后续的事件都会走父容器的onInterceptTouchEvent(ev),如果此时父容器拦截move事件,那子View是没法拿到move事件的,此时子View会触发其cancel事件。

解决冲突的代码:

 private float lastDownX;
 private float lastDownY;
//scrollView嵌套多个子RecyclerView,
 //不处理冲突,那么滑到第二个RecyclerView就会卡卡的
 //处理:OnTouchEvent事件里面先设置消费down事件
 @Override
 public boolean onTouchEvent(MotionEvent ev) {
     switch (ev.getActionMasked()){
         case MotionEvent.ACTION_DOWN:
             lastDownX = ev.getX();
             lastDownY = ev.getY();
             //down事件必须消费
             return true;
         case MotionEvent.ACTION_MOVE:
             float moveX = ev.getX();
             float moveY = ev.getY();
             float deltaX = moveX - lastDownX;
             float deltaY = moveY - lastDownY;
             //上下滑动
             Log.e("scrollView  ", "dispatchTouchEvent: " + Math.abs(deltaX) + "-----lllll" +Math.abs(deltaY));
             if (Math.abs(deltaX) < Math.abs(deltaY)) {
                 getParent().requestDisallowInterceptTouchEvent(true);
             }
             //滑动最后一个Item的时候要置为false,不然没法滑到下一个RecyclerView
             LinearLayoutManager linearLayoutManager= (LinearLayoutManager) getLayoutManager();
             if(linearLayoutManager.findLastVisibleItemPosition()==getAdapter().getItemCount()-1){
                 getParent().requestDisallowInterceptTouchEvent(false);
             }
             lastDownX = moveX;
             lastDownY = moveY;
             break;
     }
     return super.onTouchEvent(ev);
 }

附上demo

自定义ViewGroup支持弹性滑动、多点触控

两种冲突类型处理