在许多场景中我们往往需要自己实现控件的滑动效果,如TextView的跑马灯效果,歌词控件的滑动。其实,许多系统控件也实现了滑动效果,如ScrollerView、RecyclerView等等。同时如果布局中有多个可滑动的控件,且要求各个可滑动控件要相互协同工作,这就要求滑动事件要在各个控件中间分发传递,NestedScrollingChild和NestedScrollingParent两个接口即可完成上述功能定义,系统控件CoordinationLayout就可以让其子控件完成协同工作。
1、滑动基本原理
系统中,最基础的控件View已经实现了滑动功能,滑动功能最终是通过调用scrollTo()方法来实现的:
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
函数中x和y表示滑动到的坐标,先将其赋值给mScrollX和mScrollY。接着调用invalidateParentCaches()方法将夫控件的mPrivateFlags的PFLAG_INVALIDATED位置位1,将强制父控件刷新。再调用onScrollChanged()方法改变一些状态值
并将滑动变化的信息通知给观察者。最后刷新控件,先判断是否唤醒ScrollBar,若是需要展示ScrollBar,则会在awakenScrollBars()方法中刷新控件,否则就是调用postInvalidateOnAnimation()在下一次动画到来事件刷新控件。先看一下awakenScrollBars():
protected boolean awakenScrollBars(int startDelay, boolean invalidate) {
final ScrollabilityCache scrollCache = mScrollCache;
if (scrollCache == null || !scrollCache.fadeScrollBars) {
return false;
}
if (scrollCache.scrollBar == null) {
scrollCache.scrollBar = new ScrollBarDrawable();
scrollCache.scrollBar.setState(getDrawableState());
scrollCache.scrollBar.setCallback(this);
}
if (isHorizontalScrollBarEnabled() || isVerticalScrollBarEnabled()) {
if (invalidate) {
// Invalidate to show the scrollbars
postInvalidateOnAnimation();
}
if (scrollCache.state == ScrollabilityCache.OFF) {
// FIXME: this is copied from WindowManagerService.
// We should get this value from the system when it
// is possible to do so.
final int KEY_REPEAT_FIRST_DELAY = 750;
startDelay = Math.max(KEY_REPEAT_FIRST_DELAY, startDelay);
}
// Tell mScrollCache when we should start fading. This may
// extend the fade start time if one was already scheduled
long fadeStartTime = AnimationUtils.currentAnimationTimeMillis() + startDelay;
scrollCache.fadeStartTime = fadeStartTime;
scrollCache.state = ScrollabilityCache.ON;
// Schedule our fader to run, unscheduling any old ones first
if (mAttachInfo != null) {
mAttachInfo.mHandler.removeCallbacks(scrollCache);
mAttachInfo.mHandler.postAtTime(scrollCache, fadeStartTime);
}
return true;
}
return false;
}
这里函数的形参invalidate为true,scrollCache中缓存了滑动条的UI信息,若在水平滑动条和竖直滑动条任意一个开启的条件下,调用postInvalidateOnAnimation()方法刷新滑动条及其他UI绘制,并且还要将scrollCache的fadeStartTime重新计算赋值,scrollCache实现了Runnable接口,再延迟执行该scrollCache的run()方法执行一个fade动画。再回到scrollTo()方法中,可以看到若调用awakenScrollBars()方法返回false,则会直接调用postInvalidateOnAnimation()方法:
public void postInvalidateOnAnimation() {
// We try only with the AttachInfo because there's no point in invalidating
// if we are not attached to our window
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
attachInfo.mViewRootImpl.dispatchInvalidateOnAnimation(this);
}
}
当View已经添加到Window上时,attachInfo不为空,调用其mViewRootImpl对象的dispatchInvalidateOnAnimation()方法:
//ViewRootImpl.java
public void dispatchInvalidateOnAnimation(View view) {
mInvalidateOnAnimationRunnable.addView(view);
}
//class InvalidateOnAnimationRunnable
public void addView(View view) {
synchronized (this) {
mViews.add(view);
postIfNeededLocked();
}
}
//class InvalidateOnAnimationRunnable
public void run() {
final int viewCount;
final int viewRectCount;
synchronized (this) {
mPosted = false;
viewCount = mViews.size();
if (viewCount != 0) {
mTempViews = mViews.toArray(mTempViews != null
? mTempViews : new View[viewCount]);
mViews.clear();
}
viewRectCount = mViewRects.size();
if (viewRectCount != 0) {
mTempViewRects = mViewRects.toArray(mTempViewRects != null
? mTempViewRects : new AttachInfo.InvalidateInfo[viewRectCount]);
mViewRects.clear();
}
}
for (int i = 0; i < viewCount; i++) {
mTempViews[i].invalidate();
mTempViews[i] = null;
}
for (int i = 0; i < viewRectCount; i++) {
final View.AttachInfo.InvalidateInfo info = mTempViewRects[i];
info.target.invalidate(info.left, info.top, info.right, info.bottom);
info.recycle();
}
}
先将需要刷新的View添加到InvalidateOnAnimationRunnable中的views列表中,再执行其run()方法刷新mView和mViewRects中所有的对象。接下来被
新的View会重新走measure()、layout()、draw()流程,这一过程可以参考老罗的文章:Android应用程序窗口(Activity)的测量(Measure)、布局(Layout)和绘制(Draw)过程分析。下面看一下重新绘制时是怎么控制滑动距离的。
public void draw(Canvas canvas) {
// Step 1, draw the background, if needed
if (!dirtyOpaque) {
drawBackground(canvas);
}
//...
int left = mScrollX + paddingLeft;
int right = left + mRight - mLeft - mPaddingRight - paddingLeft;
int top = mScrollY + getFadeTop(offsetRequired);
int bottom = top + getFadeHeight(offsetRequired);
if (offsetRequired) {
right += getRightPaddingOffset();
bottom += getBottomPaddingOffset();
}
//...
if (drawTop) {
matrix.setScale(1, fadeHeight * topFadeStrength);
matrix.postTranslate(left, top);
fade.setLocalMatrix(matrix);
p.setShader(fade);
canvas.drawRect(left, top, right, top + length, p);
}
}
private void drawBackground(Canvas canvas) {
final Drawable background = mBackground;
if (background == null) {
return;
}
setBackgroundBounds();
//...
final int scrollX = mScrollX;
final int scrollY = mScrollY;
if ((scrollX | scrollY) == 0) {
background.draw(canvas);
} else {
canvas.translate(scrollX, scrollY);
background.draw(canvas);
canvas.translate(-scrollX, -scrollY);
}
}
可以看到绘制时在计算位置时均是以scrollX和scrollY为基础,其四个边框位置由此得到,并画到canvas上。
2、嵌套滑动
系统为实现嵌套滑动定义了几个接口,看一下它门间的关系图:
2.1、外部控件处理嵌套滑动
NestedScrollingChild系列接口是用来给View及其子类实现的,以支持将嵌套滑动操作分发给父级控件ViewGroup处理。和NestedScrollingParent接口相似,NestedScrollingChild的相关操作也是依靠NestedScrollingChildHelper类处理的。看一下接口定义了重要方法:
- boolean startNestedScroll(@ScrollAxis int axes):在相应坐标轴上开启一个嵌套滑动操作,返回true代表协同的父级控件已经找到并且允许嵌套滑动。
- boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);:调度正在进行的嵌套滚动的一个步骤,返回true表示该事件被成功分发。
ScrollView是一个继承于View的常见滑动控件,看一下其滑动处理流程:
public boolean onTouchEvent(MotionEvent ev) {
case MotionEvent.ACTION_MOVE:
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
break;
}
//1.先调用dispatchNestedPreScroll()分发嵌套滑动事件,看外层控件是否处理嵌套滑动
final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
//...
if (mIsBeingDragged) {
// Scroll to follow the motion event
mLastMotionY = y - mScrollOffset[1];
final int oldY = mScrollY;
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
(overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
// Calling overScrollBy will call onOverScrolled, which
// calls onScrollChanged if applicable.
//2.调用ScrollView的overScrollBy()通知给外层控件嵌套滑动的事件仍在继续
if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
&& !hasNestedScrollingParent()) {
// Break our velocity if we hit a scroll barrier.
mVelocityTracker.clear();
}
final int scrolledDeltaY = mScrollY - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
//3.调用dispatchNestedScroll()分发嵌套滑动
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
} else if (canOverscroll) {
final int pulledToY = oldY + deltaY;
if (pulledToY < 0) {
mEdgeGlowTop.onPull((float) deltaY / getHeight(),
ev.getX(activePointerIndex) / getWidth());
if (!mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onRelease();
}
} else if (pulledToY > range) {
mEdgeGlowBottom.onPull((float) deltaY / getHeight(),
1.f - ev.getX(activePointerIndex) / getWidth());
if (!mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onRelease();
}
}
if (mEdgeGlowTop != null
&& (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
postInvalidateOnAnimation();
}
}
}
}
通过上面代码可以看到:dispatchNestedPreScroll()是嵌套滑动执行的时机,overScrollBy()是ScrollView执行滑动的时机,dispatchNestedScroll()是通知嵌套滑动的时机,ScrollView总是会执行滑动操作。下面看一下dispatchNestedPreScroll()在View中的实现:
//View.java
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;
//核心是调用类型为ViewParent的mNestedScrollingParent的onNestedPreScroll()方法
mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
这里ScrollView现将嵌套滑动事件传递给外层控件(如果有则mNestedScrollingParent不为null),ViewParent是一个接口,其部分方法和NestedScrollingParent接口定义相同,因此这里是想让ScrollView的外层控件来协调处理嵌套滑动,假如ScrollView的外层控件是CoordinationLayout的话,CoordinationLayout会将其交给其内部控件中定义了Behacior的控件来处理嵌套滑动。
2.2、内部控件处理嵌套滑动
NestedScrollingParent系列的接口是用来给ViewGroup的子类实现的,表明将外部控件的滑动事件交给内部控件来代理处理。NestedScrollingParent系列接口中定义的方法在NestedScrollingParentHelper中均有相应的版本,因此当NestedScrollingParent系列接口的实现类中对相应事件的处理均是代理到NestedScrollingParentHelper中处理。看一下接口中定义的几个重要方法:
- onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes):当内控件初始化一个嵌套滑动操作时,外控件的该方法得到调用,用来决定是否声明嵌套滚动操作和是否交给内部控件处理。
- onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes):对成功声明嵌套滚动操作做出反应,可以在这个时机做一些滑动初始化配置。
以上两个方法的调用时机可以在View中清楚看到:
//View.java
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {
ViewParent p = getParent();
View child = this;
while (p != null) {
try {
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获得一个滑动事件时,会回溯该View的所有外部控件,先调用外部控件的onStartNestedScroll()方法,若该方法返回true,则紧接着会调用外部控件的onNestedScrollAccepted()方法,并返回true结束该方法的调用。这里总结一下:实现NestedScrollingParent接口的ViewGroup的子控件子所有能够将嵌套滑动事件交给内部控件代理处理,是因为内部基础控件View具有将嵌套滑动事件分发给外部控件的功能,在事件传递过程中外部控件决定是否由内部控件来处理该嵌套滑动事件。处理嵌套滑动最具代表性的控件时CoordinationLayout,下面来看一下它的处理:
//CoordinationLayout.java
@Override
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
boolean handled = false;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
if (view.getVisibility() == View.GONE) {
// If it's GONE, don't dispatch
continue;
}
final LayoutParams lp = (LayoutParams) view.getLayoutParams();
final Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child,
target, axes, type);
handled |= accepted;
lp.setNestedScrollAccepted(type, accepted);
} else {
lp.setNestedScrollAccepted(type, false);
}
}
return handled;
}
通过上面分析,知道View会将嵌套滑动事件交给其外部控件先判断,假设这里的外部控件就是CoordinationLayout,在其onStartNestedScroll()方法中是去寻找所有内部控件中定义了的Behavior来处理,Behavior也定义了与NestedScrollingParent接口相应的方法,该Behavior的onStartNestedScroll()方法的返回值即是CoordinationLayout的onStartNestedScroll()方法的返回值。其他方法也是类似的处理方式。