Android-高级-UI-进阶之路-(二)-深入理解-Android-8-0-View-触摸事件分发机制(1)

}
}

return result;
}

View 中的事件处理逻辑比较简单,我们先看注释 1 处,如果我们外部设置了 mOnTouchListener 点击事件,那么就会执行 onTouch 回调,如果该回调的返回值为 false ,那么才会执行 onTouchEvent 方法,可见onTouchListener 优先级高于 onTouchEvent 方法,下面我们来分析 onTouchEvent 方法实现,代码如下:

//View.java
public boolean onTouchEvent(MotionEvent event) {
final float x = event.getX();
final float y = event.getY();
final int viewFlags = mViewFlags;
final int action = event.getAction();

final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

/**

  • 1. View 处于不可用状态下的点击事件的处理过程
    */
    if ((viewFlags & ENABLED_MASK) == DISABLED) {
    if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
    setPressed(false);
    }
    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
    // A disabled view that is clickable still consumes the touch
    // events, it just doesn’t respond to them.
    return clickable;
    }

/**

  • 2. 如果 View 设置了代理,那么还会执行 TouchDelegate 的 onTouchEvent 方法。
    */
    if (mTouchDelegate != null) {
    if (mTouchDelegate.onTouchEvent(event)) {
    return true;
    }
    }

/**

  • 3. 如果 clickable 或 (viewFlags & TOOLTIP) == TOOLTIP 有一个成立那么就会处理该事件
    */
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
    switch (action) {
    case MotionEvent.ACTION_UP:
    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
    if ((viewFlags & TOOLTIP) == TOOLTIP) {
    handleTooltipUp();
    }
    if (!clickable) {
    removeTapCallback();
    removeLongPressCallback();
    mInContextButtonPress = false;
    mHasPerformedLongPress = false;
    mIgnoreNextUpEvent = false;
    break;
    }
    // 用于识别快速按下
    boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
    if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {

boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}

if (prepressed) {
setPressed(true, x, y);
}

if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
removeLongPressCallback();
if (!focusTaken) {
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
/**

  • 如果设置了点击事件 mOnClickListener 就会执行内部回调
    */
    if (!post(mPerformClick)) {
    performClick();
    }
    }
    }

if (mUnsetPressedState == null) {
mUnsetPressedState = new UnsetPressedState();
}

if (prepressed) {
postDelayed(mUnsetPressedState,
ViewConfiguration.getPressedStateDuration());
} else if (!post(mUnsetPressedState)) {
// If the post failed, unpress right now
mUnsetPressedState.run();
}

removeTapCallback();
}
mIgnoreNextUpEvent = false;
break;

case MotionEvent.ACTION_DOWN:

//判断是否是在滚动容器中
boolean isInScrollingContainer = isInScrollingContainer();
if (isInScrollingContainer) {
mPrivateFlags |= PFLAG_PREPRESSED;
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
mPendingCheckForTap.x = event.getX();
mPendingCheckForTap.y = event.getY();
//发送一个延迟执行长按事件的操作
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
} else {
// Not inside a scrolling container, so show the feedback right away
setPressed(true, x, y);
checkForLongClick(0, x, y);
}
break;

case MotionEvent.ACTION_CANCEL:
if (clickable) {
setPressed(false);
}
//移除一些回调比如长按事件
removeTapCallback();
removeLongPressCallback();
mInContextButtonPress = false;
mHasPerformedLongPress = false;
mIgnoreNextUpEvent = false;
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
break;

case MotionEvent.ACTION_MOVE:
if (clickable) {
drawableHotspotChanged(x, y);
}
if (!pointInView(x, y, mTouchSlop)) {
//移除一些回调比如长按事件
removeTapCallback();
removeLongPressCallback();
if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
}
break;
}

return true;
}

return false;
}

