Android嵌套滑动

Android 5.0时,谷歌推出NestedScrolling机制,来解决传统嵌套的缺陷


传统嵌套滑动

由于父View拦截后,后续事件无法传递到子View,导致滑动无法连贯,必须滑完父View,抬起手指,重新向上滑动

比如:ScrollView嵌套TopView+ListView,或者自定义一个LinearLayout(在onInterceptTouchEvent中判断是否拦截,在onTouchEvent中使用scrollBy进行滑动)

NestedScrolling机制

  • Android5.0以上,可以直接使用ViewGoup与View自带方法
  • 向下兼容,使用support v4包提供的接口

父View实现接口:NestedScrollingParent
子View实现接口:NestedScrollingChild

或者

父View实现接口:NestedScrollingParent2 / 3
子View实现接口:NestedScrollingChild2 / 3

NestedScrollingParent

@param child:
<ParentView><ChildView/></ParentView>
如果这样,那么 child = target
<ParentView><ViewGroup><ChildView/></ViewGroup></ParentView>
如果这样,那么 child = ViewGroup
@param target: 嵌套滚动的view (ChildView)
@param axes: 滚动方向
@return: 父View是否接收嵌套滑动

1
2
3
boolean onStartNestedScroll(@NonNull View child,
@NonNull View target,
@ScrollAxis int axes);

上同,上面方法返回true才会执行此方法

1
2
3
void onNestedScrollAccepted(@NonNull View child, 
@NonNull View target,
@ScrollAxis int axes);

嵌套滑动结束

1
void onStopNestedScroll(@NonNull View target);

@param target:ChildView
@param dxConsumed: ChildView消费的x距离
@param dyConsumed: ChildView消费的y距离
@param dxUnconsumed: ChildView未消费的x距离
@param dyUnconsumed: ChildView未消费的y距离
子View滑动之后,判断父View是否继续滑动

1
2
3
4
5
void onNestedScroll(@NonNull View target,
int dxConsumed,
int dyConsumed,
int dxUnconsumed,
int dyUnconsumed);

@param target :ChildView
@param dx: 水平滑动距离
@param dy: 垂直滑动距离
@param consumed :当前父View消耗的距离
consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离
子View滑动之前,父View优先处理

1
2
3
4
void onNestedPreScroll(@NonNull View target,
int dx,
int dy,
@NonNull int[] consumed);

@param target:ChildView
@param velocityX: x轴滚动速度
@param velocityY: y轴滚动速度
@param consumed: 子View是否消耗掉fling
@return:父View是否消耗fling
当父View不拦截fling,子View将fling传入父View

1
2
3
4
boolean onNestedFling(@NonNull View target,
float velocityX,
float velocityY,
boolean consumed);

@param target:ChildView
@param velocityX: x轴滚动速度
@param velocityY: y轴滚动速度
@return:父View是否拦截fling
子View产生fling时,判断父View是否拦截

1
2
3
boolean onNestedPreFling(@NonNull View target,
float velocityX,
float velocityY);

返回父View滑动方向

1
int getNestedScrollAxes();

NestedScrollingChild

设置当前子View是否支持嵌套滑动,如果是false,父View无法响应嵌套滑动

1
void setNestedScrollingEnabled(boolean enabled);

当前子View是否支持嵌套滑动

1
boolean isNestedScrollingEnabled();

@param axes滑动方向
@return 返回true, 表示有父View可以配合当前子View一起嵌套滑动

1
boolean startNestedScroll(@ScrollAxis int axes);

当前子View停止嵌套滑动

1
void stopNestedScroll();

判断是否有父View

1
boolean hasNestedScrollingParent();

@param dxConsumed: ChildView消费的x距离
@param dyConsumed: ChildView消费的y距离
@param dxUnconsumed: ChildView未消费的x距离
@param dyUnconsumed: ChildView未消费的y距离
父View滑动后,子View滑动后,继续将事件分发给父View来判断是否消耗

1
2
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);

@param dx: 子View想要的水平滑动距离
@param dy: 子View想要的垂直滑动距离
@param consumed :子View传给父View的数组,记录父View消耗的距离
consumed[0] 水平消耗的距离,consumed[1] 垂直消耗的距离
子View滑动前,将事件发给父View,让父View计算消耗

1
2
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);

@param velocityX: x轴滚动速度
@param velocityY: y轴滚动速度
@param consumed: 子View是否消耗掉fling

1
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);

@param velocityX: x轴滚动速度
@param velocityY: y轴滚动速度
@return:父View是否拦截fling
如果返回true,表示父View拦截了fling,子View无法处理fling

