View 滚动与滑动冲突解决

View 滚动与滑动冲突解决

嵌套滚动场景的完整解决方案

目录

  1. 滑动冲突场景
  2. View 的滑动方式
  3. 外部拦截法
  4. 内部拦截法
  5. NestedScrolling 机制
  6. 实战:CoordinatorLayout 原理

1. 滑动冲突场景

典型冲突场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
┌─────────────────────────────────────────────────────────────────────┐
│ 常见滑动冲突场景 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 场景1: ViewPager + RecyclerView │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ViewPager (左右滑动) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ RecyclerView (上下滑动) │ │ │
│ │ │ ┌─────────────────────────────────────────────┐ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ │ │ └─────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ 冲突: 左右滑动和上下滑动如何区分 │
│ │
│ 场景2: ScrollView + RecyclerView │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ScrollView (上下滚动) │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ RecyclerView │ │ │
│ │ │ ┌─────────────────────────────────────────────┐ │ │ │
│ │ │ │ List Item 1 │ │ │ │
│ │ │ │ List Item 2 │ │ │ │
│ │ │ │ List Item 3 │ │ │ │
│ │ │ └─────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ 冲突: 到底该谁滚动? │
│ │
└─────────────────────────────────────────────────────────────────────┘

2. View 的滑动方式

方式对比

方式 原理 优缺点
scrollTo/scrollBy 移动 View 内容 不改变 View 位置,性能好
动画 (Translation) 改变 View 属性 GPU 加速,性能好
LayoutParams 改变 LayoutParams 性能差,需要 requestLayout
requestDisallowIntercept 父容器不拦截 解决滑动冲突

scrollTo / scrollBy 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// scrollBy: 相对滚动
view.scrollBy(100, 0) // 向右滚动 100px

// scrollTo: 绝对滚动
view.scrollTo(100, 0) // 滚动到 (100, 0)

// scroll 移动的是 View 的内容,不是 View 本身
// 正值表示内容向左/上移动(看起来 View 像是向右/下移动)

// 自定义可滚动 View
class ScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {

private var lastY = 0f
private var scrollY = 0

override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastY = event.y
}
MotionEvent.ACTION_MOVE -> {
val dy = (lastY - event.y).toInt()
// 相对滚动
scrollBy(0, dy)
lastY = event.y
}
}
return true
}

override fun scrollBy(x: Int, y: Int) {
scrollTo(scrollX + x, scrollY + y)
}

override fun scrollTo(x: Int, y: Int) {
// 边界检查
val maxY = computeVerticalScrollRange() - height
val newY = y.coerceIn(0, maxY)

if (newY != scrollY) {
scrollY = newY
// 移动内容
scrollTo(scrollX, scrollY)
}
}
}

动画方式滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用 Scroller
val scroller = Scroller(context)

fun smoothScrollTo(destX: Int, destY: Int) {
val deltaX = destX - scrollX
val deltaY = destY - scrollY
scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 500)
invalidate()
}

override fun computeScroll() {
if (scroller.computeScrollOffset()) {
scrollTo(scroller.currX, scroller.currY)
postInvalidate()
}
}

// 使用属性动画
ObjectAnimator.ofInt(scrollableView, "scrollY", targetY).apply {
duration = 300
start()
}

3. 外部拦截法

原理

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────────────────┐
│ 外部拦截法 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 父容器根据滑动方向决定是否拦截事件 │
│ │
│ DOWN ──▶ 记录初始位置,不拦截 │
│ MOVE ──▶ 判断滑动方向 ──▶ 拦截/不拦截 │
│ UP ──▶ 不拦截 │
│ │
│ 关键: onInterceptTouchEvent │
│ │
└─────────────────────────────────────────────────────────────────────┘

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class HorizontalViewPager : ViewGroup {

private var lastX = 0f
private var lastY = 0f
private var isIntercept = false

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.x
lastY = ev.y
isIntercept = false // DOWN 不拦截
}

MotionEvent.ACTION_MOVE -> {
val dx = abs(ev.x - lastX)
val dy = abs(ev.y - lastY)

// 水平滑动超过垂直滑动,拦截
if (dx > dy && dx > ViewConfiguration.get(context).scaledTouchSlop) {
isIntercept = true
lastX = ev.x
}
}
}

return isIntercept
}

override fun onTouchEvent(event: MotionEvent): Boolean {
// 处理自己的滑动逻辑
return true
}
}

4. 内部拦截法

