自定义View方案

一些知识

Android Touch事件传递机制(一) – onInterceptTouchEvent & onTouchEvent

onInterceptTouchEvent的ACTION_MOVE事件不执行_webview 不执行 ontouchevent 事件不执行onintercepttoucheven-CSDN博客

场景1

描述

经典的底部导航,横向ViewPager2,里面的Fragment是一个竖向的线性布局,这个布局里面是一个顶部视图,底部视图为TabLayout+ViewPager2,然后ViewPager2里面的Fragment都是RecyclerView,滑动底部会将顶部布局顶到顶部才滑动自己

下面是网上一些案例和解决方案

AndyJennifer/NestedScrollingDemo: 😋😋😋A good app for understanding android nested scrolling

仿网易云音乐日推界面(监听AppBarLayout滑动+动态高斯模糊)_appbarlayout监听滑动-CSDN博客

解决方案

方案1(传统事件分发机制)

代码示例

这个是那个有头部和底部视图的Fragment的布局

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
50
51
52
53
54
55
56
57
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".test1.TestFragment1">

<com.example.androidoffertest.view.TraditionalHorizontalNestedLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<!--headview-->
<LinearLayout
android:id="@+id/headview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:title="传统解决方式" />

<ImageView
android:layout_width="match_parent"
android:layout_height="180dp"
android:src="@color/design_default_color_secondary_variant" />

</LinearLayout>

<!--底部视图-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical">

<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp2"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</LinearLayout>

</com.example.androidoffertest.view.TraditionalHorizontalNestedLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

下面是布局中的自定义布局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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
package com.example.androidoffertest.view

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import kotlin.math.abs

/**
* @author: wuleizhenshang
* @date: 2025/2/4 21:22
* @description: 传统解决方案
*/
class TraditionalNestedParentLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
/**
* 头部View
*/
private var mHeadView: View? = null
/**
* 底部View
*/
private var mBottomView: ViewGroup? = null
/**
* 底部View的第二个子View为ViewPager2
*/
private var mVp2: ViewPager2? = null
/**
* 头部View的高度
*/
private var mHeadTopHeight = 0
/**
* 记录上次触摸的坐标
*/
private var mLastTouchX = 0f
private var mLastTouchY = 0f
/**
* 头部是否到顶
*/
private var mIsHeadTop = false

/**
* 重写scrollTo方法,scrollBy方法也会调用scrollTo方法
*/
override fun scrollTo(x: Int, y: Int) {
var newY = y
//修正y值,限制滚动范围
if (y < 0) {
newY = 0
}
if (y > mHeadTopHeight) {
newY = mHeadTopHeight
}
super.scrollTo(x, newY)
//scrollY为已经滑动的距离
mIsHeadTop = scrollY == mHeadTopHeight
}

/**
* ViewGroup拦截触摸事件
*/
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_DOWN -> {
mLastTouchX = ev.x
mLastTouchY = ev.y
}

MotionEvent.ACTION_MOVE -> {
//是上下滑动
if (abs(ev.x - mLastTouchX) < abs(ev.y - mLastTouchY)) {
//向上滑动并且头部没有到顶就拦截
if (ev.y < mLastTouchY && !mIsHeadTop) {
return true
}
//向下滑动并且头部到顶就拦截
else if (ev.y > mLastTouchY && mIsHeadTop) {
return true
}
}
}
}
return super.onInterceptTouchEvent(ev)
}

/**
* ViewGroup分发触摸事件
*/
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (mBottomView == null || ev == null) {
return super.dispatchTouchEvent(ev)
}
val location = IntArray(2)
mBottomView?.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + (mBottomView?.width ?: 0)
val bottom = top + (mBottomView?.height ?: 0)
//rawX和rawY是相对于屏幕的坐标
if (ev.rawX < left || ev.rawX > right || ev.rawY < top || ev.rawY > bottom) {
return super.dispatchTouchEvent(ev)
}

when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mLastTouchX = ev.x
mLastTouchY = ev.y
}