上面代码虽然比较多,但是逻辑还是很清楚的,我们来分析一下

  1. 判断 View 是否处于不可用的状态下使用,返回一个 clickable 。
  2. 判断 View 是否设置了代理,如果设置了代理将会执行 代理的 onTouchEvent 方法。
  3. 如果 clickable 或 (viewFlags & TOOLTIP) == TOOLTIP 有一个成立那么就会处理 MotionEvent 事件。
  4. 在 MotionEvent 事件中分别会在 up 和 down 中会执行点击 onClick 和 onLongClick 回调。

到这里点击事件的分发机制源码实现已经分析完了,结合之前分析的传递规则和下面这张图,然后结合源码相信你应该理解了事件分发跟事件处理机制了。

滑动冲突

本小节将介绍 View 体系中一个非常重要的知识点滑动冲突,相信在开发中特别是做一些滑动效果处理的时候而且还不止一层滑动,又的是嵌套好几层的滑动,那么它们之间如果不解决滑动冲突必定是不可行的,下面我们先来看看造成滑动冲突的场景。

滑动冲突场景及处理规则

1. 外部滑动方向和内部滑动方向不一致

主要是将 ViewPager 和 Fragment 配合使用所组成的页面滑动效果,主流应用几乎都会使用这个效果。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往是一个 RecyclerView 。本来这种情况下是有滑动冲突的,但是 ViewPager 内部处理了这种滑动冲突,因此采用 ViewPager 时我们无须关注这个问题,但是如果我们采用的是 ScrollView 等滑动控件,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只能由一层能够滑动,这是因为两者之间的滑动事件有冲突。

它的处理规则是:

当用户左右滑动时,需要让外部的 View 拦截点击事件,当用户上下滑动的时候,需要让内部的 View 拦截点击事件。这个时候我们就可以根据他们的特征来解决滑动冲突。具体来说就是可以通过判断滑动手势是水平方向还是竖直方向具体来对应拦截事件。

2. 外部滑动方向和内部滑动方向一致

这种情况就稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。因为当手指开始滑动的时候,系统无法知道用户到底是想让那一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动得很卡顿。在实际的开发中,这种场景主要是指内外两层同时能上下滑动或者内外两层同时能左右滑动。

它的处理规则是:

这种事比较特殊的,因为它无法根据滑动的角度、距离差以及速度差来做判断,但是这个时候一般都能在业务上找到突破点,比如业务有规定,当处理某种状态的时候需要外部 View 响应用户的滑动,而处于另外一种状态时则需要内部 View 来响应 View 的滑动,根据这种业务上的需求我们也能得出相应的处理规则,有了处理规则同样可以进行下一步处理。这种场景通过文字描述可能比较抽象,在下一小节中我们会通过实际例子来演示这种情况。

3. 1 + 2 场景的嵌套

场景三是场景一和场景二两种情况的嵌套,因此场景三的滑动冲突看起来就更加复杂了。比如在许多应用中会有这么一个效果:内层有一个场景 1 中的滑动效果,然后外层又有一个场景 2 中的滑动效果。虽然说场景三的滑动冲突看起来是比较复杂的,但是它是几个单一的滑动冲突的叠加,所以只需要分别处理内中外层之间的冲突就行了,处理方式跟场景 1 和 2 一致。

下面我们就来看一下滑动冲突的处理规则。

它的处理规则是:

它的滑动规则就更复杂了,和场景 2 一样,它也无法直接根据滑动的角度、距离以及速度差来做判断,同样还是只能从业务员上找到突破点,具体方法和场景 2 一样,都是从业务的需求上得出相应的处理规则,在下一节中同样会给出代码示例来进行演示。

滑动冲突的解决方式

上面说过针对场景 1 中的滑动,我们可以根据滑动的距离差来进行判断,这个距离差就是所谓的滑动规则。如果用 ViewPager 去实现场景 1 中的效果,我们不需要手动处理滑动冲突,因为 ViewPager 已经帮我们做了,但是这里为了更好的演示滑动冲突解决思想,没有采用 ViewPager 。其实在滑动过程中得到滑动的角度这个是相当简单的,但是到底要怎么做才能将点击事件交给合适的 View 去处理呢?这时就要用到 3.4 节所讲述的事件分发机制了。针对滑动冲突,这里给出 2 种解决滑动冲突的方式,外部拦截和内部拦截发。

  1. 外部拦截法

