Android NestedScrolling 实战
原文出处:http://www.race604.com/android-nested-scrolling/
从 Android 5.0 Lollipop 开始提供一套 API 来支持嵌入的滑动效果。同样在最新的 Support V4 包中也提供了前向的兼容。有了嵌入滑动机制,就能实现很多很复杂的滑动效果。在 Android Design Support 库中非常总要的 CoordinatorLayout 组件就是使用了这套机制,实现了 Toolbar 的收起和展开功能,如下图所示:
NestedScrolling 提供了一套父 View 和子 View 滑动交互机制。要完成这样的交互,父 View 需要实现 NestedScrollingParent 接口,而子 View 需要实现 NestedScrollingChild 接口。
实现 NestedScrollingChild
首先来说 NestedScrollingChild。如果你有一个可以滑动的 View,需要被用来作为嵌入滑动的子 View,就必须实现本接口。在此 View 中,包含一个 NestedScrollingChildHelper 辅助类。NestedScrollingChild 接口的实现,基本上就是调用本 Helper 类的对应的函数即可,因为 Helper 类中已经实现好了 Child 和 Parent 交互的逻辑。原来的 View 的处理 Touch 事件,并实现滑动的逻辑大体上不需要改变。
需要做的就是,如果要准备开始滑动了,需要告诉 Parent,你要准备进入滑动状态了,调用 startNestedScroll()。你在滑动之前,先问一下你的 Parent 是否需要滑动,也就是调用dispatchNestedPreScroll()。如果父类滑动了一定距离,你需要重新计算一下父类滑动后剩下给你的滑动距离余量。然后,你自己进行余下的滑动。最后,如果滑动距离还有剩余,你就再问一下,Parent 是否需要在继续滑动你剩下的距离,也就是调用 dispatchNestedScroll()。
以上是一些基本原理,有了上面的基本思路,可以参考这篇文章,这里面有原理的详细解析。如果还是不清楚,这里有对应的代码可以参考。
实现 NestedScrollingParent
作为一个可以嵌入 NestedScrollingChild 的父 View,需要实现 NestedScrollingParent,这个接口方法和 NestedScrollingChild 大致有一一对应的关系。同样,也有一个 NestedScrollingParentHelper辅助类来默默的帮助你实现和 Child 交互的逻辑。滑动动作是 Child 主动发起,Parent 就收滑动回调并作出响应。
从上面的 Child 分析可知,滑动开始的调用 startNestedScroll(),Parent 收到 onStartNestedScroll() 回调,决定是否需要配合 Child 一起进行处理滑动,如果需要配合,还会回调onNestedScrollAccepted()。
每次滑动前,Child 先询问 Parent 是否需要滑动,即 dispatchNestedPreScroll(),这就回调到 Parent 的 onNestedPreScroll(),Parent 可以在这个回调中“劫持”掉 Child 的滑动,也就是先于 Child 滑动。
Child 滑动以后,会调用 onNestedScroll(),回调到 Parent 的 onNestedScroll(),这里就是 Child 滑动后,剩下的给 Parent 处理,也就是 后于 Child 滑动。
最后,滑动结束,调用 onStopNestedScroll() 表示本次处理结束。
其实,除了上面的 Scroll 相关的调用和回调,还有 Fling 相关的调用和回调,处理逻辑基本一致。
实战
有了这一套官方的嵌套滑动的解决方案,打算把我的 FlyRefresh 的滑动和下来部分用 NestedScrolling 来实现。我在这篇博客中讲了,之前是通过在 PullHeaderLayout 的 dispatchTouchEvent() 中小心处理 Touch 事件来实现的。现在回想起来,这种方法相对复杂,需要清楚知道 Parent 和 Child 的滑动状态,这就导致了,只能支持有限的 Child 类型,例如当时只支持 ListView 和 RecyclerView,为了支持更多的类型,还定义了一个 IScrollHandler 接口来支持。
让 FlyRefresh 实现 NestedScrollingParent,就可以支持所有的 NestedScrollingChild 作为 FlyRefreshLayout 的子 View。另外,因为 CoordinatorLayout 是如此的重要,大部分的 App 都需要使用它作为顶层的 Layout,为了让 FlyRefreshLayout 能够在 CoordinatorLayout 也能使用,所以我还打算同时实现 NestedScrollingChild 接口。关键实现代码如下:
public class PullHeaderLayout extends ViewGroup implements NestedScrollingParent, NestedScrollingChild {
private final int\[\] mScrollOffset = new int\[2\];
private final int\[\] mScrollConsumed = new int\[2\];
private final NestedScrollingParentHelper mParentHelper;
private final NestedScrollingChildHelper mChildHelper;
...
// NestedScrollingChild
@Override
public void setNestedScrollingEnabled(boolean enabled) {
mChildHelper.setNestedScrollingEnabled(enabled);
}
@Override
public boolean isNestedScrollingEnabled() {
return mChildHelper.isNestedScrollingEnabled();
}
@Override
public boolean startNestedScroll(int axes) {
return mChildHelper.startNestedScroll(axes);
}
@Override
public void stopNestedScroll() {
mChildHelper.stopNestedScroll();
}
@Override
public boolean hasNestedScrollingParent() {
return mChildHelper.hasNestedScrollingParent();
}
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed, int\[\] offsetInWindow) {
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
offsetInWindow);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int\[\] consumed, int\[\] offsetInWindow) {
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}
@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
}
@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
}
// NestedScrollingParent
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
}
@Override
public void onStopNestedScroll(View target) {
stopNestedScroll();
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed) {
final int myConsumed = moveBy(dyUnconsumed);
final int myUnconsumed = dyUnconsumed - myConsumed;
dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null);
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int\[\] consumed) {
if (dy > 0 && mHeaderController.canScrollUp()) {
final int delta = moveBy(dy);
consumed\[0\] = 0;
consumed\[1\] = delta;
//dispatchNestedScroll(0, myConsumed, 0, consumed\[1\], null);
}
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
if (!consumed) {
flingWithNestedDispatch((int) velocityY);
return true;
}
return false;
}
private boolean flingWithNestedDispatch(int velocityY) {
final boolean canFling = (mHeaderController.canScrollUp() && velocityY > 0) ||
(mHeaderController.canScrollDown() && velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
if (canFling) {
fling(velocityY);
}
}
return canFling;
}
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
return flingWithNestedDispatch((int) velocityY);
}
@Override
public int getNestedScrollAxes() {
return mParentHelper.getNestedScrollAxes();
}
// Touch event hanlder
@Override
public boolean onTouchEvent(MotionEvent ev) {
MotionEvent vtev = MotionEvent.obtain(ev);
final int actionMasked = MotionEventCompat.getActionMasked(ev);
if (actionMasked == MotionEvent.ACTION_DOWN) {
mNestedYOffset = 0;
}
vtev.offsetLocation(0, mNestedYOffset);
switch (actionMasked) {
...
case MotionEvent.ACTION_MOVE:
...
final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
int deltaY = mLastMotionY - y;
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed\[1\];
vtev.offsetLocation(0, mScrollOffset\[1\]);
mNestedYOffset += mScrollOffset\[1\];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
// Scroll to follow the motion event
mLastMotionY = y - mScrollOffset\[1\];
final int scrolledDeltaY = moveBy(deltaY);
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
mLastMotionY -= mScrollOffset\[1\];
vtev.offsetLocation(0, mScrollOffset\[1\]);
mNestedYOffset += mScrollOffset\[1\];
}
}
break;
...
}
...
return true;
}
...
}
完整的修改,可以看这个 commit。整个修改下来,代码减少了不少,而且更加整洁了。
总结
总体来说,NestedScroll 初看起来有些让人费解,但是真的理解以后,就发现这种设计的优秀之处。把滑动整体封装起来,通过 Helper 来实现 Child 和 Parent 之间的连接和交互。通过接口来回调,实现了 Child 和 Parent 的逻辑独立。
Android 5.0的大部分可以滑动的控件都支持了 NestScrolling 接口,最新的 Support V4 中也一样,相信以后越来越多的第三方库都会支持,到时候各种控件的嵌套滑动就能无缝集成了。