MotionEvent.ACTION_MOVE -> {
val dx = ev.x - mLastTouchX
val dy = ev.y - mLastTouchY
//是水平滑动
if (abs(dx) > abs(dy)) {
//内部视图含有ViewPager2并且没有到第一个或者最后一个时,请求父View不要拦截
mVp2?.let { viewPager2 ->
val currentItem = viewPager2.currentItem
val rec = viewPager2.getChildAt(0) as RecyclerView?
val itemCount = rec?.adapter?.itemCount ?: 0
//判断是否第一个且左滑,或者最后一个且右滑,是请求父View拦截,否则不拦截
when {
currentItem == 0 && dx > 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

currentItem == itemCount - 1 && dx < 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

else -> parent.requestDisallowInterceptTouchEvent(true)
}
}
}
}
}
return super.dispatchTouchEvent(ev)
}

/**
* ViewGroup处理触摸事件
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
mLastTouchX = event.x
mLastTouchY = event.y
//重要!确保 ACTION_DOWN 被消费,否则后续事件不会被传递
return true
}

MotionEvent.ACTION_MOVE -> {
//是竖直滑动
if (abs(event.x - mLastTouchX) < abs(event.y - mLastTouchY)) {
scrollBy(0, (mLastTouchY - event.y).toInt())
return true
}
}
}
return super.onTouchEvent(event)
}

/**
* 测量
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//避免了使用weight属性导致的测量问题
mBottomView?.layoutParams?.apply {
height = measuredHeight
(this as? LayoutParams)?.weight = 0f
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

/**
* 当 View 的 尺寸发生变化 时调用,例如:
* 首次测量 后(measure() 计算完大小)。
* 窗口大小变化(例如旋转屏幕、调整布局)。
* 父布局重新分配空间(如 View 变更 layoutParams)。
* 回调时机:
* 可能会被调用多次(窗口变化、重测量时都会触发)。
* 发生在 onMeasure() 之后,但 onLayout() 之前。
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//获取headView高度
mHeadTopHeight = mHeadView?.measuredHeight ?: 0
}

/**
* 当XML布局加载完成并且所有子View都已实例化时调用。
* 只适用于从XML加载的View,手动创建的View(new View(context))不会触发。
* 回调时机:
* 发生在XML解析完毕后,但还未测量、布局和绘制。
* 只会调用一次。
*/
override fun onFinishInflate() {
super.onFinishInflate()
//这里头部为第一个View,底部为第二个View,ViewPager2为底部View的第二个子View
mHeadView = getChildAt(0)
mBottomView = getChildAt(1) as ViewGroup?
mVp2 = mBottomView?.getChildAt(1) as ViewPager2?
requireNotNull(mHeadView) { "The first child view is null" }
requireNotNull(mBottomView) { "The second child view is null" }
requireNotNull(mVp2) { "The second child view of the bottom view must be ViewPager2" }
}
}
缺陷

传统分发机制的解决方案会有中断,这是因为一次时间被当前View拦截后是不会再给外部拿到了,需要手指拿起才能重新触发

方案2(嵌套滚动机制)

代码示例
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
50
51
52
53
54
55
56
57
58
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".test1.TestFragment1">

<com.example.androidoffertest.view.NestedParentLinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<!--headview-->
<LinearLayout
android:id="@+id/headview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:title="嵌套滚动解决方式" />

<ImageView
android:layout_width="match_parent"
android:layout_height="180dp"
android:src="@color/design_default_color_secondary_variant" />

</LinearLayout>

<!--底部视图-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="vertical"
android:layout_weight="1">

<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp2"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>

</LinearLayout>

</com.example.androidoffertest.view.NestedParentLinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
package com.example.androidoffertest.view

import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.LinearLayout
import androidx.core.view.NestedScrollingParent3
import androidx.core.view.NestedScrollingParentHelper
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import kotlin.math.abs

