关于直播页面滑动的思考:
直播页面是一个很复杂的页面,牵扯到视频,直播间内容,动画,活动,推荐等大量内容的展示.所以一般情况下会给直播页面做层架划分运用了多层级和可滑动的页面展示数据.
因为用到了多层级,以及可缓存以及滑动的控件就导致了一系列的滑动问题,以及缓存问题.
注意:
RecycleView 和ViewPager2 滑动到最后之后会将滑动事件 回传给父布局.
1:效果图实现梳理
层级划分:
- 直播引擎页面(底层)
- 直播间数据页面(侧滑一级页面)
- 更多页面(侧滑二级页面)
2:实现思路
自定义一个 TouchView 的ViewGroup 添加一个ViewPager2或者RecycleView (做列表播放视频,支持上下左右滑动)子类.
- RecycleView或者ViewPager2 支持上下滑动
- TouchView重写
onInterceptTouchEvent和onTouchEvent方法处理侧滑事件
注意:
当页面重叠展示的的时候滑动需要做分发,上层页面是否拦截是否分发.
核心:
- onInterceptTouchEvent() 是否拦截 返回true表示拦截 将事件分发给 onTouchEvent
- onTouchEvent 滑动事件处理
3:核心代码实现
/**
* 自定义触摸事件处理View,用于实现视频播放时的滑动手势识别
* 支持上下左右滑动,并根据滑动方向执行相应操作
*/
class TouchView : ConstraintLayout, VideoTouchListener {
// 滑动阈值配置(可根据实际体验调整)
companion object {
private const val MIN_SWIPE_DISTANCE = 80f // 最小滑动距离(像素)
private const val TIME_LIMIT = 100L // 事件锁定时间(毫秒),防止重复触发
}
private val binding: LayoutVideoTouchBinding
private var curPosition = 0 // 当前ViewPager2选中页
private var isEventLocked = false // 事件锁定标志,防止重复触发
private var startX = 0f // 触摸起点X坐标
private var startY = 0f // 触摸起点Y坐标
private var startTime = 0L // 触摸开始时间
private val ignoreViews: MutableList<View> = ArrayList() // 不拦截触摸事件的View列表
var videoFragmentAdapter: VideoFragmentAdapter? = null
private var shouldIgnore = false // 是否忽略当前触摸事件
// 使用弱引用Handler避免内存泄漏
private val handler = object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what == 1) {
isEventLocked = false
}
}
}
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context, attrs, defStyleAttr
) {
binding = LayoutVideoTouchBinding.inflate(LayoutInflater.from(context), this, true)
initView()
}
/**
* 初始化ViewPager2和相关配置
*/
private fun initView() {
videoFragmentAdapter = VideoFragmentAdapter(context as FragmentActivity)
binding.vpContent.adapter = videoFragmentAdapter
binding.vpContent.offscreenPageLimit = 2
binding.vpContent.isUserInputEnabled = false // 禁用原生滑动,使用自定义触摸处理
// 监听页面变化,更新当前页码
binding.vpContent.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
curPosition = position
}
})
// 隐藏ViewPager2的滚动条
ViewPager2Helper.setVp2NoBar(binding.vpContent)
// 添加默认的4个Fragment(实际项目中可能会动态添加)
val fragments = (0..3).map { LiveFragment.newInstance(it) }
addFragment(fragments)
}
// ---------------------- 对外提供的Fragment管理方法 ----------------------
fun addFragment(fragments: List<Fragment>) {
videoFragmentAdapter?.addFragments(fragments)
}
fun addFragment(fragment: Fragment) {
videoFragmentAdapter?.addFragment(fragment)
}
fun getFragmentListSize(): Int {
return videoFragmentAdapter?.getFragmentListSize() ?: 0
}
// ---------------------- 触摸事件拦截处理 ----------------------
/**
* 优化的事件拦截逻辑
* - DOWN事件总是返回false,允许子View处理点击
* - MOVE事件根据滑动距离和是否在忽略区域决定是否拦截
*/
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 记录起点坐标,不拦截DOWN事件,允许子View处理点击
startX = event.x
startY = event.y
isEventLocked = false // 重置锁定状态
shouldIgnore = ignoreViews.any { isTouchInView(it, event.x, event.y) }
return false
}
MotionEvent.ACTION_MOVE -> {
// 计算滑动距离,超过阈值则拦截事件(由父View处理滑动)
if (shouldIgnore && isTouchInView(findViewById<LiveMoreView>(R.id.v_more), event.x, event.y)) return true
val deltaX = event.x - startX
val deltaY = event.y - startY
val distance = hypot(deltaX, deltaY)
// 如果不在忽略区域且滑动距离足够,拦截事件进行滑动处理
return !shouldIgnore && distance > MIN_SWIPE_DISTANCE
}
else -> return super.onInterceptTouchEvent(event)
}
}
// ---------------------- 核心触摸事件处理 ----------------------
/**
* 处理触摸事件,识别滑动方向并执行相应操作
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (isEventLocked) return true // 事件锁定时不处理新触摸
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = event.x
startY = event.y
startTime = System.currentTimeMillis()
return true // 消费DOWN事件,确保后续事件能传递到这里
}
MotionEvent.ACTION_UP -> {
val deltaX = event.x - startX
val deltaY = event.y - startY
val distance = hypot(deltaX, deltaY)
// 判断是否为有效滑动(距离超过阈值)
if (distance > MIN_SWIPE_DISTANCE) {
handleSwipe(deltaX, deltaY)
lockEvent() // 锁定事件,防止重复触发
return true // 消费滑动事件
}
// 非滑动事件(点击),不消费,让子View处理
}
}
return super.onTouchEvent(event)
}
/**
* 根据滑动距离和方向执行相应操作
*/
private fun handleSwipe(deltaX: Float, deltaY: Float) {
when {
abs(deltaX) > abs(deltaY) -> {
// 水平滑动
if (deltaX > 0) onSwipeRight() else onSwipeLeft()
}
else -> {
// 垂直滑动
if (deltaY > 0) onSwipeDown() else onSwipeUp()
}
}
}
/**
* 锁定事件处理,防止短时间内重复触发
*/
private fun lockEvent() {
if (isEventLocked) return
isEventLocked = true
handler.sendEmptyMessageDelayed(1, TIME_LIMIT) // 解锁事件处理
}
// ---------------------- 滑动回调实现 ----------------------
override fun onSwipeRight() {
LogUtils.e("右滑")
EventBusFlow.send(BaseEvent("swipe_right", curPosition))
}
override fun onSwipeLeft() {
LogUtils.e("左滑")
EventBusFlow.send(BaseEvent("swipe_left", curPosition))
}
override fun onSwipeUp() {
LogUtils.e("上滑")
// 如果不是最后一页,则切换到下一页
if (curPosition < binding.vpContent.adapter!!.itemCount - 1) {
binding.vpContent.setCurrentItem(curPosition + 1, true)
}
}
override fun onSwipeDown() {
LogUtils.e("下滑")
// 如果不是第一页,则切换到上一页
if (curPosition > 0) {
binding.vpContent.setCurrentItem(curPosition - 1, true)
}
}
// ---------------------- 辅助方法 ----------------------
/**
* 判断触摸点是否在指定View区域内
*/
private fun isTouchInView(view: View?, x: Float, y: Float): Boolean {
if (view == null || view.visibility != View.VISIBLE) return false
val location = IntArray(2)
view.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + view.width
val bottom = top + view.height
val rawX = IntArray(2).apply { getLocationOnScreen(this) }
val offsetX = rawX[0]
val offsetY = rawX[1]
val screenX = x.toInt() + offsetX
val screenY = y.toInt() + offsetY
return screenX in left..right && screenY in top..bottom
}
// ---------------------- 公共方法 ----------------------
/**
* 添加不拦截触摸事件的View
* 这些View区域的触摸事件会被传递给子View处理
*/
fun addIgnoreView(view: View) {
ignoreViews.add(view)
}
fun addIgnoreViews(vararg views: View) {
ignoreViews.addAll(views)
}
fun clearIgnoreViews() {
ignoreViews.clear()
}
}
核心代码:
-
addIgnoreView() 添加忽略拦截的View
将需要忽略拦截的View 添加到容器 拦截触发的时候做判断
-
isTouchInView() 根据触摸位置判断是否在View的区域内
注意,叠加的时候触摸区域判断会出现通知在两个空间内 -
isTouchInView(findViewById(R.id.v_more), event.x, event.y)
判断第三层级是否展示 当第三层级展示的时候 需要拦截下层的触摸事件隐藏第三层级
总结
复杂页面的事件分发看起来麻烦实际也就那么回事儿.开发中尽量减少层级嵌套就好了.当必修用到多层架嵌套滑动的时候,研究以下事件分发流程,以及根据自己的需求做事件分发就行了
难点总结:
- 事件拦截(父布局给子布局做分发)
- 两个View重叠时触摸分发给谁
- 可滑动容器重叠分发
- 点击事件穿透(当不是滑动事件时 不拦截,分发给子布局)
MotionEvent.ACTION_MOVE -> {
// 计算滑动距离,超过阈值则拦截事件(父View处理滑动)
if (shouldIgnore&&isTouchInView(findViewById<LiveMoreView>(R.id.v_more), event.x, event.y)) return true
val deltaX = event.x - startX
val deltaY = event.y - startY
val distance = kotlin.math.hypot(deltaX, deltaY)
if (shouldIgnore)return false
// 当滑动距离小于最小限制认为时点击 不拦截 分发给子布局
return distance > MIN_SWIPE_DISTANCE
}