事件分发 & 嵌套滑动 & 滑动冲突

事件序列

指从手指刚接触屏幕,到手指离开屏幕的那一刻结束,在这一过程产生的一系列事件,这个序列一般以 Down 事件开始,中间含有多个 Move 事件,最终以 Up 事件结束

事件传递的顺序:Activity -> Window -> DecorView -> ViewGroup -> View

事件分发机制主要由 事件分发 -> 事件拦截 -> 事件处理 三步来进行逻辑控制,很巧的这三步刚好对应了三个核心方法:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent

Activity、Window 核心代码

// Activity
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 事件由上向下传递,由下向上消费
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    //如果Window的dispatchTouchEvent返回了false,则点击事件传递给Activity.onTouchEvent
    return onTouchEvent(ev);
}

// PhoneWindow
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
     return mDecor.superDispatchTouchEvent(event);
}

ViewGroup 主要逻辑在 dispatchTouchEvent 方法中,主要流程有:

  1. 在事件序列开始时重置标记位
  2. 判断是否需要拦截,子 View 是否设置了 DISALLOW_INTERCEPT
  3. 如果 mFirstTouchTarget 不为空,直接把事件传给子 View,否则循环遍历子 View,询问是否消费事件
  4. 如果没有子View消费事件,则调用自身的onTouchEvent
  5. 返回最终消费结果(子View.dispatchTouchEvent || onTouchEvent)
// ViewGroup
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (actionMasked == MotionEvent.ACTION_DOWN) {
        // Throw away all previous state when starting a new touch gesture.
        // The framework may have dropped the up or cancel event for the previous gesture
        // due to an app switch, ANR, or some other state change.
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }
    final boolean intercepted;  //是否拦截
    // 当事件由ViewGroup的子元素处理时,mFirstTouchTarget会被赋值并指向子元素
    if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
        // FLAG_DISALLOW_INTERCEPT标记位是通过requestDisallowInterceptTouchEvent方法来设置,一般用在子View中。
        // 如果FLAG_DISALLOW_INTERCEPT被设置后,ViewGroup将无法拦截除了ACTION_DOWN以外的其它点击事件,这是
        // 因为ViewGroup在分发事件中,如果是ACTION_DOWN事件,将会重置FLAG_DISALLOW_INTERCEPT这个标记位
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);
        } 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;
    }
    boolean handled = false;
    if (!intercepted) {
        // 遍历所有子View
        if (mFirstTouchTarget != null) {
            mFirstTouchTarget.dispatchTouchEvent(ev)
            handled = true
        } else {
            for(child in Childs) {
                if (child.dispatchTouchEvent(ev)) {
                    mFirstTouchTarget = child
                    handled = true
                    break
                }
            }
        }
    }
    return handled || super.dispatchTouchEvent(ev) //交给View处理点击事件
}

View 主要逻辑如下:


// View
public boolean dispatchTouchEvent(MotionEvent ev) {
    return mOnTouchListener.onTouch(this, event) || onTouchEvent(event)
}

// TouchDelegate在onTouch之后,在onTouchEvent之前,
// TouchDelegate没有破坏事件传递机制,事件还是会直接传递给父类,然后在由父类继续dispatch
// 父View在消费事件时会判断是否设置了mTouchDelegate,如果设置了mTouchDelegate,
// 会调用mTouchDelegate的onTouchEvent方法
public boolean onTouchEvent(MotionEvent event) {
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
            || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
            || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
    if ((viewFlags & ENABLED_MASK) == DISABLED) {
        return clickable;
    }
    if (mTouchDelegate != null) {
        if (mTouchDelegate.onTouchEvent(event)) {
            return true;
        }
    }
    if (clickable) {
        return true
    }
    return false
}

public class TouchDelegate {
    // 该方法会判断点击事件是否在扩展的范围内:如果在范围内,则修改点击事件位置,
    // 确保事件一定在View范围内,调用对应View的dispatch方法;如果不在范围内,
    // 也会修改事件位置,确保点击事件一定不在View范围内,调用对应View的dispatch方法,
    // 最终返回dispatch结果。题外话,只要当前层消费了DOWN事件,其余事件是否消费都会传递给该层
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int)event.getX();
        int y = (int)event.getY();
        boolean sendToDelegate = false;
        boolean hit = true;
        boolean handled = false;

        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            Rect bounds = mBounds;

            if (bounds.contains(x, y)) {
                mDelegateTargeted = true;
                sendToDelegate = true;
            }
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_MOVE:
            sendToDelegate = mDelegateTargeted;
            if (sendToDelegate) {
                Rect slopBounds = mSlopBounds;
                if (!slopBounds.contains(x, y)) {
                    hit = false;
                }
            }
            break;
        case MotionEvent.ACTION_CANCEL:
            sendToDelegate = mDelegateTargeted;
            mDelegateTargeted = false;
            break;
        }
        if (sendToDelegate) {
            final View delegateView = mDelegateView;

            if (hit) {
                // Offset event coordinates to be inside the target view
                event.setLocation(delegateView.getWidth() / 2, delegateView.getHeight() / 2);
            } else {
                // Offset event coordinates to be outside the target view (in case it does
                // something like tracking pressed state)
                int slop = mSlop;
                event.setLocation(-(slop * 2), -(slop * 2));
            }
            handled = delegateView.dispatchTouchEvent(event);
        }
        return handled;
    }
}

