嵌套系列导航
- 1.浅析NestedScrolling嵌套滑动机制之基础篇
- 2.浅析NestedScrolling嵌套滑动机制之实践篇-仿写饿了么商家详情页
- 3.浅析NestedScrolling嵌套滑动机制之CoordinatorLayout.Behavior
- 4.浅析NestedScrolling嵌套滑动机制之实践篇-自定义Behavior实现小米音乐歌手详情
本文已在公众号鸿洋原创发布。未经许可,不得以任何形式转载!
概述
NestedScrolling是Android5.0推出的嵌套滑动机制,能够让父View和子View在滑动时相互协调配合可以实现连贯的嵌套滑动,它基于原有的触摸事件分发机制上为ViewGroup和View增加处理滑动的方法提供调用,后来为了向前兼容到Android1.6,在Revision 22.1.0的android.support.v4兼容包中提供了从View、ViewGroup抽取出NestedScrollingChild、NestedScrollingParent两个接口和NestedScrollingChildHelper、NestedScrollingParentHelper两个辅助类来帮助控件实现嵌套滑动,CoordinatorLayout便是基于这个机制实现各种神奇的滑动效果。
处理同向滑动事件冲突
如果两个可滑动的容器嵌套,外部View拦截了内部View的滑动,可能造成滑动冲突,通常基于传统的触摸事件分发机制来解决:
1.外部拦截法
public class MyScrollView extends ScrollView {
private int mLastY = 0;
//此处省略构造方法
...
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int y = (int) ev.getY();
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
//调用ScrollView的onInterceptTouchEvent()初始化mActivePointerId
super.onInterceptTouchEvent(ev);
break;
case MotionEvent.ACTION_MOVE:
int detY = y - mLastY;
//这里要找到子ScrollView
View contentView = findViewById(R.id.my_scroll_inner);
if (contentView == null) {
return true;
}
//判断子ScrollView是否滑动到顶部或者顶部
boolean isChildScrolledTop = detY > 0 && !contentView.canScrollVertically(-1);
boolean isChildScrolledBottom = detY < 0 && !contentView.canScrollVertically(1);
if (isChildScrolledTop || isChildScrolledBottom) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastY = y;
return intercepted;
}
}
2.内部拦截法
public class MyScrollView extends ScrollView {
private int mLastY = 0;
//此处省略构造方法
...
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int y = (int) ev.getY();
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int detY = y - mLastY;
boolean isScrolledTop = detY > 0 && !canScrollVertically(-1);
boolean isScrolledBottom = detY < 0 && !canScrollVertically(1);
//根据自身是否滑动到顶部或者顶部来判断让父View拦截触摸事件
if (isScrolledTop || isScrolledBottom) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
mLastY = y;
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
super.onInterceptTouchEvent(ev);
return false;
}
return true;
}
}
3.小结
上面通过两种经典的解决方案,在内部View可以滑动时,外部View不拦截,当内部View滑动到底部或者顶部时,让外部消费滑动事件进行滑动。一般而言,外部拦截法和内部拦截法不能公用。 否则内部容器可能并没有机会调用 requestDisallowInterceptTouchEvent方法。在传统的触摸事件分发中,如果不手动调用分发事件或者去发出事件,外部View最先拿到触摸事件,一旦它被外部View拦截消费了,内部View无法接收到触摸事件,同理,内部View消费了触摸事件,外部View也没有机会响应触摸事件。 而接下介绍的NestedScrolling机制,在一次滑动事件中外部View和内部View都有机会对滑动进行响应,这样处理滑动冲突就相对方便许多。
NestedScrolling机制原理
NestedScrollingChild(下图简称nc)、NestedScrollingParent(下图简称np)逻辑上分别对应之前内部View和外部View的角色,之所以称之为逻辑上是因为View可以同时扮演NestedScrollingChild和NestedScrollingParent,下面图片就是NestedScrolling的交互流程。
接下来详细说明一下上图的交互流程:
1.当NestedScrollingChild接收到触摸事件MotionEvent.ACTION_DOWN时,它会往外层布局遍历寻找最近的NestedScrollingParent请求配合处理滑动。所以它们之间层级不一定是直接上下级关系。
2.如果NestedScrollingParent不配合NestedScrollingChild处理滑动就没有接下来的流程,否则就会配合处理滑动。
3.NestedScrollingChild要滑动之前,它先拿到MotionEvent.ACTION_MOVE滑动的dx,dy并将一个有两个元素的数组(分别代表NestedScrollingParent要滑动的水平和垂直方向的距离)作为输出参数一同传给NestedScrollingParent。
4.NestedScrollingParent拿到上面【3】NestedScrollingChild传来的数据,将要消费的水平和垂直方向的距离传进数组,这样NestedScrollingChild就知道NestedScrollingParent要消费滑动值是多少了。
5.NestedScrollingChild将【2】里拿到的dx、dy减去【4】NestedScrollingParent消费滑动值,计算出剩余的滑动值;如果剩余的滑动值为0说明NestedScrollingParent全部消费了NestedScrollingChild不应进行滑动;否则NestedScrollingChild根据剩余的滑动值进行消费,然后将自己消费了多少、还剩余多少汇报传递给NestedScrollingParent。
6.如果NestedScrollingChild在滑动期间发生的惯性滑动,它会将velocityX,velocityY传给NestedScrollingParent,并询问NestedScrollingParent是否要全部消费。
7.NestedScrollingParent收到【6】NestedScrollingChild传来的数据,告诉NestedScrollingChild是否全部消费惯性滑动。
8.如果在【7】NestedScrollingParent没有全部消费惯性滑动,NestedScrollingChild会将velocityX,velocityY、自身是否需要消费全部惯性滑动传给NestedScrollingParent,并询问NestedScrollingParent是否要全部消费。
9.NestedScrollingParent收到【8】NestedScrollingChild传来的数据,告诉NestedScrollingChild是否全部消费惯性滑动。
10.NestedScrollingChild停止滑动时通知NestedScrollingParent。
PS:
- A.上面的【消费】是指可滑动View调用自身的滑动方法进行滑动来消耗滑动数值,比如scrollBy()、scrollTo()、fling()、offsetLeftAndRight()、offsetTopAndBottom()、layout()、Scroller、LayoutParams等,View实现NestedScrollingParent、NestedScrollingChild只仅仅是能将数值进行传递,需要配合Touch事件根据需求去调用NestScrolling的接口和辅助类,而本身不支持滑动的View即使有嵌套滑动的相关方法也不能进行嵌套滑动。
- B.在【1】中外层实现NestedScrollingParent的View不该拦截NestedScrollingChild的MotionEvent.ACTION_DOWN;在【2】中如果NestedScrollingParent配合处理滑动时,实现NestedScrollingChild的View应该通过getParent().requestDisallowInterceptTouchEvent(true)往上递归关闭外层View的事件拦截机制,这样确保【3】中NestedScrollingChild先拿到MotionEvent.ACTION_MOVE。具体可以参考RecyclerView和NestedScrollView源码的触摸事件处理。
类与接口
前面提到Android 5.0及以上的View、ViewGroup自身分别就有NestedScrollingChild和NestedScrollingParent的方法,而方法逻辑就是对应的NestedScrollingChildHelper和NestedScrollingParentHelper的具体方法实现,所以本小节不讲解View、ViewGroup的NestedScrolling机制相关内容,请自行查看源码。
1.NestedScrollingChild
public interface NestedScrollingChild {
/**
* @param enabled 开启或关闭嵌套滑动
*/
void setNestedScrollingEnabled(boolean enabled);
/**
* @return 返回是否开启嵌套滑动
*/
boolean isNestedScrollingEnabled();
/**
* 沿着指定的方向开始滑动嵌套滑动
* @param axes 滑动方向
* @return 返回是否找到NestedScrollingParent配合滑动
*/
boolean startNestedScroll(@ScrollAxis int axes);
/**
* 停止嵌套滑动
*/
void stopNestedScroll();
/**
* @return 返回是否有配合滑动NestedScrollingParent
*/
boolean hasNestedScrollingParent();
/**
* 滑动完成后,将已经消费、剩余的滑动值分发给NestedScrollingParent
* @param dxConsumed 水平方向消费的距离
* @param dyConsumed 垂直方向消费的距离
* @param dxUnconsumed 水平方向剩余的距离
* @param dyUnconsumed 垂直方向剩余的距离
* @param offsetInWindow 含有View从此方法调用之前到调用完成后的屏幕坐标偏移量,
* 可以使用这个偏移量来调整预期的输入坐标(即上面4个消费、剩余的距离)跟踪,此参数可空。
* @return 返回该事件是否被成功分发
*/
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
/**
* 在滑动之前,将滑动值分发给NestedScrollingParent
* @param dx 水平方向消费的距离
* @param dy 垂直方向消费的距离
* @param consumed 输出坐标数组,consumed[0]为NestedScrollingParent消耗的水平距离、
* consumed[1]为NestedScrollingParent消耗的垂直距离,此参数可空。
* @param offsetInWindow 同上dispatchNestedScroll
* @return 返回NestedScrollingParent是否消费部分或全部滑动值
*/
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
/**
* 将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分发给NestedScrollingParent
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
* @return 返回NestedScrollingParent是否消费全部惯性滑动
*/
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
/**
* 在惯性滑动之前,将惯性滑动值分发给NestedScrollingParent
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @return 返回NestedScrollingParent是否消费全部惯性滑动
*/
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
2.NestedScrollingParent
public interface NestedScrollingParent {
/**
* 对NestedScrollingChild发起嵌套滑动作出应答
* @param child 布局中包含下面target的直接父View
* @param target 发起嵌套滑动的NestedScrollingChild的View
* @param axes 滑动方向
* @return 返回NestedScrollingParent是否配合处理嵌套滑动
*/
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* NestedScrollingParent配合处理嵌套滑动回调此方法
* @param child 同上
* @param target 同上
* @param axes 同上
*/
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* 嵌套滑动结束
* @param target 同上
*/
void onStopNestedScroll(@NonNull View target);
/**
* NestedScrollingChild滑动完成后将滑动值分发给NestedScrollingParent回调此方法
* @param target 同上
* @param dxConsumed 水平方向消费的距离
* @param dyConsumed 垂直方向消费的距离
* @param dxUnconsumed 水平方向剩余的距离
* @param dyUnconsumed 垂直方向剩余的距离
*/
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
/**
* NestedScrollingChild滑动完之前将滑动值分发给NestedScrollingParent回调此方法
* @param target 同上
* @param dx 水平方向的距离
* @param dy 水平方向的距离
* @param consumed 返回NestedScrollingParent是否消费部分或全部滑动值
*/
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
/**
* NestedScrollingChild在惯性滑动之前,将惯性滑动的速度和NestedScrollingChild自身是否需要消费此惯性滑动分
* 发给NestedScrollingParent回调此方法
* @param target 同上
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @param consumed NestedScrollingChild自身是否需要消费此惯性滑动
* @return 返回NestedScrollingParent是否消费全部惯性滑动
*/
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
/**
* NestedScrollingChild在惯性滑动之前,将惯性滑动的速度分发给NestedScrollingParent
* @param target 同上
* @param velocityX 同上
* @param velocityY 同上
* @return 返回NestedScrollingParent是否消费全部惯性滑动
*/
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
/**
* @return 返回当前嵌套滑动的方向
*/
int getNestedScrollAxes();
}
3.方法调用流程图:
4.NestedScrollingChildHepler
NestedScrollingChildHepler对NestedScrollingChild的接口方法做了代理,您可以结合实际情况借助它来实现,如:
public class MyScrollView extends View implements NestedScrollingChild{
...
@Override
public boolean startNestedScroll(int axes) {
return mChildHelper.startNestedScroll(axes);
}
}
这里只分析关键的方法,具体代码请参考源码。
4.1 startNestedScroll()
public boolean startNestedScroll(int axes) {
//判断是否找到配合处理滑动的NestedScrollingParent
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {//判断是否开启滑动嵌套
ViewParent p = mView.getParent();
View child = mView;
//循环往上层寻找配合处理滑动的NestedScrollingParent
while (p != null) {
//ViewParentCompat.onStartNestedScroll()会判断p是否实现NestedScrollingParent,
//若是则将p转为NestedScrollingParent类型调用onStartNestedScroll()方法
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
mNestedScrollingParent = p;
//通过ViewParentCompat调用p的onNestedScrollAccepted()方法
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
这个方法首先会判断是否已经找到了配合处理滑动的NestedScrollingParent、若找到了则返回true,否则会判断是否开启嵌套滑动,若开启了则通过构造函数注入的View来循环往上层寻找配合处理滑动的NestedScrollingParent,循环条件是通过ViewParentCompat这个兼容类判断p是否实现NestedScrollingParent,若是则将p转为NestedScrollingParent类型调用onStartNestedScroll()方法如果返回true则证明找配合处理滑动的NestedScrollingParent,所以接下来同样借助ViewParentCompat调用NestedScrollingParent的onNestedScrollAccepted()。
4.2 dispatchNestedPreScroll()
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//如果开启嵌套滑动并找到配合处理滑动的NestedScrollingParent
if (dx != 0 || dy != 0) {//如果有水平或垂直方向滑动
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
//先记录View当前的在Window上的x、y坐标值
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
//初始化输出数组consumed
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
//通过ViewParentCompat调用NestedScrollingParent的onNestedPreScroll()方法
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
if (offsetInWindow != null) {
//将之前记录好的x、y坐标减去调用NestedScrollingParent的onNestedPreScroll()后View的x、y坐标,计算得出偏移量并赋值进offsetInWindow数组
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//consumed数组的两个元素的值有其中一个不为0则说明NestedScrollingParent消耗的部分或者全部滑动值
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
这个方法首先会判断是否开启嵌套滑动并找到配合处理滑动的NestedScrollingParent,若符合这两个条件则会根据参数dx、dy滑动值判断是否有水平或垂直方向滑动,若有滑动调用mView.getLocationInWindow()将View当前的在Window上的x、y坐标值赋值进offsetInWindow数组并以startX、startY记录,接下来初始化输出数组consumed、并通过ViewParentCompat调用NestedScrollingParent的onNestedPreScroll(),再次调用mView.getLocationInWindow()将调用NestedScrollingParent的onNestedPreScroll()后的View在Window上的x、y坐标值赋值进offsetInWindow数组并与之前记录好的startX、startY相减计算得出偏移量,接着以consumed数组的两个元素的值有其中一个不为0作为boolean值返回,若条件为true说明NestedScrollingParent消耗的部分或者全部滑动值。
4.3 dispatchNestedScroll()
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//如果开启嵌套滑动并找到配合处理滑动的NestedScrollingParent
if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {//如果有消费滑动值或者有剩余滑动值
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
//先记录View当前的在Window上的x、y坐标值
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
//通过ViewParentCompat调用NestedScrollingParent的onNestedScroll()方法
ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed,
dyConsumed, dxUnconsumed, dyUnconsumed);
if (offsetInWindow != null) {
//将之前记录好的x、y坐标减去调用NestedScrollingParent的onNestedScroll()后View的x、y坐标,计算得出偏移量并赋值进offsetInWindow数组
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//返回true表明NestedScrollingChild的dispatchNestedScroll事件成功分发NestedScrollingParent
return true;
} else if (offsetInWindow != null) {
// No motion, no dispatch. Keep offsetInWindow up to date.
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
这个方法与上面的dispatchNestedPreScroll()方法十分类似,这里就不细说了。
4.3 dispatchNestedPreFling()、dispatchNestedFling()
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
//通过ViewParentCompat调用NestedScrollingParent的onNestedPreFling()方法,返回值表示NestedScrollingParent是否消费全部惯性滑动
return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX,
velocityY);
}
return false;
}
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
//通过ViewParentCompat调用NestedScrollingParent的onNestedFling()方法,返回值表示NestedScrollingParent是否消费全部惯性滑动
return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX,
velocityY, consumed);
}
return false;
}
这两方法都是通过ViewParentCompat调用NestedScrollingParent对应的fling方法来返回NestedScrollingParent是否消费全部惯性滑动。
4.NestedScrollingParentHelper
public class NestedScrollingParentHelper {
private final ViewGroup mViewGroup;
private int mNestedScrollAxes;
public NestedScrollingParentHelper(ViewGroup viewGroup) {
mViewGroup = viewGroup;
}
public void onNestedScrollAccepted(View child, View target, int axes) {
mNestedScrollAxes = axes;
}
public int getNestedScrollAxes() {
return mNestedScrollAxes;
}
public void onStopNestedScroll(View target) {
mNestedScrollAxes = 0;
}
}
NestedScrollingParentHelper只提供对应NestedScrollingParent相关的onNestedScrollAccepted()和onStopNestedScroll()方法,主要维护mNestedScrollAxes管理滑动的方向字段。
NestedScrolling机制的改进
惯性滑动不连续问题
在使用之前NestedScrolling机制的 系统控件 嵌套滑动,当内部View快速滑动产生惯性滑动到边缘就停止,而不将惯性滑动传递给外部View继续消费惯性滑动,就会出现下图两个NestedScrollView嵌套滑动这种 惯性滑动不连续 的情况:
这里以com.android.support:appcompat-v7:22.1.0的NestedScrollView源码作为分析问题例子:
@Override
public boolean onTouchEvent(MotionEvent ev) {
...
switch (actionMasked) {
...
case MotionEvent.ACTION_UP:
if (mIsBeingDragged) {
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(velocityTracker,
mActivePointerId);
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
//分发惯性滑动
flingWithNestedDispatch(-initialVelocity);
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
break;
}
...
}
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0) &&
(scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {//将惯性滑动分发给NestedScrollingParent,让它先对惯性滑动进行处理
dispatchNestedFling(0, velocityY, canFling);//若惯性滑动没被消费,再次将惯性滑动分发给NestedScrollingParent,并带上自身是否能消费fling的canFling参数让NestedScrollingParent根据情况处理决定canFling是true还是false
if (canFling) {
//执行fling()消费惯性滑动
fling(velocityY);
}
}
}
public void fling(int velocityY) {
if (getChildCount() > 0) {
int height = getHeight() - getPaddingBottom() - getPaddingTop();
int bottom = getChildAt(0).getHeight();
//初始化fling的参数
mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
Math.max(0, bottom - height), 0, height/2);
//重绘会触发computeScroll()进行滚动
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int oldX = getScrollX();
int oldY = getScrollY();
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
if (oldX != x || oldY != y) {
final int range = getScrollRange();
final int overscrollMode = ViewCompat.getOverScrollMode(this);
final boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
(overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
overScrollByCompat(x - oldX, y - oldY, oldX, oldY, 0, range,
0, 0, false);
if (canOverscroll) {
ensureGlows();
if (y <= 0 && oldY > 0) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
} else if (y >= range && oldY < range) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
}
}
上面代码执行如下:
1.当快速滑动并抬起手指时onTouchEvent()方法会命中MotionEvent.ACTION_UP,执行关键flingWithNestedDispatch()方法将垂直方向的惯性滑动值分发。
2.flingWithNestedDispatch()方法先调用dispatchNestedPreFling()将惯性滑动分发给NestedScrollingParent,若NestedScrollingParent没有消费则调用dispatchNestedFling()并带上自身是否能消费fling的canFling参数让NestedScrollingParent可以根据情况处理决定canFling是true还是false,若canFling值为true,执行fling()方法。
3.fling()方法执行mScroller.fling()初始化fling参数,然后 调用ViewCompat.postInvalidateOnAnimation()重绘触发computeScroll()方法进行滚动。
4.computeScroll()方法里面只让自身进行fling,并没有在自身fling到边缘时将惯性滑动分发给NestedScrollingParent。
NestedScrollingChild2、NestedScrollingParent2
在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild2、NestedScrollingParent2两个接口:
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);
}
public interface NestedScrollingParent2 extends NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
@NestedScrollType int type);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
@NestedScrollType int type);
void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed,
@NestedScrollType int type);
}
它们分别继承NestedScrollingChild、NestedScrollingParent,都为滑动相关的方法添加了int类型参数type,这个参数有两个值:TYPE_TOUCH值为0表示滑动由用户手势滑动屏幕触发;TYPE_NON_TOUCH值为1表示滑动不是由用户手势滑动屏幕触发;同时View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper同样根据参数type做了调整。
前面说到因为系统控件在computeScroll()方法里面只让自身进行fling,并没有在自身fling到边缘时将惯性滑动分发给NestedScrollingParent导致惯性滑动不连贯,所以这里以com.android.support:appcompat-v7:26.1.0的NestedScrollView源码看看如何使用改进后的NestedScrolling机制:
public void fling(int velocityY) {
if (getChildCount() > 0) {
//发起滑动嵌套,注意ViewCompat.TYPE_NON_TOUCH参数表示不是由用户手势滑动屏幕触发
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL,ViewCompat.TYPE_NON_TOUCH);
mScroller.fling(getScrollX(), getScrollY(),
0, velocityY, 0, 0,Integer.MIN_VALUE, Integer.MAX_VALUE,0, 0);
mLastScrollerY = getScrollY();
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
final int x = mScroller.getCurrX();
final int y = mScroller.getCurrY();
int dy = y - mLastScrollerY;
// Dispatch up to parent(将滑动值分发给NestedScrollingParent2)
if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null,ViewCompat.TYPE_NON_TOUCH)) {
//计算NestedScrollingParent2消费后剩余的滑动值
dy -= mScrollConsumed[1];
}
if (dy != 0) {//若滑动值没有NestedScrollingParent2全部消费掉,则自身进行消费滚动
final int range = getScrollRange();
final int oldScrollY = getScrollY();
overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledDeltaY = getScrollY() - oldScrollY;
final int unconsumedY = dy - scrolledDeltaY;
if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,ViewCompat.TYPE_NON_TOUCH)) {//若滚动值没有分发成功给NestedScrollingParent2,则自己用EdgeEffect消费
final int mode = getOverScrollMode();
final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
|| (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
if (canOverscroll) {
ensureGlows();
if (y <= 0 && oldScrollY > 0) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
} else if (y >= range && oldScrollY < range) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
}
// Finally update the scroll positions and post an invalidation
mLastScrollerY = y;
ViewCompat.postInvalidateOnAnimation(this);
} else {
// We can't scroll any more, so stop any indirect scrolling
if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
// and reset the scroller y
mLastScrollerY = 0;
}
}
代码分析如下:
1.与之前的NestedScrollView相比,fling()方法里面用到了NestedScrollingChild2的startNestedScroll方法发起滑动嵌套。
2.computeScroll()方法首先调用dispatchNestedPreScroll()将滑动值分发给NestedScrollingParent2,若滑动值没有被NestedScrollingParent2全部消费掉,则自身进行消费滚动,然后再调用dispatchNestedScroll()将自身消费、剩余的滑动值分发给NestedScrollingParent2,若分发失败则用EdgeEffect(这个用来滑动到顶部或者底部时会出现一个波浪形的边缘效果)消费掉,当mScroller滚动完成后调用stopNestedScroll()方法结束嵌套滑动。
OverScroller未终止滚动动画
在使用之前NestedScrolling机制的 系统控件 嵌套滑动,当子、父View都在顶部时,首先快速下滑子View并抬起手指制造惯性滑动,然后马上滑动父View,这时就会出现上图的两个NestedScrollView嵌套滑动现象,你手指往上滑视图内容往下滚一段距离,视图内容立刻就会自动往上回滚。
这里还是以com.android.support:appcompat-v7:26.1.0的NestedScrollView源码作为分析问题例子:
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0)
&& (scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
fling(velocityY);
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (actionMasked) {
...
case MotionEvent.ACTION_DOWN: {
...
//停止mScroller滚动
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
}
...
}
...
}
代码执行如下:
1.这里分析场景是两个NestedScrollView嵌套滑动,所以dispatchNestedPreFling()返回值为false,子View执行就会fling()方法,前面分析过fling()方法调用mScroller.fling()触发computeScroll()进行实际的滚动。
2.在子View调用computeScroll()方法期间,如果此时子View不命中MotionEvent.ACTION_DOWN,mScroller是不会停止滚动,只能等待它完成,于是就子View就不停调用dispatchNestedPreScroll()和dispatchNestedScroll()分发滑动值给父View,就出现了上图的场景。
NestedScrollingChild3、NestedScrollingParent3
在androidx.core 1.1.0-alpha01开始引入NestedScrollingChild3、NestedScrollingParent3,它们在androidx.core:core:1.1.0正式被添加:
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);
}
public interface NestedScrollingParent3 extends NestedScrollingParent2 {
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
}
NestedScrollingChild3继承NestedScrollingChild2重载dispatchNestedScroll()方法,从返回值类型boolean改为void类型,添加了一个int数组consumed参数作为输出参数记录NestedScrollingParent3消费的滑动值,同理,NestedScrollingParent3继承NestedScrollingParent2重载onNestedScroll添加了一个int数组consumed参数来对应NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper同样根据变化做了适配调整。
下面是androidx.appcompat:appcompat:1.1.0的NestedScrollView源码看看如何使用改进后的NestedScrolling机制:
@Override
public void computeScroll() {
if (mScroller.isFinished()) {
return;
}
mScroller.computeScrollOffset();
final int y = mScroller.getCurrY();
int unconsumed = y - mLastScrollerY;
mLastScrollerY = y;
// Nested Scrolling Pre Pass(分发滑动值给NestedScrollingParent3)
mScrollConsumed[1] = 0;
dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
ViewCompat.TYPE_NON_TOUCH);
//计算剩余的滑动值
unconsumed -= mScrollConsumed[1];
final int range = getScrollRange();
if (unconsumed != 0) {
// Internal Scroll(自身滚动消费滑动值)
final int oldScrollY = getScrollY();
overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledByMe = getScrollY() - oldScrollY;
//计算剩余的滑动值
unconsumed -= scrolledByMe;
// Nested Scrolling Post Pass(分发滑动值给NestedScrollingParent3)
mScrollConsumed[1] = 0;
dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
//计算剩余的滑动值
unconsumed -= mScrollConsumed[1];
}
if (unconsumed != 0) {
//EdgeEffect消费剩余滑动值
final int mode = getOverScrollMode();
final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
|| (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
if (canOverscroll) {
ensureGlows();
if (unconsumed < 0) {
if (mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
}
} else {
if (mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
//停止mScroller滚动动画并结束滑动嵌套
abortAnimatedScroll();
}
if (!mScroller.isFinished()) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
private void abortAnimatedScroll() {
mScroller.abortAnimation();
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
代码分析如下:
1.首先调用dispatchNestedPreScroll()将滑动值分发给NestedScrollingParent3并附带mScrollConsumed数组作为输出参数记录其具体消费多少滑动值,变量unconsumed表示剩余的滑动值,在调用dispatchNestedPreScroll()后,unconsumed减去之前的mScrollConsumed数组的元素重新赋值;
2.此时unconsumed值不为0,说明NestedScrollingParent3没有消费掉全部滑动值,则自身掉用overScrollByCompat()进行滚动消费滑动值,unconsumed减去记录本次消费的滑动值scrolledByMe重新赋值;然后调用dispatchNestedScroll()类似于【1】将滑动值分发给NestedScrollingParent3的操作然后计算unconsumed;
3.若unconsumed值还不为0,说明滑动值没有完全消费掉,此时实现NestedScrollingParent3、NestedScrollingChild3对应的父View、子View在同一方向都滑动到了边缘尽头,此时自身用EdgeEffect消费剩余滑动值并调用abortAnimatedScroll()来 停止mScroller滚动并结束嵌套滑动;
NestedScrolling机制的使用
如果你最低支持android版本是5.0及其以上,你可以使用View、ViewGroup本身对应的NestedScrollingChild、NestedScrollingParent接口;如果你使用AndroidX那么你就需要使用NestedScrollingChild3、NestedScrollingParent3;如果你兼容Android5.0之前版本请使用NestedScrollingChild2、NestedScrollingParent2。下面的例子是伪代码,因为下面的自定义View没有实现类似Scroller的方式来消费滑动值,因此它运行也不能实现嵌套滑动进行滑动,只是提供给大家处理触摸事件调用NestedScrolling机制的思路。
使用NestedScrollingParent2
如果要兼容NestedScrollingParent则覆写其接口即可,可以借助NestedScrollingParentHelper结合需求作方法代理,你可以根据具体业务在onStartNestedScroll()选择在嵌套滑动的方向、在onNestedPreScroll()要不要消费NestedScrollingChild2的滑动值等等。
使用NestedScrollingChild2
如果要兼容NestedScrollingChild则覆写其接口即可,可以借助NestedScrollingChildHelper结合需求作方法代理。
public class NSChildView extends FrameLayout implements NestedScrollingChild2 {
private int mLastMotionY;
private final int[] mScrollOffset = new int[2];
private final int[] mScrollConsumed = new int[2];
...
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
//关闭外层触摸事件拦截,确保能拿到MotionEvent.ACTION_MOVE
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mLastMotionY = (int) ev.getY();
//开始嵌套滑动
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
break;
}
case MotionEvent.ACTION_MOVE:
final int y = (int) ev.getY();
int deltaY = mLastMotionY - y;
//开始滑动之前,分发滑动值给NestedScrollingParent2
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
deltaY -= mScrollConsumed[1];
}
//模拟Scroller消费剩余滑动值
final int oldY = getScrollY();
scrollBy(0,deltaY);
//计算自身消费的滑动值,汇报给NestedScrollingParent2
final int scrolledDeltaY = getScrollY() - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
mLastMotionY -= mScrollOffset[1];
}else {
//可以选择EdgeEffectCompat消费剩余的滑动值
}
break;
case MotionEvent.ACTION_UP:
//可以用VelocityTracker计算velocityY
int velocityY=0;
//根据需求判断是否能Fling
boolean canFling=true;
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
//模拟执行惯性滑动,如果你希望惯性滑动也能传递给NestedScrollingParent2,对于每次消费滑动距离,
// 与MOVE事件中处理滑动一样,按照dispatchNestedPreScroll() -> 自己消费 -> dispatchNestedScroll() -> 自己消费的顺序进行消费滑动值
fling(velocityY);
}
//停止嵌套滑动
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
case MotionEvent.ACTION_CANCEL:
//停止嵌套滑动
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
}
return true;
}
同时使用NestedScrollingChild2、NestedScrollingParent2
这种情况通常是ViewGroup支持布局嵌套如:
<android.support.v4.widget.NestedScrollView
android:tag="我是爷爷">
<android.support.v4.widget.NestedScrollView
android:tag="我是爸爸">
<android.support.v4.widget.NestedScrollView
android:tag="我是儿子">
</android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >
举个例子:当儿子NestedScrollView调用stopNestedScroll()停止嵌套滑动时,就会回调爸爸NestedScrollView的onStopNestedScroll(),这时爸爸NestedScrollView也该停止嵌套滑动并且爷爷NestedScrollView也应该收到爸爸NestedScrollView的停止嵌套滑动,故在NestedScrollingParent2的onStopNestedScroll()应该这么写达到嵌套滑动事件往外分发的效果:
//NestedScrollingParent2
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
mParentHelper.onStopNestedScroll(target, type);
//往外分发
stopNestedScroll(type);
}
//NestedScrollingChild2
@Override
public void stopNestedScroll(int type) {
mChildHelper.stopNestedScroll(type);
}
常见交互效果
除了下面的饿了么商家详情页外其他的效果可以用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 实现折叠悬停效果,其实它们底层Behavior也是基于NestedScrolling机制来实现的,而像饿了么这样的效果如果使用自定View的话要么用NestedScrolling机制来实现,要能基于传统的触摸事件分发实现。
- 1.饿了么商家详情页(v8.27.6)
- 2.美团商家详情页(v10.6.203)
- 3.腾讯课堂首页(v4.7.1)
- 4.腾讯课堂课程详情页(v4.7.1)
- 5.支付宝首页(v10.1.82)
总结
本文偏向概念性内容,难免有些枯燥,但若遇到稍微有点挑战要解决的问题,没有现成的工具可以利用,只能靠自己思考和分析或者借鉴其他现成的工具的原理,就离不开这些看不起眼的“细节知识”;由于本人水平有限仅给各位提供参考,希望能够抛砖引玉,如果有什么可以讨论的问题可以在评论区留言或联系本人,下篇将带大家实战基于NestedScrolling机制自定义View实现饿了么商家详情页效果。