/**
* @author: wuleizhenshang
* @date: 2025/2/5 11:32
* @description: 嵌套滚动解决方案
*/
class NestedParentLinearLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr), NestedScrollingParent3 {

/**
* 头部View
*/
private var mHeadView: View? = null

/**
* 底部View
*/
private var mBottomView: ViewGroup? = null

/**
* 底部View的第二个子View为ViewPager2
*/
private var mVp2: ViewPager2? = null

/**
* 头部View的高度
*/
private var mHeadTopHeight = 0

/**
* 记录上次触摸的坐标
*/
private var mLastTouchX = 0f
private var mLastTouchY = 0f

/**
* NestedScrollingParentHelper
*/
private val mNestedScrollingParentHelper = NestedScrollingParentHelper(this)

/**
* 动画
*/
private var mValueAnimator: ValueAnimator? = null

/**
* 重写scrollTo方法,scrollBy方法也会调用scrollTo方法
*/
override fun scrollTo(x: Int, y: Int) {
var newY = y
//修正y值,限制滚动范围
if (y < 0) {
newY = 0
}
if (y > mHeadTopHeight) {
newY = mHeadTopHeight
}
super.scrollTo(x, newY)
}

//-----view测量绘制等-----------------------------------------------------------------------------//
/**
* 测量
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//避免了使用weight属性导致的测量问题
mBottomView?.layoutParams?.apply {
height = measuredHeight
(this as? LayoutParams)?.weight = 0f
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

/**
* 当 View 的 尺寸发生变化 时调用,例如:
* 首次测量 后(measure() 计算完大小)。
* 窗口大小变化(例如旋转屏幕、调整布局)。
* 父布局重新分配空间(如 View 变更 layoutParams)。
* 回调时机:
* 可能会被调用多次(窗口变化、重测量时都会触发)。
* 发生在 onMeasure() 之后,但 onLayout() 之前。
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//获取headView高度
mHeadTopHeight = mHeadView?.measuredHeight ?: 0
}

/**
* 当XML布局加载完成并且所有子View都已实例化时调用。
* 只适用于从XML加载的View,手动创建的View(new View(context))不会触发。
* 回调时机:
* 发生在XML解析完毕后,但还未测量、布局和绘制。
* 只会调用一次。
*/
override fun onFinishInflate() {
super.onFinishInflate()
//这里头部为第一个View,底部为第二个View,ViewPager2为底部View的第二个子View
mHeadView = getChildAt(0)
mBottomView = getChildAt(1) as ViewGroup?
mVp2 = mBottomView?.getChildAt(1) as ViewPager2?
requireNotNull(mHeadView) { "The first child view is null" }
requireNotNull(mBottomView) { "The second child view is null" }
requireNotNull(mVp2) { "The second child view of the bottom view must be ViewPager2" }
}

//-----传统事件分发--------------------------------------------------------------------------------//
/**
* ViewGroup分发触摸事件
* 如果触摸部分在底部View上,并且水平可以滑动,那么请求父View不要拦截
*/
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (mBottomView == null || ev == null) {
return super.dispatchTouchEvent(ev)
}
val location = IntArray(2)
mBottomView?.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + (mBottomView?.width ?: 0)
val bottom = top + (mBottomView?.height ?: 0)
//rawX和rawY是相对于屏幕的坐标
if (ev.rawX < left || ev.rawX > right || ev.rawY < top || ev.rawY > bottom) {
return super.dispatchTouchEvent(ev)
}

when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mLastTouchX = ev.x
mLastTouchY = ev.y
}

MotionEvent.ACTION_MOVE -> {
val dx = ev.x - mLastTouchX
val dy = ev.y - mLastTouchY
//是水平滑动
if (abs(dx) > abs(dy)) {
//内部视图含有ViewPager2并且没有到第一个或者最后一个时,请求父View不要拦截
mVp2?.let { viewPager2 ->
val currentItem = viewPager2.currentItem
val rec = viewPager2.getChildAt(0) as RecyclerView?
val itemCount = rec?.adapter?.itemCount ?: 0
//判断是否第一个且左滑,或者最后一个且右滑,是请求父View拦截,否则不拦截
when {
currentItem == 0 && dx > 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

currentItem == itemCount - 1 && dx < 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

else -> parent.requestDisallowInterceptTouchEvent(true)
}
}
}
}
}
return super.dispatchTouchEvent(ev)
}

//-----嵌套滚动-----------------------------------------------------------------------------------//

/**
* 有嵌套滑动到来了,判断父控件是否接受嵌套滑动
*
* @param child 嵌套滑动对应的父类的子类(因为嵌套滑动对于的父控件不一定是一级就能找到的,可能挑了两级父控件的父控件,child的辈分>=target)
* @param target 具体嵌套滑动的那个子类
* @param axes 支持嵌套滚动轴。水平方向,垂直方向,或者不指定
* @param type 嵌套滑动类型,ViewCompat.TYPE_TOUCH(拖动),ViewCompat.TYPE_NON_TOUCH(惯性滑动)
* @return 父控件是否接受嵌套滑动, 只有接受了才会执行剩下的嵌套滑动方法
*/
override fun onStartNestedScroll(
child: View,
target: View,
axes: Int,
type: Int
): Boolean {
return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}