//com.lede.train.commondemo E/offer: CustomRelativeLayout dispatchTouchEvent 0
//com.lede.train.commondemo E/offer: CustomButton dispatchTouchEvent 0
//com.lede.train.commondemo E/offer: CustomButton onTouchEvent 0
//com.lede.train.commondemo E/offer: CustomRelativeLayout dispatchTouchEvent 1
//com.lede.train.commondemo E/offer: CustomButton dispatchTouchEvent 1
//com.lede.train.commondemo E/offer: CustomButton onTouchEvent 1

//com.lede.train.commondemo E/offer: CustomRelativeLayout dispatchTouchEvent 0
//com.lede.train.commondemo E/offer: CustomRelativeLayout onTouchEvent 0
//com.lede.train.commondemo E/offer: CustomButton dispatchTouchEvent 0
//com.lede.train.commondemo E/offer: CustomButton onTouchEvent 0
//com.lede.train.commondemo E/offer: CustomRelativeLayout dispatchTouchEvent 1
//com.lede.train.commondemo E/offer: CustomRelativeLayout onTouchEvent 1
//com.lede.train.commondemo E/offer: CustomButton dispatchTouchEvent 1
//com.lede.train.commondemo E/offer: CustomButton onTouchEvent 1

Demo演示

要求如下:中插卡片(发现小组)需要屏蔽 ViewPager 的左右滑动功能,需要支持 Feed 流竖向滑动,中插卡片内部支持卡片横向滑动。UI效果图如下:

设计思路:如果子视图消费事件,并且没有调用disallowIntercept方法,理论上最外层视图会优先拦截事件

由于这个需求ViewPager和RecyclerView拦截事件的条件有重合,而且我们想让RecyclerView优先拦截事件,我们只能定制ViewPager、RecyclerView,并通过中插卡片Layout来控制ViewPager和RecyclerView的拦截标记位。

先打开RecyclerView拦截标记位,然后在RecyclerView的onInterceptTouchEvent中调用disallow方法,并且在中插卡片Layout的dispatch方法中监听ACTION_UP和ACTION_CANCEL事件,恢复ViewPager和RecyclerView可拦截状态

需要注意的是,中插卡片Layout一定要消费事件(onTouchEvent返回true),否则没办法主动控制点击到卡片灰色部分的逻辑。

关键代码如下:

class ForbidHorizontalScrollLinearLayout : LinearLayout {

    private var mTouchSlop: Int = 0
    private var mInitialMotionX: Float = 0f
    private var mInitialMotionY: Float = 0f

    constructor(context: Context?) : super(context) {
        init(context)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        init(context)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init(context)
    }

    fun init(context: Context?) {
        val configuration: ViewConfiguration? = context?.let { ViewConfiguration.get(it) }
        mTouchSlop = configuration?.scaledTouchSlop ?: 8 * 2
    }

    /**
     * RecyclerView
     *
     * final ViewConfiguration vc = ViewConfiguration.get(context);
     * mTouchSlop = vc.getScaledTouchSlop();
     * if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
     *    mLastTouchX = x;
     *    startScroll = true;
     * }
     * if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
     *    mLastTouchY = y;
     *    startScroll = true;
     * }
     *
     * ViewPager
     *
     * final ViewConfiguration configuration = ViewConfiguration.get(context);
     * mTouchSlop = configuration.getScaledPagingTouchSlop();
     * if (xDiff > mTouchSlop && xDiff * 0.5f > yDiff) {
     *     mIsBeingDragged = true;
     * }
     *
     */
    override fun dispatchTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                mInitialMotionX = event.x
                mInitialMotionY = event.y
                changeAllScrollViewState(false)
            }
            MotionEvent.ACTION_MOVE -> {
                handleRecyclerView(event)
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                changeAllScrollViewState(true)
            }
        }
        return super.dispatchTouchEvent(event)
    }

    private fun changeAllScrollViewState(canScroll: Boolean) {
        var viewParent = parent
        while (viewParent != null) {
            if (viewParent is ScrollStateConfigurableViewPager) {
                viewParent.scrollEnable = canScroll
            } else if (viewParent is ScrollStateConfigurableRecyclerView) {
                viewParent.scrollEnable = canScroll
            }
            viewParent = viewParent.parent
        }
    }

    private fun switchOnRecyclerView() {
        var viewParent = parent
        while (viewParent != null) {
            if (viewParent is ScrollStateConfigurableRecyclerView) {
                viewParent.scrollEnable = true
                break
            }
            viewParent = viewParent.parent
        }
    }

    private fun handleRecyclerView(event: MotionEvent) {
        if (abs(event.y - mInitialMotionY) > mTouchSlop) {
            if (abs(event.x - mInitialMotionX) * 0.5f < abs(event.y - mInitialMotionY)) {
                switchOnRecyclerView()
            }
        }
    }

    /**
     * 强制返回true
     */
    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean = true
}