1
boolean dispatchNestedPreFling(float velocityX, float velocityY);

源码分析(以NestedScrollingChild和NestedScrollingParent为例)

onTouchEvent

ACTION_DOWN

NestedScrollingChild / startNestedScroll
NestedScrollingChildHelper / startNestedScroll
while循环直到找到实现NestedScrollingParent的父View
if(ViewParentCompat / onStartNestedScroll)
NestedScrollingParent / onStartNestedScroll
如果返回true,则表示已找到父View,进行如下操作
NestedScrollingChildHelper / setNestedScrollingParentForType(保存父View)
ViewParentCompat / onNestedScrollAccepted
NestedScrollingParent / onNestedScrollAccepted

Child拿到ActionDown事件,先执行startNestedScroll,通过NestedScrollingChildHelper将参数传递到Parent的回调onStartNestedScroll,如果返回ture(表示配合Child进行嵌套滑动),则还会继续回调onNestedScrollAccepted

ACTION_MOVE

NestedScrollingChild / dispatchNestedPreScroll
NestedScrollingChildHelper / dispatchNestedPreScroll
ViewParentCompat / onNestedPreScroll
NestedScrollingParent / onNestedPreScroll

vNestedScrollingChild / dispatchNestedScrollNestedScrollingChildHelper / dispatchNestedScroll ViewParentCompat / onNestedScrollNestedScrollingParent / onNestedScroll`

Child在滑动之前,调用dispatchNestedPreScroll,通过NestedScrollingChildHelper将参数传递到Parent的回调onNestedPreScroll,在这个方法中Parent可以优先于Child滑动,并记录消费的距离,如果Parent不去记录消费的距离,则距离会由Child自身消费,即Child开始滑动,会调用dispatchNestedScroll,通过NestedScrollingChildHelper将参数传递到Parent的回调onNestedScroll

ACTION_UP

NestedScrollingChild / dispatchNestedPreFling
NestedScrollingChildHelper / dispatchNestedPreFling
ViewParentCompat / onNestedPreFling
NestedScrollingParent / onNestedPreFling

NestedScrollingChild / dispatchNestedFling
NestedScrollingChildHelper / dispatchNestedFling
ViewParentCompat / onNestedFling
NestedScrollingParent / onNestedFling

NestedScrollingChild / stopNestedScroll
NestedScrollingChildHelper / stopNestedScroll
ViewParentCompat / onStopNestedScroll(置空父View)
NestedScrollingParent / onStopNestedScroll

Child在fling之前会调用dispatchNestedPreFling,通过NestedScrollingChildHelper将参数传递到Parent的回调onNestedPreFling,Parent如果返回true,则Child无法处理fling,如果返回false,Child和Parent一起处理fling,这个地方体验不是很好,算是一个初代版本的bug


举例(自定义LinearLayout,包含一个topView和bottomView)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<com.stew.androidtest.testfornestedscroll.NSParentLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<ImageView
android:id="@+id/top_view"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@color/purple_200" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/bottom_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

</com.stew.androidtest.testfornestedscroll.NSParentLayout>




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
public class NSParentLayout extends LinearLayout implements NestedScrollingParent {

private static final String TAG = NSParentLayout.class.getSimpleName();
private View topView;
private View bottomView;
private int topViewHeight;
private final NestedScrollingParentHelper mNestedScrollingParentHelper;
private final LinearLayout.LayoutParams params;

public NSParentLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
Log.d(TAG, "NSParentLayout: ");
mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
setOrientation(VERTICAL);
params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT,LinearLayout.LayoutParams.MATCH_PARENT);
}

@Override
protected void onFinishInflate() {
super.onFinishInflate();
Log.d(TAG, "onFinishInflate: ");
topView = findViewById(R.id.top_view);
bottomView = findViewById(R.id.bottom_view);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Log.d(TAG, "onMeasure: ");
params.height = getMeasuredHeight();
bottomView.setLayoutParams(params);
topViewHeight = topView.getMeasuredHeight();
}

@Override
public void scrollTo(int x, int y) {

if (y < 0) {
y = 0;
}

if (y >= topViewHeight) {
y = topViewHeight;
}
super.scrollTo(x, y);
}

@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int nestedScrollAxes) {
Log.d(TAG, "onStartNestedScroll: ");
return true;
}

@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes) {
Log.d(TAG, "onNestedScrollAccepted: ");
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
}

@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
Log.d(TAG, "onNestedPreScroll: getScrollY = " + getScrollY());
//topView刚好要消失
boolean FLAG_TOP_ON = dy > 0 && getScrollY() < topViewHeight;
//topView刚好要出现
boolean FLAG_TOP_OFF = dy < 0 && getScrollY() > 0 && !target.canScrollVertically(-1);
if (FLAG_TOP_ON || FLAG_TOP_OFF) {
scrollBy(0, dy);
consumed[1] = dy;
}
}

@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
Log.d(TAG, "onNestedScroll dxConsumed: "
+ dxConsumed + " dyConsumed:"
+ dyConsumed + " dxUnconsumed:"
+ dxUnconsumed + " dyUnconsumed:"
+ dyUnconsumed);
}

@Override
public boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY) {
Log.d(TAG, "onNestedPreFling: ");
return false;
}

@Override
public boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed) {
Log.d(TAG, "onNestedFling: velocityY = " + velocityY + " consumed = " + consumed);
if (velocityY > 0) {
scrollBy(0, topViewHeight - getScrollY());
} else if (velocityY < 0) {
scrollBy(0, -getScrollY());
}

return false;
}

@Override
public void onStopNestedScroll(@NonNull View target) {
Log.d(TAG, "onStopNestedScroll: ");
mNestedScrollingParentHelper.onStopNestedScroll(target);
}
}

NestedScrollingChild2与NestedScrollingParent2

在初代版本中,fling是无法传递给parent的,parent也知不知道child的fling何时结束
现在第二个版本可以传递fling了,和第一代相比,每个方法的参数都多了一个type(除了fling)

1
2
3
4
5
6
7
8
9
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type)

public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type)

public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type)

public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type)

public void onStopNestedScroll(@NonNull View target, int type)

上述5个方法内部的逻辑和第一版本一样,另外如下两个fling方法都返回false

1
2
3
4
5
6
7
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
return false;
}

public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
return false;
}

总结

1.正常滑动,非fling(具体可参考RecyclerView源码onTouchEvent中的具体细节)

NestedScrollingChild / dispatchNestedPreScroll
对应
NestedScrollingParent / onNestedPreScroll

NestedScrollingChild / dispatchNestedScroll
对应
NestedScrollingParent / onNestedScroll

一个正常的滑动(down/move/up)操作下来,比如竖直方向的,dispatchNestedPreScroll和dispatchNestedScroll都会调用到,先执行dispatchNestedPreScroll,把当前一帧(16ms)的滑动距离dy分发到parent的onNestedPreScroll,parent如果消费(滑动),则consume[1]=dy,回到child,dy=dy-consume[1]=0,所以dispatchNestedScroll方法内没有进行滑动,即parent滑动,child自身不滑动,dispatchNestedPreScroll和dispatchNestedScroll这俩方法,各代表谁来滑动,关键条件就是onNestedPreScroll内写的自定义条件,条件用户自己来定

2.fling

滑动速度达到阀值,手指up之后就会进行fling操作,在fling方法内先进行了

1
2
3
4
5
6
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)
if (parent instanceof NestedScrollingParent2) {
} else if (type == ViewCompat.TYPE_TOUCH) {
}
return false;

如果没有实现NestedScrollingParent2接口,比如第一个版本,那么就会返回false,也就不会进行
setNestedScrollingParentForType(type, p),则在后续的fling操作中,具体细节:

1
2
3
postOnAnimation
internalPostOnAnimation
ViewCompat.postOnAnimation(RecyclerView.this, this);

这个this是一个runnable,具体操作在run方法中,run方法中也有一套dispatchNestedPreScroll和dispatchNestedScroll,只不过他的驱动力是循环internalPostOnAnimation,不断地去减速执行这个runnable,可以把它想象成一个自动的减速的【手动滑动】,接着分析,在dispatchNestedPreScroll内部:

1
2
3
4
final ViewParent parent = getNestedScrollingParentForType(type);
if (parent == null) {
return false;
}

由于没有parent,则dispatchNestedPreScroll方法返回false,所以第一版本的NestedScrollingParent实现类是无法实现fling操作的,为了解决这个bug,谷歌推出来了NestedScrollingParent2,如果parent实现了NestedScrollingParent2,则child的dispatchNestedPreScroll方法就可以顺利的打达parent的onNestedPreScroll,然后再根据自定义的条件,来决定要不要消费fling的数据,滑动部分其实原理差不多,只不过驱动力是循环执行一个runnable,而正常滑动是靠手指滑动,多走几遍过程就熟悉了,不得不说谷歌兼容设计的很巧妙