/**
* 当onStartNestedScroll返回为true时,也就是父控件接受嵌套滑动时,该方法才会调用
*/
override fun onNestedScrollAccepted(
child: View,
target: View,
axes: Int,
type: Int
) {
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
}

/**
* 在嵌套滑动的子控件未滑动之前,判断父控件是否优先与子控件处理(也就是父控件可以先消耗,然后给子控件消耗)
*
* @param target 具体嵌套滑动的那个子类
* @param dx 水平方向嵌套滑动的子控件想要变化的距离 dx<0 向右滑动 dx>0 向左滑动
* @param dy 垂直方向嵌套滑动的子控件想要变化的距离 dy<0 向下滑动 dy>0 向上滑动
* @param consumed 这个参数要我们在实现这个函数的时候指定,回头告诉子控件当前父控件消耗的距离
* consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离 好让子控件做出相应的调整
* @param type 嵌套滑动类型,ViewCompat.TYPE_TOUCH(拖动),ViewCompat.TYPE_NON_TOUCH(惯性滑动)
*/
override fun onNestedPreScroll(
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
//这里必须子控件能嵌套滚动才能触发到这里,否则不会触发,头部的view不会触发到这里
//dy>0表示手指向上滑动,scrollY < mHeadTopHeight表示头部View没有完全隐藏
val isCanMoveTop = dy > 0 && scrollY < mHeadTopHeight
//dy<0表示手指向下滑动,scrollY > 0表示头部View没有完全显示,并且target不能向下滚动
val isCanMoveBottom = dy < 0 && scrollY >= 0 && !target.canScrollVertically(-1)
//改为下面这行就是类似b站的搜索栏了,下滑搜索栏会出来,剩下距离不会拦截了
//val isCanMoveBottom = dy < 0 && scrollY > 0
if (isCanMoveTop || isCanMoveBottom) {
scrollBy(0, dy)
consumed[1] = dy
}
}

/**
* 嵌套滑动的子控件在滑动之后,判断父控件是否继续处理(也就是父消耗一定距离后,子再消耗,最后判断父消耗不)
*
* @param target 具体嵌套滑动的那个子类
* @param dxConsumed 水平方向嵌套滑动的子控件滑动的距离(消耗的距离)
* @param dyConsumed 垂直方向嵌套滑动的子控件滑动的距离(消耗的距离)
* @param dxUnconsumed 水平方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
* @param dyUnconsumed 垂直方向嵌套滑动的子控件未滑动的距离(未消耗的距离)
*/
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int
) {

}

override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {

}

/**
* 嵌套滑动结束
*/
override fun onStopNestedScroll(target: View, type: Int) {
mNestedScrollingParentHelper.onStopNestedScroll(target, type)
}

/**
* 当子控件产生fling滑动时,判断父控件是否处拦截fling,如果父控件处理了fling,那子控件就没有办法处理fling了。
*
* @param target 具体嵌套滑动的那个子类
* @param velocityX 水平方向上的速度 velocityX > 0 向左滑动,反之向右滑动
* @param velocityY 竖直方向上的速度 velocityY > 0 向上滑动,反之向下滑动
* @return 父控件是否拦截该fling
*/
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
return super.onNestedPreFling(target, velocityX, velocityY)
}

/**
* 当父控件不拦截该fling,那么子控件会将fling传入父控件,处理剩下的fling
*
* @param target 具体嵌套滑动的那个子类
* @param velocityX 水平方向上的速度 velocityX > 0 向左滑动,反之向右滑动
* @param velocityY 竖直方向上的速度 velocityY > 0 向上滑动,反之向下滑动
* @param consumed 子控件是否可以消耗该fling,也可以说是子控件是否消耗掉了该fling
* @return 父控件是否消耗了该fling
*/
override fun onNestedFling(
target: View,
velocityX: Float,
velocityY: Float,
consumed: Boolean
): Boolean {
//向上就交给子控件处理,向下才处理让顶部显示出来
//TODO 效果不太好,注释掉先吧,感觉网易云是整体是一个RecyclerView或者NestedScrollView,推荐界面也是RecyclerView处理掉Fling
// val distance = abs(scrollY)
// when {
// velocityY > 0 -> { // 向上滑动
// (3 * (1000 * (distance / velocityY.toFloat())).roundToInt())
// .also { startAnimation(it.toLong(), scrollY, mHeadTopHeight) }
// }
// velocityY < 0 -> { // 向下滑动
// val distanceRatio = distance.toFloat() / height
// ((distanceRatio + 1) * 150).toInt()
// .also { startAnimation(it.toLong(), scrollY, 0) }
//
// }
// else -> 0
// }
// return true
return super.onNestedFling(target, velocityX, velocityY, consumed)
}