所谓外部拦截就是指点击事件先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。外部拦截法需要重写 onInterceptTouchEvent方法,在内部做响应的拦截即可,可以参考下面代码:

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
isIntercepted = false
}
MotionEvent.ACTION_MOVE -> {
//拦截子类的移动事件
if (true) {
println(“事件分发机制开始分发 ----> 拦截子类的移动事件 onInterceptTouchEvent”)
isIntercepted = true
} else {
isIntercepted = false
}

}
MotionEvent.ACTION_UP -> {
isIntercepted = false
}
}
return isIntercepted
}

上述代码是外部拦截的典型逻辑,针对不同的滑动冲突只需要修改父容器需要当前点击事件这个条件即可,其它均不做修改也不能修改。这里对上述代码再描述一下,在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 这个事件,父容器必须返回 false 。既不拦截 ACTION_DOWN 事件,这是因为一旦父容器拦截了 ACTION_DOWN , 这是因为一旦父容器拦截 ACTION_DOWN, 那么后续的 ACTION_DOWN, 那么后续的 ACTION_MOVE 和 ACTION_UP 事件都会直接交由父容器处理,这个时候事件没法再传递给子元素了;其次是 ACTION_MOVE 事件,这个事件可以根据需要来决定是否拦截,如果是 ACTION_UP 事件,这里必须要返回 false , 因为 ACTION_UP 事件本身没有太多意义。

考虑一种情况,假设事件交由子元素处理,如果父容器在 ACTION_UP 时返回了 true ,就会导致子元素无法接收到 ACTION_UP 事件,这个时候子元素中的 onClick 事件就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它来处理,而 ACTION_UP 作为最后一个事件也必定可以传递给父容器,即便父容器的 onInterceptTouchEvent 方法在 ACTION_UP 时返回了 false.

  1. 内部拦截法

内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和 Android 中的事件分发机制不一致,在讲解源码的时候,我们讲解了 ,可以通过 requestDisalloWInterceptTouchEvent 方法才能正常工作,使用起来较外部拦截法稍显复杂,我们需要重写子元素的 dispatchTouchEvent 方法

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
println(“事件分发机制开始分发 ----> 子View dispatchTouchEvent ACTION_DOWN”)
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> {
println(“事件分发机制开始分发 ----> 子View dispatchTouchEvent ACTION_MOVE”)
if (true){
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
println(“事件分发机制开始分发 ----> 子View dispatchTouchEvent ACTION_UP”)
}
}
return super.dispatchTouchEvent(event)
}

上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其它不需要做改动而且也不能有改动,除了子元素需要做处理以外,父元素也要默认拦截除了 ACTION_DOWN 以外的其它事件,这样当子元素调用 parent.requestDisallowInterceptTouchEvent(false) ,父元素才能继续拦截所需的事件。

下面就以实战的 demo 具体来说明一下。

实战

场景一 滑动冲突案例

我们自定义一个 ViewPager + RecyclerView 包含左右 + 上下滑动,这样就满足了我们场景一的滑动冲突,我们先来看一下完整的效果图:

上面录屏的效果解决了上下滑动跟左右滑动冲突,实现方式就是自定义 ViewGroup 利用 Scroller 达到像 ViewPager 一样丝滑般的感觉 ,然后内部添加了 3 个 RecyclerView 。

我们看一下自定义 ViewGroup 实现:

class ScrollerViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
/**

  • 定义 Scroller 实例
    */
    private var mScroller = Scroller(context)

/**

  • 判断拖动的最小移动像素点
    */
    private var mTouchSlop = 0

/**

  • 手指按下屏幕的 x 坐标
    */
    private var mDownX = 0f

/**

  • 手指当前所在的坐标
    */
    private var mMoveX = 0f

