Android 触摸事件分发与滑动冲突处理
Android 触摸事件分发与滑动冲突处理
轮播图嵌套列表、横向卡片放在纵向页面、可拖拽面板覆盖按钮时,都可能出现滑动冲突。很多问题源于对事件序列理解不完整:一次手势不是某个单独的移动事件,而是从按下开始、以抬起或取消结束的一组连续事件。
一、理解完整事件序列
典型手势包含:
ACTION_DOWN:手指首次接触屏幕。ACTION_MOVE:手指位置变化,可能出现多次。ACTION_UP:手指正常离开屏幕。ACTION_CANCEL:当前目标失去后续事件处理权。
如果父容器在移动阶段拦截,原本处理手势的子视图会收到取消事件。子视图必须在取消时结束按压、拖拽和速度跟踪等临时状态。
二、三个核心分发方法
ViewGroup 中通常关注三个方法:
dispatchTouchEvent:负责把事件向自身或子视图分发。onInterceptTouchEvent:父容器判断是否拦截事件。onTouchEvent:当前视图处理事件并决定是否消费。
普通 View 没有拦截阶段。事件是否继续传递,取决于当前序列中各层返回值和父容器的拦截决定。
最重要的规则是:想持续接收一个手势的后续事件,目标通常需要从按下阶段开始消费。只在移动时突然返回 true,往往无法得到预期序列。
三、用触摸阈值区分点击和滑动
手指不可能完全静止,系统提供的触摸阈值可以过滤轻微抖动。
class DirectionLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
private var downY = 0f
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
return false
}
MotionEvent.ACTION_MOVE -> {
val distanceX = kotlin.math.abs(event.x - downX)
val distanceY = kotlin.math.abs(event.y - downY)
return distanceY > touchSlop && distanceY > distanceX
}
}
return false
}
}
示例表示父容器只接管明显的纵向滑动。实际实现还要处理取消、多指和自身能否继续滚动等条件。
四、外部拦截法
外部拦截法由父容器根据方向和边界决定是否拦截。适合父容器清楚知道整个手势规则的场景。
例如,纵向父列表中嵌套横向列表:横向位移大于纵向位移时不拦截,明显纵向滑动时由父列表处理。方向一旦确定,最好在当前手势中保持稳定,避免斜向滑动时处理权来回切换。
父容器拦截后,子视图会收到取消事件,因此子视图不能假设每个按下最终都有抬起。
五、内部拦截法
内部拦截法由子视图请求父容器暂时不要拦截:
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
downX = event.x
downY = event.y
}
MotionEvent.ACTION_MOVE -> {
val distanceX = kotlin.math.abs(event.x - downX)
val distanceY = kotlin.math.abs(event.y - downY)
if (distanceY > distanceX) {
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
parent.requestDisallowInterceptTouchEvent(false)
}
}
return super.dispatchTouchEvent(event)
}
这种方式适合子视图更了解自身滚动边界的情况。例如子列表还能向上滚动时自己消费,到达顶部后再把继续下拉交还父容器。
六、优先使用嵌套滚动体系
父子视图都属于滚动容器时,项目已有的嵌套滚动组件通常比手写方向判断更可靠。它能在一次滚动中分配预滚动、剩余距离和惯性速度,适合折叠头部、联动列表等场景。
手写事件分发更适合明确、局部的特殊交互。不要在已有嵌套滚动能力之上再叠加一套拦截逻辑,否则很容易产生重复消费。
七、处理多指与坐标
event.x 和 event.y 是相对当前视图的坐标,原始坐标则相对屏幕。跨视图比较时必须保证坐标系一致。
多指场景不能固定使用第一个索引,因为某根手指抬起后索引会变化。需要保存活动指针标识,并在指针变化时更新。缩放、旋转等复杂手势应优先复用项目已有手势检测组件。
八、兼顾点击与可访问性
自定义视图在 onTouchEvent 中识别点击后,应调用 performClick,让无障碍服务和自动化测试能够感知点击语义。
override fun onTouchEvent(event: MotionEvent): Boolean {
return when (event.actionMasked) {
MotionEvent.ACTION_UP -> {
performClick()
true
}
MotionEvent.ACTION_CANCEL -> {
resetGesture()
true
}
else -> true
}
}
override fun performClick(): Boolean {
super.performClick()
return true
}
实际点击判断还要结合移动距离,不能把一次滑动结束误判为点击。
九、验证边界场景
滑动冲突应在真机上覆盖:
- 很短距离移动和快速甩动。
- 横向、纵向及接近对角线的手势。
- 子列表位于开头、中间和末尾的位置。
- 双指操作和中途增加手指。
- 父容器开始拦截后的取消处理。
- 点击、长按与滑动之间的切换。
总结
滑动冲突的解决关键是为一次完整手势确定稳定处理者。根据父子视图谁更了解规则选择外部或内部拦截,并用系统触摸阈值判断方向。滚动容器联动优先复用嵌套滚动体系,自定义处理则必须补齐取消、多指和点击语义。
- 点赞
- 收藏
- 关注作者
评论(0)