/**
* 返回当前父控件嵌套滑动的方向,分为水平方向与,垂直方法,或者不变
*/
override fun getNestedScrollAxes(): Int {
return mNestedScrollingParentHelper.nestedScrollAxes
}

/**
* 启动处理Fling的动画
*/
private fun startAnimation(duration: Long, startY: Int, endY: Int) {
if (mValueAnimator?.isRunning == true) {
mValueAnimator?.cancel()
}

if (mValueAnimator == null) {
mValueAnimator = ValueAnimator().apply {
addUpdateListener { animation ->
scrollTo(0, animation.animatedValue as Int)
}
interpolator = DecelerateInterpolator()
}
}

mValueAnimator?.apply {
setIntValues(startY, endY)
this.duration = duration
start()
}
}
}
缺陷

顶部不能滑动,在CollapsingToolbarLayout+AppBarLayout中,有个问题就是上滑时横滑卡顿,应该是外部的ViewPager2导致的,这里的话不会有这个问题,但是顶部不能滑动,网易云的每日推荐更新后就类似这样

方案3(嵌套滚动机制,重写NestedScrollView)

代码示例
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
50
51
52
53
54
55
56
57
<?xml version="1.0" encoding="utf-8"?>
<com.example.androidoffertest.view.CustomNestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".test3.TestFragment3">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<!--headview-->
<LinearLayout
android:id="@+id/headview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:title="嵌套滚动解决方式" />

<ImageView
android:layout_width="match_parent"
android:layout_height="180dp"
android:src="@color/design_default_color_secondary_variant" />

</LinearLayout>

<!--底部视图-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="vertical"
android:layout_weight="1">

<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp2"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>

</LinearLayout>

</LinearLayout>

</com.example.androidoffertest.view.CustomNestedScrollView>
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package com.example.androidoffertest.view

import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.widget.LinearLayout
import androidx.core.view.NestedScrollingParent3
import androidx.core.view.NestedScrollingParentHelper
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import kotlin.math.abs

/**
* @author: wuleizhenshang
* @date: 2025/2/5 17:41
* @description: CustomNestedScrollView
*/
class CustomNestedScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : NestedScrollView(context, attrs, defStyleAttr) {

/**
* 头部View
*/
private var mHeadView: View? = null

/**
* 底部View
*/
private var mBottomView: ViewGroup? = null

/**
* 底部View的第二个子View为ViewPager2
*/
private var mVp2: ViewPager2? = null

/**
* 头部View的高度
*/
private var mHeadTopHeight = 0

/**
* 记录上次触摸的坐标
*/
private var mLastTouchX = 0f
private var mLastTouchY = 0f

//-----view测量绘制等-----------------------------------------------------------------------------//
/**
* 测量
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//避免了使用weight属性导致的测量问题
mBottomView?.layoutParams?.apply {
height = measuredHeight
(this as? LinearLayout.LayoutParams)?.weight = 0f
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

/**
* 当 View 的 尺寸发生变化 时调用,例如:
* 首次测量 后(measure() 计算完大小)。
* 窗口大小变化(例如旋转屏幕、调整布局)。
* 父布局重新分配空间(如 View 变更 layoutParams)。
* 回调时机:
* 可能会被调用多次(窗口变化、重测量时都会触发)。
* 发生在 onMeasure() 之后,但 onLayout() 之前。
*/
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//获取headView高度
mHeadTopHeight = mHeadView?.measuredHeight ?: 0
}