/**

  • 记录上一次触发 按下是的坐标
    */
    private var mLastMoveX = 0f

/**

  • 界面可以滚动的左边界
    */
    private var mLeftBorder = 0

/**

  • 界面可以滚动的右边界
    */
    private var mRightBorder = 0

/**

  • 记录下一次拦截的 X,y
    */
    private var mLastXIntercept = 0
    private var mLastYIntercept = 0

/**

  • 是否拦截
    */
    private var interceptor = false

init {
init()
}

constructor(context: Context?) : this(context, null) {
}

private fun init() {
/**

  • 通过 ViewConfiguration 拿到认为手指滑动的最短的移动 px 值
    */
    mTouchSlop = ViewConfiguration.get(context).scaledPagingTouchSlop

}

/**

  • 测量 child 宽高
    */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    //拿到子View 个数
    val childCount = childCount
    for (index in 0…childCount - 1) {
    val childView = getChildAt(index)
    //为 ScrollerViewPager 中的每一个子控件测量大小
    measureChild(childView, widthMeasureSpec, heightMeasureSpec)

}
}

/**

  • 测量完之后,拿到 child 的大小然后开始对号入座
    */
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    if (changed) {
    val childCount = childCount
    for (child in 0…childCount - 1) {
    //拿到子View
    val childView = getChildAt(child)
    //开始对号入座
    childView.layout(
    child * childView.measuredWidth, 0,
    (child + 1) * childView.measuredWidth, childView.measuredHeight
    )
    }
    //初始化左右边界
    mLeftBorder = getChildAt(0).left
    mRightBorder = getChildAt(childCount - 1).right

}

}

/**

  • 外部解决 1. 根据垂直或水平的距离来判断
    */
    // override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    // interceptor = false
    // var x = ev.x.toInt()
    // var y = ev.y.toInt()
    // when (ev.action) {
    // MotionEvent.ACTION_DOWN -> {
    // interceptor = false
    // }
    // MotionEvent.ACTION_MOVE -> {
    // var deltaX = x - mLastXIntercept
    // var deltaY = y - mLastYIntercept
    // interceptor = Math.abs(deltaX) > Math.abs(deltaY)
    // if (interceptor) {
    // mMoveX = ev.getRawX()
    // mLastMoveX = mMoveX
    // }
    // }
    // MotionEvent.ACTION_UP -> {
    // //拿到当前移动的 x 坐标
    // interceptor = false
    // println(“onInterceptTouchEvent—ACTION_UP”)
    //
    // }
    // }
    // mLastXIntercept = x
    // mLastYIntercept = y
    // return interceptor
    // }

/**

  • 外部解决 2. 根据第二点坐标 - 第一点坐标 如果差值大于 TouchSlop 就认为是在左右滑动
    */
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    interceptor = false
    when (ev.action) {
    MotionEvent.ACTION_DOWN -> {
    //拿到手指按下相当于屏幕的坐标
    mDownX = ev.getRawX()
    mLastMoveX = mDownX
    interceptor = false
    }
    MotionEvent.ACTION_MOVE -> {
    //拿到当前移动的 x 坐标
    mMoveX = ev.getRawX()
    //拿到差值
    val absDiff = Math.abs(mMoveX - mDownX)
    mLastMoveX = mMoveX
    //当手指拖动值大于 TouchSlop 值时,就认为是在滑动,拦截子控件的触摸事件
    if (absDiff > mTouchSlop)
    interceptor = true
    }
    }
    return interceptor
    }