RecyclerView:

class ConfigurableRecyclerView : PeppaPagingRecyclerView {

    var scrollEnable = true

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle)

    override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
        return if (scrollEnable) {
            if (super.onInterceptTouchEvent(e)) {
                requestDisallowInterceptTouchEvent(true)
                true
            } else {
                false
            }
        } else {
            false
        }
    }
}

ViewPager:

class ConfigurableViewPager : SSViewPager {

    var scrollEnable = true

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    override fun onInterceptTouchEvent(event: MotionEvent?): Boolean {
        return if (scrollEnable) {
            return super.onInterceptTouchEvent(event)
        } else {
            false
        }
    }
}

嵌套滑动机制

NestedScrollingChild:如果一个 View 想要能够产生嵌套滑动事件,这个 View 必须实现 NestedScrollChild 接口。从 Android 5.0 开始,View 实现了这个接口,不需要我们手动实现

NestedScrollingParent:这个接口通常用来被 ViewGroup 来实现,表示能够接收从子 View 发送过来的嵌套滑动事件

NestedScrollingChildHelper:这个类通常在实现 NestedScrollChild 接口的 View 里面使用,他通常用来负责将子 View 产生的嵌套滑动事件报告给父 View。也就是说,如果一个子 View 想要将产生的嵌套滑动事件交给父 View,这个过程不需要我们来实现,而是交给 NestedScrollingChildHelper 来帮助我们处理

NestedScrollingParentHelper:这个类跟 NestedScrollingChildHelper 差不多,也是帮助来传递事件的,不过这个类通常用在实现 NestedScrollingParent 接口的 View。如果一个父 View 不想处理一个事件,通过 NestedScrollingParentHelper 类帮助我们传递就行了

子 View 事件处理:整个事件传递过程中,首先能保证传统的事件能够到达子 View,当一个事件序列开始时,首先会调用 startNestedScroll 方法来告诉父 View,马上就要开始一个滑动事件了,请问父控件是否需要处理,如果处理的话,会返回 true,不处理返回 fasle。跟传统的事件传递一样,如果不处理的话,那么该事件序列的其他事件都不会传递到父 View 里面。然后就是调用 dispatchNestedPreScroll 方法,这个方法调用时,子 View 还未真正滑动,所以这个方法的作用是子 View 告诉它的父 View ,此时滑动的距离已经产生,父 View 能消耗多少,然后父 View 会根据情况消耗自己所需的距离,如果此时距离还未消耗完,剩下的距离子 View 来消耗,子 View 滑动完毕之后,会调用 dispatchNestedScroll 方法来告诉父 View,我已经滑动完毕,你看看你有什么要求没?这个过程里面可能有子 View 未消耗完的距离。其次就是 fling 事件产生,过程跟上面也是一样,也是先调用 dispatchNestedPreFling 方法来询问父 View 是否有所行动,然后调用 dispatchNestedFling 告诉父 View,子 View 已经 fling 完毕。最后就是调用 stopNestedScroll 表示本次事件序列结束。整个过程中,我们会发现子 View 开始一个动作时,会询问父 View 是否有所表示,结束一个动作时,也会告诉父 View,自己的动作结束了,父 View 是否有所指示

父 View 事件处理:在系统中,没有特定 ViewGroup 用来接收和消耗子 View 传递的事件。因此,只能自己动手了。在整个实现过程中,我们发现,我们只对 onStartNestedScroll 方法和 onNestedPreScroll 方法做了我们自己的实现,其他的要么空着,要么就是通过 NestedScrollingParentHelper 来帮助我们来实现

总结:

1、跟传统的事件分发不同,嵌套滑动是由子 View 传递给父 View,是从下到上的,传统事件的分发是从上到下的
2、如果一个 View 想要传递嵌套滑动的事件,有两个前提:实现 NestedScrollingChild 接口;setNestedScrollingEnabled 方法设置为 true。如果一个 ViewGroup 想要接收和消耗子 View 传递过来的事件,必须实现 NestedScrollingParent 接口

GestureDetector

GestureDetector gestureDetector = new GestureDetector(getContext(),
                                     new GestureDetector.OnGestureListener() {
    @Override
    public boolean onDown(MotionEvent e) {
        return false;
    }

    @Override
    public void onShowPress(MotionEvent e) {

    }

    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        return false;
    }

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2,
                                                float distanceX, float distanceY) {
        return false;
    }

    @Override
    public void onLongPress(MotionEvent e) {

    }

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2,
                                                float velocityX, float velocityY) {
        return false;
    }
});

滑动冲突

分为两种情况,解决方法无非以下几种:

  1. 通过 touchSlot 来判断滑动方向,进而 Parent 调用 intercept,或者 Child 调用 disallowIntercept 防止 Parent 拦截
  2. 所有事件都由 Child 消费,通过 NestScroll 方式来让 Parent 动态消费
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

little-sparrow

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值