/**
* 当XML布局加载完成并且所有子View都已实例化时调用。
* 只适用于从XML加载的View,手动创建的View(new View(context))不会触发。
* 回调时机:
* 发生在XML解析完毕后,但还未测量、布局和绘制。
* 只会调用一次。
*/
override fun onFinishInflate() {
super.onFinishInflate()
//这里头部为第一个View,底部为第二个View,ViewPager2为底部View的第二个子View
val viewGroup = getChildAt(0) as ViewGroup?
mHeadView = viewGroup?.getChildAt(0)
mBottomView = viewGroup?.getChildAt(1) as ViewGroup?
mVp2 = mBottomView?.getChildAt(1) as ViewPager2?
requireNotNull(mHeadView) { "The first child view is null" }
requireNotNull(mBottomView) { "The second child view is null" }
requireNotNull(mVp2) { "The second child view of the bottom view must be ViewPager2" }
}

//-----传统事件分发--------------------------------------------------------------------------------//
/**
* ViewGroup分发触摸事件
* 如果触摸部分在底部View上,并且水平可以滑动,那么请求父View不要拦截
*/
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (mBottomView == null || ev == null) {
return super.dispatchTouchEvent(ev)
}
val location = IntArray(2)
mBottomView?.getLocationOnScreen(location)
val left = location[0]
val top = location[1]
val right = left + (mBottomView?.width ?: 0)
val bottom = top + (mBottomView?.height ?: 0)
//rawX和rawY是相对于屏幕的坐标
if (ev.rawX < left || ev.rawX > right || ev.rawY < top || ev.rawY > bottom) {
return super.dispatchTouchEvent(ev)
}

when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mLastTouchX = ev.x
mLastTouchY = ev.y
}

MotionEvent.ACTION_MOVE -> {
val dx = ev.x - mLastTouchX
val dy = ev.y - mLastTouchY
//是水平滑动
if (abs(dx) > abs(dy)) {
//内部视图含有ViewPager2并且没有到第一个或者最后一个时,请求父View不要拦截
mVp2?.let { viewPager2 ->
val currentItem = viewPager2.currentItem
val rec = viewPager2.getChildAt(0) as RecyclerView?
val itemCount = rec?.adapter?.itemCount ?: 0
//判断是否第一个且左滑,或者最后一个且右滑,是请求父View拦截,否则不拦截
when {
currentItem == 0 && dx > 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

currentItem == itemCount - 1 && dx < 0 -> parent.requestDisallowInterceptTouchEvent(
false
)

else -> parent.requestDisallowInterceptTouchEvent(true)
}
}
}
}
}
return super.dispatchTouchEvent(ev)
}

//-----嵌套滚动-----------------------------------------------------------------------------------//
/**
* 在嵌套滑动的子控件未滑动之前,判断父控件是否优先与子控件处理(也就是父控件可以先消耗,然后给子控件消耗)
*
* @param target 具体嵌套滑动的那个子类
* @param dx 水平方向嵌套滑动的子控件想要变化的距离 dx<0 向右滑动 dx>0 向左滑动
* @param dy 垂直方向嵌套滑动的子控件想要变化的距离 dy<0 向下滑动 dy>0 向上滑动
* @param consumed 这个参数要我们在实现这个函数的时候指定,回头告诉子控件当前父控件消耗的距离
* consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离 好让子控件做出相应的调整
* @param type 嵌套滑动类型,ViewCompat.TYPE_TOUCH(拖动),ViewCompat.TYPE_NON_TOUCH(惯性滑动)
*/
override fun onNestedPreScroll(
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
//这里必须子控件能嵌套滚动才能触发到这里,否则不会触发,头部的view不会触发到这里
//dy>0表示手指向上滑动,scrollY < mHeadTopHeight表示头部View没有完全隐藏
val isCanMoveTop = dy > 0 && scrollY < mHeadTopHeight
//dy<0表示手指向下滑动,scrollY > 0表示头部View没有完全显示,并且target不能向下滚动
val isCanMoveBottom = dy < 0 && scrollY >= 0 && !target.canScrollVertically(-1)
//改为下面这行就是类似b站的搜索栏了,下滑搜索栏会出来,剩下距离不会拦截了
//val isCanMoveBottom = dy < 0 && scrollY > 0
if (isCanMoveTop || isCanMoveBottom) {
scrollBy(0, dy)
consumed[1] = dy
}
}

}