RecyclerView布局之外,最常用的功能应该就是滑动。RecyclerView的事件处理依然是常规的onTouchEvent根据触控事件响应,特别的是RecyclerView采用了嵌套滑动机制,会把滑动事件通知给支持嵌套滑动的父view先做决定,以实现诸如toolBar上划隐藏的效果,还有就是涉及到缓存策略,不过相比布局,滑动的缓存策略要简单的多,仅仅是把划出屏幕的viewHolder存入mCachedViews。
public boolean onTouchEvent(MotionEvent e) {
//如果使用了ItemTouchHelper,先让它处理
if (dispatchToOnItemTouchListeners(e)) {
cancelScroll();
return true;
}
//LayoutManager是否支持水平或竖直滑动
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
boolean eventAddedToVelocityTracker = false;
//Mask和是事件类型,Index是触控点信息
//Index和PointerId可以互相获取
final int action = e.getActionMasked();
final int actionIndex = e.getActionIndex();
//down事件重置偏移距离为0
if (action == MotionEvent.ACTION_DOWN) {
mNestedOffsets[0] = mNestedOffsets[1] = 0;
}
//深复制一份MotionEvent
final MotionEvent vtev = MotionEvent.obtain(e);
vtev.offsetLocation(mNestedOffsets[0], mNestedOffsets[1]);
switch (action) {
.......
}
if (!eventAddedToVelocityTracker) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}
onTouchEvent比较常规,除了一般的getActionMasked获取事件,多了getActionIndex,这个是处理多指滑动的,根据这个信息可以区分不同的手指。下面就一个一个看具体的case:
(1)ACTION_DOWN
case MotionEvent.ACTION_DOWN: {
//一个Pointer就是一个触摸点
mScrollPointerId = e.getPointerId(0);
mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
//无滑动轴、水平滑动轴、竖直滑动轴
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontally) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertically) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
//判断是否有支持嵌套滑动的父view,不关心结果,只是将NestedScrollingChildHelper
//的mNestedScrollingParentTouch设为支持嵌套滑动的父view
//并调用NestedScrollingParent的onNestedScrollAccepted让父view做一些初始配置
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
} break;
down事件首先获取PointerId,因为down是开始一系列事件,所以一定是第一只手指。
然后记录下mLastTouchX/Y,这是很常规的操作。
最后用nestedScrollAxis记录当前的滑动轴,并调用startNestedScroll通知支持嵌套滑动的父view做初始配置。滑动轴信息RecyclerView自身并没有使用,startNestedScroll之后就和RecyclerView无关了,不去深究。
(2)ACTION_POINTER_DOWN
case MotionEvent.ACTION_POINTER_DOWN: {
//又有新的手指按下了,立即更新位置,不响应老手指的动作,一切以新手指为准
mScrollPointerId = e.getPointerId(actionIndex);
mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
} break;
pointer_down事件代表又有手指按下,多指滑动开始了,此时需要立即更新PointerId和mLastTouchX/Y,一切事件和坐标以新手指为准,之前的手指再怎么滑动RecyclerView都不再响应。
(3)ACTION_MOVE
case MotionEvent.ACTION_MOVE: {
//可能有多根手指,使用最近按下的那根
final int index = e.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e(TAG, "Error processing scroll; pointer index for id "
+ mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}
final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
//初次收到move事件
if (mScrollState != SCROLL_STATE_DRAGGING) {
......
//判断滑动距离是否大于mTouchSlop,是startScroll=true
//一个微笑的体验上的处理:
//如果滑动距离始终小于mTouchSlop就不开始滑动,
//但是如果开始滑动后中途变慢,是不判断滑动距离直接滑动的
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
//真正处理滑动的地方
if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
//mReusableIntPair是父view消耗的滑动距离,mScrollOffsetRecyclerView需要移动的位置
//比如嵌套滑动使toolbar隐藏,那么mScrollOffsetRecyclerView就是负数
//会调用NestedScrollingParent的onNestedPreScroll询问父view需要消耗多少距离
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH
)) {
//dx、dy减去父view消耗的
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
//更新偏移距离
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
// Scroll has initiated, prevent parents from intercepting
getParent().requestDisallowInterceptTouchEvent(true);
}
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
//处理自身滑动
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
//预取一个holder放进mCachedViews,新版本优化性能用的
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;
move事件是处理滑动的核心,代码很多,但是结构简单:先计算dx/dy,再dispatchNestedPreScroll询问支持嵌套滑动的父view需要消耗多少滑动距离,最后调用scrollByInternal处理自身滑动(滑动核心方法)。
除此之外,有一个关于mScrollState是不是SCROLL_STATE_DRAGGING的判断,这一步的目的在于实现一种效果:开始滑动时如果滑动距离小于mTouchSlop则不作响应,但是在滑动过程中某时刻滑动距离小于mTouchSlop仍然响应,mScrollState就是为了区分这两种状态以做不同处理。
(4)ACTION_POINTER_UP
case MotionEvent.ACTION_POINTER_UP: {
onPointerUp(e);
} break;
当有某一只手指抬起但是还有其他手指在屏幕上时,换一只手指,并重新设置坐标
(5)ACTION_UP
case MotionEvent.ACTION_UP: {
mVelocityTracker.addMovement(vtev);
eventAddedToVelocityTracker = true;
mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
final float xvel = canScrollHorizontally
? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
final float yvel = canScrollVertically
? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
setScrollState(SCROLL_STATE_IDLE);
}
resetScroll();
} break;
up表示所有手指抬起,滑动事件结束,此时做一个处理,如果抬起时有速度,就保持惯性再滑动一段,最后通知支持嵌套滑动的view我滑完了。
(6)ACTION_CANCEL
case MotionEvent.ACTION_CANCEL: {
cancelScroll();
} break;
当事件中途被父view消费时会产生cancel事件,例如RecyclerView接收到DOWN事件,但是后续被父view拦截
RecyclerView就会收到CANCEL事件,然后设置mScrollState为SCROLL_STATE_IDLE。
到这所有的case就分析完了,下面继续看RecyclerView在处理自身滑动时究竟做了什么。
boolean scrollByInternal(int x, int y, MotionEvent ev) {
int unconsumedX = 0, unconsumedY = 0;
int consumedX = 0, consumedY = 0;
//Adapter数据改变,如果设定了固定大小,对于Adapter的notify系列方法
//(notifyDataSetChanged除外),会延迟更新UI,如果这期间发生滑动,
//会先请求布局更新UI,再进行滑动
consumePendingUpdateOperations();
if (mAdapter != null) {
//由LayoutManager处理具体滑动
scrollStep(x, y, mScrollStepConsumed);
consumedX = mScrollStepConsumed[0];
consumedY = mScrollStepConsumed[1];
unconsumedX = x - consumedX;
unconsumedY = y - consumedY;
}
if (!mItemDecorations.isEmpty()) {
invalidate();
}
//由支持嵌套滑动的父view处理剩余的滑动
if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
TYPE_TOUCH)) {
// Update the last touch co-ords, taking any scroll offset into account
mLastTouchX -= mScrollOffset[0];
mLastTouchY -= mScrollOffset[1];
if (ev != null) {
ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
}
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
} else if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
}
considerReleasingGlowsOnScroll(x, y);
}
if (consumedX != 0 || consumedY != 0) {
dispatchOnScrolled(consumedX, consumedY);
}
if (!awakenScrollBars()) {
invalidate();
}
return consumedX != 0 || consumedY != 0;
}
可以看到自身滑动完毕后仍然是采用嵌套滑动机制通知父view,不过本文没有对嵌套滑动机制做系统梳理,只是着重RecyclerView自身的事情:
(1)最开始调用了consumePendingUpdateOperations();起初我觉得奇怪,Adapter更新数据、布局流程、滑动事件都发生在主线程,按说不会发生滑动期间数据改变的事情。但是是有例外情况的,如果使用setHasFixedSize(boolean hasFixedSize)方法手动保证数据更新不会改变RecyclerView的大小,那么对于Adapter的notify系列方法(notifyDataSetChanged除外),会延迟更新UI。此处延迟不是开子线程,是像主线程post一个任务,有空闲的时候再处理。
(2)调用scrollStep(x, y, mScrollStepConsumed)交由LayoutManager处理滑动。
(3)调用dispatchNestedScroll,由支持嵌套滑动的父view处理剩余的滑动。
LinearLayoutManager最终处理滑动的函数是scrollBy:
int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() == 0 || dy == 0) {
return 0;
}
mLayoutState.mRecycle = true;
ensureLayoutState();
final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDy = Math.abs(dy);
//计算可以滑动的距离
updateLayoutState(layoutDirection, absDy, true, state);
//先调用fill把滑进来的子view布局进来,并且回收滑出去的子view
final int consumed = mLayoutState.mScrollingOffset
+ fill(recycler, mLayoutState, state, false);
if (consumed < 0) {
if (DEBUG) {
Log.d(TAG, "Don't have any more elements to scroll");
}
return 0;
}
final int scrolled = absDy > consumed ? layoutDirection * consumed : dy;
//给所有的子view加一个偏移量,即按照滑动距离改变子view的位置
mOrientationHelper.offsetChildren(-scrolled);
if (DEBUG) {
Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled);
}
mLayoutState.mLastScrollDelta = scrolled;
return scrolled;
}
scrollBy处理滑动的逻辑是调用fill函数,如果滑动距离能容下一个子view就把它layout出来,如果一个view已经完全被划出屏幕,就回收它进入mCachedViews,最后调用mOrientationHelper.offsetChildren(-scrolled);给所有子view加一个偏移量,这就解释了为什么一个子view可以显示一半的问题。值得注意的是滑动事件并不会请求重新布局、重新onLayoutChildren,对布局的更新是通过fill和偏移完成的,最后调用invalidate()请求重新绘制。
本文没有关注嵌套滑动,实际上嵌套滑动正是RecyclerView的一个重要特性,但是笔者还是想先搞清楚RecyclerView究竟是怎么滑动的这一基础问题,嵌套滑动关注的是和其他view如何分配滑动距离和如何解决滑动冲突的问题,那是另外一回事了。