简介
本篇文档作为滑动控件的延续,如果对滑动控件还有疑问的可以参考连接 https://blog.csdn.net/cxmfzu/article/details/114207345
。在VIew的事件分发中,最为难处理的就是滑动事件冲突,使用传统的事件分发处理滑动冲突,可以参考书籍 《Android开发艺术探索》。本文主要分析出现内嵌滑动时,且控件作为子控件是如何处理,实现NestedScrollingxxxx
相关接口。本文的相关demo参见 https://github.com/CodeKitBox/Control.git
本文档着重回答一下问题:
- Android 源码中View 本身已实现了
NestedScrollingChild
相关接口,View 是如何实现NestedScrollingChild
接口的 - 对比View实现
NestedScrollingChild
接口和自行实现NestedScrollingChild
接口,孰优孰劣 NestedScrollingChild
,NestedScrollingChildHelper
,NestedScrollingParent
,NestedScrollingParentHelper
四个接口是如何配合解决滑动冲突的- 区别
NestedScrollingChild
,NestedScrollingChild1
,NestedScrollingChild2
三个接口的区别
NestedScrollingChild 简介
在正文开始之前,先将 NestedScrollingChild
的相关接口源码贴出来,方便正文的阐述。
public interface NestedScrollingChild{
void setNestedScrollingEnabled(boolean enabled);
boolean isNestedScrollingEnabled();
boolean startNestedScroll(@ScrollAxis int axes);
void stopNestedScroll();
boolean hasNestedScrollingParent();
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
View 实现的内嵌滑动
当我们需要View 实现内嵌滑动时,实现接口 NestedScrollingChild
,你会发现我们自定义的View不需要实现任何接口,从中可以View已经实现了 NestedScrollingChild
的所有接口。那么View是如何实现的呢?
使用系统提供的控件测试内嵌滑动
我们使用以下布局,将ScrollView 中嵌套一个ListView,实现一个简单的滑动嵌套布局,Xml 如下
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/svParent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/holo_green_dark"
android:id="@+id/llFirst"
android:orientation="vertical"/>
<ListView
android:layout_width="match_parent"
android:layout_height="400dp"
android:id="@+id/listView"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/holo_red_light"
android:id="@+id/llSecond"
android:orientation="vertical"/>
</LinearLayout>
</ScrollView>
ListView 的高度是固定的方便看到嵌套的效果,这时候只需要将ListView 的内嵌属性设置为 true,则可以实现一个嵌套滑动,手指在ListView的区域内,优先滑动ListView。通过分析源码,实现自定义View支持内嵌滑动
自定义View 支持内嵌滑动
内嵌滑动主要是事件分发,因此处理滑动相关的流程在 onTouch
中。
- 当手指点击在自定义View的区域内,通知父控件,自定义View支持的内嵌滑动方向,源码如下
override fun onTouchEvent(event: MotionEvent): Boolean{
...
when(vtev.actionMasked){
MotionEvent.ACTION_DOWN->{
// 点击需要停止惯性滑动
if(mScroller.isFinished){
mScroller.abortAnimation()
recycleVelocityTracker()
}
// 设置为非拖动状态
mIsBeingDragged = false
// 记录触摸点坐标
saveLocation(event.x.toInt(),event.y.toInt())
// 通知父控件,子控件支持的内嵌滑动方向
startNestedScroll(View.SCROLL_AXIS_VERTICAL)
}
}
}
View 源码中 startNestedScroll(int axes)
的源码如下
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
// 已经找到支持内嵌滑动的父控件
// Already in progress
return true;
}
// 判断当前的控件是否支持内嵌滑动
if (isNestedScrollingEnabled()) {
// 遍历父控件,
ViewParent p = getParent();
View child = this;
while (p != null) {
try {
// 调用父控件的 onStartNestedScroll 方法
// onStartNestedScroll 在ViewGrop中返回false
// 当是在ScrollView 中,根据条件 (nestedScrollAxes & SCROLL_AXIS_VERTICAL) != 0; 返回的是true
// 因此在ScrollView 作为父控件,默认是支持内嵌滑动的
if (p.onStartNestedScroll(child, this, axes)) {
// 找到支持内嵌滑动的父控件之后,保存,然后返回
mNestedScrollingParent = p;
p.onNestedScrollAccepted(child, this, axes);
return true;
}
} catch (AbstractMethodError e) {
Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
"method onStartNestedScroll", e);
// Allow the search upward to continue
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
在View 中的 startNestedScroll 中是查找到离控件本身最近的支持内嵌滑动的父控件。
- 当子控件开始滑动的处理流程
当子控件开始滑动的处理流程是:首先通知父控件子控件的滑动距离,由父控件决定是否消费子控件的滑动距离,然后子控件根据父控件的消费情况,在进行控件本身的滑动,最后通知父控件是否需要消费子控件剩余的滑动距离
实现源码如下
override fun onTouchEvent(event: MotionEvent): Boolean{
MotionEvent.ACTION_MOVE->{
var dy = differY(event.y.toInt())
println("dy == $dy ; touchSlop = $touchSlop ; mIsBeingDragged=$mIsBeingDragged ")
if(isNestedScrollingEnabled){
// 判断是否父控件优先滑动
/**
* 接口 dispatchNestedPreScroll
* 参数 dx dy 子控件在x 轴,y 轴的偏移
* 参数 consumed 是数组,是父控件消耗了子控件偏移的数据
* 参数 mScrollOffset 也是数组,是父控件滑动了,这时候子控件也要应得滑动
*/
if (dispatchNestedPreScroll(0, dy.toInt(), mScrollConsumed, mScrollOffset)) {
println("父控件优先滑动")
// 调整事件坐标
dy -= mScrollConsumed[1]
vtev.offsetLocation(0f, mScrollOffset[1].toFloat())
mNestedYOffset += mScrollOffset[1]
}else{
println("子控件优先滑动")
// 子控件优先 不用调整
}
}
// 通过系统分发到这里,判断是否可以滑动
if(abs(dy) > touchSlop && !mIsBeingDragged){
mIsBeingDragged = true
parent?.requestDisallowInterceptTouchEvent(true)
}
if (mIsBeingDragged){
// 内嵌滑动下,子控件滑动
// 调用View的接口判断滑动
// 参数 deltaX ,deltaY 指的是滑动的偏移量
// 参数 scrollX scrollY 指的是已经滑动的距离
// 参数 scrollRangeX scrollRangeY 指的是滑动的范围
// 参见 maxOverScrollX maxOverScrollY 指的是越界滑动的距离
// isTouchEvent 系统中此参数没有使用
// 返回值 true 标识达到了最大越界,在惯性滑动中使用
// 调用onOverScrolled 实现真正的滑动
//println("dy = $dy ;scrollY = $scrollY ")
// 记录上一次滑动得距离
val oldY = scrollY
// 子控件滑动
overScrollBy(0,dy,0,scrollY,0,scrollRange,0,0,true)
// 子控件滑动完成,需要判断dy 被完全消费,如果没有完全消费,通知父控件进行一定得滑动
val scrolledDeltaY: Int = scrollY - oldY
val unconsumedY: Int = dy - scrolledDeltaY
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
dy -= mScrollOffset[1]
vtev.offsetLocation(0f, mScrollOffset[1].toFloat())
mNestedYOffset += mScrollOffset[1]
}
// 记录触摸点坐标
saveLocation(event.x.toInt(),event.y.toInt())
}
}
}
- 函数
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,@Nullable int[] offsetInWindow)
是通知在子控件滑动之前通知父控件子控件的滑动距离,由父控件决定是否需要在消费子控件的滑动距离,即父控件优先滑动。
源码如下
public boolean dispatchNestedPreScroll(int dx, int dy,
@Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dx != 0 || dy != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
// 通知父控件,父控件根据传入的dx dy 来进行滑动,如果由滑动,将滑动距离保存在数组 consumed
mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
// 调整View 的位置
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
// 如果父控件由消费滑动距离,则返回true,否则返回false
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
- 函数
overScrollBy
是控件本身的滑动,最终调用scrollTo
接口 - 函数
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow)
在子控件本身完成滑动之后通知父控件剩余的滑动距离,一般在子控件滑动到底部的时候,父控件接着滑动。
源码中
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable @Size(2) int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
// 通知父控件,父控件根据传入的dxUnconsumed, dyUnconsumed 来进行滑动,
// dxConsumed,dyConsumed 是子控件已经消费的滑动距离
mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
dxUnconsumed, dyUnconsumed);
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return true;
} else if (offsetInWindow != null) {
// No motion, no dispatch. Keep offsetInWindow up to date.
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
- 子控件开始惯性滑动
当手指离开界面时,为了更好的用户体验一般需要继续滑动一段距离,即进行惯性滑动。执行惯性滑动的源码如下
override fun onTouchEvent(event: MotionEvent): Boolean {
when(vtev.actionMasked){
MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCEL->{
// 开始惯性滑动
if(mIsBeingDragged){
mVelocityTracker?.let {
it.computeCurrentVelocity(1000)
// 判断是否支持惯性滑动,参考系统源码
println("惯性滑动 ${it.xVelocity};${it.xVelocity}; minFling =$minFling")
// 惯性滑动的速度大于系统最小的惯性滑动速度,执行惯性滑动
if(abs(it.yVelocity) > minFling){
val velocityY = -(it.yVelocity.toInt())
// 判断是否可以滑动
val canFling = (scrollY > 0 || velocityY > 0) &&
(scrollY < getScrollRange() || velocityY < 0)
println("canFling == $canFling")
// dispatchNestedPreFling 返回true 表明父控件完全消费子控件的惯性滑动
if (!dispatchNestedPreFling(0f, velocityY.toFloat())) {
println("子控件消费控件惯性滑动 ")
// 父控件滑动,这里子控件和父控件(如果支持)本质上是做了一的滑动,没有办法父控件滑动一部分,子控件滑动一部分
// 这部分的优化在 NestedScrollingChild2 NestedScrollingParent2 中实现了,需要参考RecyclerView的源码
val ret = dispatchNestedFling(0f, velocityY.toFloat(), canFling)
println("dispatchNestedFling ret == $ret")
if (canFling) {
/**
* 参数 startX, startY 起始的滑动距离
* 参数 velocityX velocityY 滑动的速度
* 参数 minX minY 最小的滑动距离
* 参数 maxX maxY 最大的滑动就离
*/
mScroller.fling(0, scrollY, 0, velocityY,
0, 0,
0, getScrollRange())
// 通知界面刷新
invalidate()
}
}else{
println("父控件消费惯性滑动")
}
}
}
}
// 设置为非拖动状态
mIsBeingDragged = false
// 记录触摸点坐标
saveLocation(event.x.toInt(),event.y.toInt())
recycleVelocityTracker()
}
}
}
- 函数
dispatchNestedPreFling
,通知父控件开始滑动,当这个接口返回true的时候,表明父控件完全消费惯性滑动,子控件不进行滑动,当返回false,父控件和子控件一起惯性滑动一样的距离 - 函数
dispatchNestedFling
和子控件一起进行一个惯性滑动 - 由于惯性滑动不能实现子控件滑动一部分,父控件滑动一部分,因此有了其他接口
重新内嵌滑动相关接口和自定义View实现 NestedScrollingChild区别
- 自定义View 默认已经实现了
NestedScrollingChild
的接口, 且有默认的行为,因此较为简单,可以针对不满足条件的进行重写就可以。 - View 实现
NestedScrollingChild
的默认行为和NestedScrollingChildHelper
的默认行为是一致的。 - 通过实现
NestedScrollingChild
接口的好处是提供了一个清晰的实现行为,一般通过代理模式来做,会取得较好的效果。 - 如果有实现
NestedScrollingChild1
或者NestedScrollingChild2
,最好通过NestedScrollingChildHelper
来实现。
区别 NestedScrollingChild
,NestedScrollingChild1
,NestedScrollingChild2
三个接口的区别
NestedScrollingChild1
NestedScrollingChild1 的源码如下
public interface NestedScrollingChild2 extends NestedScrollingChild{
boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);
void stopNestedScroll(@NestedScrollType int type);
boolean hasNestedScrollingParent(@NestedScrollType int type);
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
@NestedScrollType int type);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow, @NestedScrollType int type);
}
通过和NestedScrollingChild
对比发现接口的名非常相似,只是多了一个参数 type
,参数type
是用来区分是触摸滑动还是非触摸滑动的。具体用法参见RecyclerView
- 当 RecyclerView 通过代码实现平滑滑动时,即调用接口
smoothScrollBy
void smoothScrollBy(@Px int dx, @Px int dy, @Nullable Interpolator interpolator,
int duration, boolean withNestedScrolling){
...
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
}
- 当RecyclerView 执行惯性滑动时
public boolean fling(int velocityX, int velocityY) {
...
startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
}
- 当相应Down事件时,onInterceptTouchEvent,onTouch
case MotionEvent.ACTION_DOWN:
...
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
dispatchNestedPreScroll
和 dispatchNestedScroll
调用的源码在 onTouchEvent
,和 ViewFlinger
中,从中可以看出,子控件将是否为触摸引起的滑动通知给了父控件,父控件可以根据是否是手指滑动进行一些特殊处理
NestedScrollingChild3
NestedScrollingChild3 的源码如下,实现子空间滑动结束后也通知父控件是怎么样的滑动类型。
public interface NestedScrollingChild3 extends NestedScrollingChild2 {
void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
@Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type,
@NonNull int[] consumed);
}