原理

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────────────────────────────┐
│ 内部拦截法 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 父容器不拦截所有事件,交给子 View 处理 │
│ 子 View 根据滑动方向决定是否让父容器拦截 │
│ │
│ 子 View: dispatchTouchEvent │
│ DOWN ──▶ requestDisallowIntercept(false) │
│ MOVE ──▶ 滑动到边界 ──▶ requestDisallowIntercept(true) │
│ UP ──▶ requestDisallowIntercept(false) │
│ │
└─────────────────────────────────────────────────────────────────────┘

父容器实现

1
2
3
4
5
6
7
class ParentViewGroup : ViewGroup {

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
// 外部拦截法:不拦截 DOWN,其他事件由子 View 决定
return ev.action != MotionEvent.ACTION_DOWN
}
}

子 View 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ChildRecyclerView : RecyclerView {

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
// 不允许父容器拦截
parent.requestDisallowInterceptTouchEvent(true)
}

MotionEvent.ACTION_MOVE -> {
// 检测是否滚动到边界
if (!canScrollVertically(1) || !canScrollVertically(-1)) {
// 滚动到边界,让父容器处理
parent.requestDisallowInterceptTouchEvent(false)
}
}

MotionEvent.ACTION_UP -> {
parent.requestDisallowInterceptTouchEvent(false)
}
}

return super.dispatchTouchEvent(ev)
}
}

5. NestedScrolling 机制

核心接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 父容器实现 NestedScrollingParent
class NestedParentView : ViewGroup, NestedScrollingParent {

override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
// 是否接收嵌套滚动
return true
}

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
// 在子 View 滚动前处理
// consumed[0] = dx 表示已消费 x 方向滚动
// consumed[1] = dy 表示已消费 y 方向滚动
}

override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int) {
// 子 View 滚动后,处理剩余滚动
}

override fun onStopNestedScroll(target: View) {
// 滚动结束
}
}

// 子 View 实现 NestedScrollingChild
class NestedChildView : View, NestedScrollingChild {

private val helper = NestedScrollingChildHelper(this)

override fun onTouchEvent(e: MotionEvent): Boolean {
when (e.action) {
MotionEvent.ACTION_DOWN -> {
// 启动嵌套滚动
helper.startNestedScroll(View.SCROLL_AXIS_VERTICAL)
}

MotionEvent.ACTION_MOVE -> {
// 分发嵌套滚动
helper.dispatchNestedScroll(0, dyConsumed, 0, dyUnconsumed, null)
}

MotionEvent.ACTION_UP -> {
// 停止嵌套滚动
helper.stopNestedScroll()
}
}
return super.onTouchEvent(e)
}
}

RecyclerView 嵌套滚动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// RecyclerView 已实现 NestedScrollingChild
// ScrollView 已实现 NestedScrollingParent
// 直接组合使用即可

// 外部 ScrollView
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="wrap_content" />

</ScrollView>

// 解决: 设置 RecyclerView 不处理嵌套滚动
recyclerView.layoutManager = LinearLayoutManager() {
// 或者使用 nested scrolling
}

6. 实战:CoordinatorLayout 原理

Behavior 机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 自定义 Behavior
class CustomBehavior : CoordinatorLayout.Behavior<View> {

override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
// 判断 child 是否依赖 dependency
return dependency.id == R.id.dependency
}

override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
// 当 dependency 变化时,更新 child
child.y = dependency.y + dependency.height
return true
}

override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
// 自定义布局
parent.onLayoutChild(child, layoutDirection)
// ...
return true
}
}

// 使用 Behavior
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<View
android:id="@+id/dependency"
android:layout_width="match_parent"
android:layout_height="100dp" />

<View
android:layout_width="match_parent"
android:layout_height="100dp"
app:layout_behavior=".CustomBehavior" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

滑动冲突解决总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────────────────────────┐
│ 滑动冲突解决策略 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 外部拦截法 (推荐): │
│ - 父容器 onInterceptTouchEvent 判断方向 │
│ - 简单直观,符合事件分发机制 │
│ │
│ 内部拦截法: │
│ - 子 View 控制父容器是否拦截 │
│ - 适合复杂场景 │
│ │
│ NestedScrolling (推荐): │
│ - 官方推荐的嵌套滚动方案 │
│ - RecyclerView + CoordinatorLayout 已实现 │
│ - 自动处理滑动冲突 │
│ │
└─────────────────────────────────────────────────────────────────────┘

面试常问

问题 答案
scrollBy 和 scrollTo 区别? scrollBy 相对滚动,scrollTo 绝对滚动
滑动冲突如何解决? 外部拦截法/内部拦截法/NestedScrolling
CoordinatorLayout 原理? Behavior 机制,监听依赖 View 变化

相关文章