/**

  • 父容器没有拦截事件,这里就会接收到用户的触摸事件
    */
    override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
    MotionEvent.ACTION_MOVE -> {
    println("onInterceptTouchEvent—onTouchEvent–ACTION_MOVE ")
    mLastMoveX = mMoveX
    //拿到当前滑动的相对于屏幕左上角的坐标
    mMoveX = event.getRawX()
    var scrolledX = (mLastMoveX - mMoveX).toInt()
    if (scrollX + scrolledX < mLeftBorder) {
    scrollTo(mLeftBorder, 0)
    return true
    } else if (scrollX + width + scrolledX > mRightBorder) {
    scrollTo(mRightBorder - width, 0)
    return true

}
scrollBy(scrolledX, 0)
mLastMoveX = mMoveX
}
MotionEvent.ACTION_UP -> {
//当手指抬起是,根据当前滚动值来判定应该回滚到哪个子控件的界面上
var targetIndex = (scrollX + width / 2) / width
var dx = targetIndex * width - scrollX
/** 第二步 调用 startScroll 方法弹性回滚并刷新页面*/
mScroller.startScroll(scrollX, 0, dx, 0)
invalidate()
}
}
return super.onTouchEvent(event)
}

override fun computeScroll() {
super.computeScroll()
/**

  • 第三步 重写 computeScroll 方法,并在其内部完成平滑滚动的逻辑
    */
    if (mScroller.computeScrollOffset()) {
    scrollTo(mScroller.currX, mScroller.currY)
    postInvalidate()
    }
    }
    }
    复制代码

上面代码很简单,通过 2 种方式处理了外部拦截法冲突,分别是:

  • 根据垂直或水平的距离来判断
  • 根据第二点坐标 - 第一点坐标 如果差值大于 TouchSlop 就认为是在左右滑动

当然我们也可以用内部拦截法来解决,按照我们前面对内部拦截法的分析,我们只需要修改自定义 RecylerView 的 分发事件 dispatchTouchEvent 方法中的父容器的拦截逻辑,下面请看代码实现:

class MyRecyclerView(context: Context, attrs: AttributeSet?) : RecyclerView(context, attrs) {
/**

  • 分别记录我们上次滑动的坐标
    */
    private var mLastX = 0;
    private var mLastY = 0;

constructor(context: Context) : this(context, null)

/**

  • 重写分发事件
    */
    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    val x = ev.getX().toInt()
    val y = ev.getY().toInt()

when (ev.action) {
MotionEvent.ACTION_DOWN -> {
var par = parent as ScrollerViewPager
//请求父类不要拦截事件
par.requestDisallowInterceptTouchEvent(true)
Log.d(“dispatchTouchEvent”, “—》子ACTION_DOWN”);
}
MotionEvent.ACTION_MOVE -> {
val deltaX = x - mLastX
val deltaY = y - mLastY

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级安卓工程师,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Android移动开发全套学习资料》送给大家,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img
img

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
img

文末

当你打算跳槽的时候,应该把“跳槽成功后,我能学到什么东西?对我的未来发展有什么好处”放在第一位。这些东西才是真正引导你的关键。在跳槽之前尽量“物尽其用”,把手头上的工作做好,最好是完成了某个项目或是得到提升之后再走。跳槽不是目的,而是为了达到最终职业目标的手段

最后祝大家工作升职加薪,面试拿到心仪Offer.
为此我在文末整理了一些关于移动开发者需要的资料,欢迎大家免费领取
领取方式:点击我的GitHub


22994)]
[外链图片转存中…(img-wurvAkBn-1711323722994)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频
如果你觉得这些内容对你有帮助,可以添加下面V无偿领取!(备注Android)
[外链图片转存中…(img-8SG8noeP-1711323722994)]

文末

当你打算跳槽的时候,应该把“跳槽成功后,我能学到什么东西?对我的未来发展有什么好处”放在第一位。这些东西才是真正引导你的关键。在跳槽之前尽量“物尽其用”,把手头上的工作做好,最好是完成了某个项目或是得到提升之后再走。跳槽不是目的,而是为了达到最终职业目标的手段

最后祝大家工作升职加薪,面试拿到心仪Offer.
为此我在文末整理了一些关于移动开发者需要的资料,欢迎大家免费领取
领取方式:点击我的GitHub

[外链图片转存中…(img-juCQIIVJ-1711323722995)]
[外链图片转存中…(img-7V7Pk7LT-1711323722995)]

  • 11
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值