AndroidX RecyclerView总结-滑动处理
文章目录
概述
RecyclerView作为一个灵活的在有限窗口显示大量数据集的视图组件,继承自ViewGroup,需要处理触摸事件产生时子View的滚动。同时RecyclerView实现了NestedScrollingChild接口,也支持嵌套在支持Nested的父容器中。
这里结合LinearLayoutManager,以垂直方向滑动为例,从源码浅析RecyclerView是如何进行滑动事件处理的。
源码探究
文中源码基于 ‘androidx.recyclerview:recyclerview:1.1.0’
RecyclerView中的处理
RecyclerView和常规事件处理方式一样,重写了onInterceptTouchEvent和onTouchEvent。RecyclerView也实现了NestedScrollingChild接口,在关键事件节点也会通知实现了NestedScrollingParent接口的父容器。
关于NestedScrollingChild和NestedScrollingParent的简要用法和说明,可参考《关于NestedScrollingParent2、NestedScrollingChild2接口》
onInterceptTouchEvent
[RecyclerView#onInterceptTouchEvent]
public boolean onInterceptTouchEvent(MotionEvent e) {
// 判断是否抑制布局滚动,可通过suppressLayout方法设置为true,当重新设置Adapter或托管item动画时不拦截。
if (mLayoutSuppressed) {
// When layout is suppressed, RV does not intercept the motion event.
// A child view e.g. a button may still get the click.
return false;
}
// 省略OnItemTouchListener部分,设置FastScroller或ItemTouchHelper时涉及 ···
if (mLayout == null) {
return false;
}
// 获取支持滚动的方向。以垂直排列的LinearLayoutManager为例,canScrollVertically为true。
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
mVelocityTracker.addMovement(e);
final int action = e.getActionMasked();
final int actionIndex = e.getActionIndex();
switch (action) {
case MotionEvent.ACTION_DOWN:
// mIgnoreMotionEventTillDown默认为false,调用suppressLayout抑制布局滚动时会将其置为true
if (mIgnoreMotionEventTillDown) {
mIgnoreMotionEventTillDown = false;
}
// 获取第一个触摸点的ID
mScrollPointerId = e.getPointerId(0);
// 保存DOWN时X、Y坐标,用于计算滑动偏移量
mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);
// 判断当前滑动状态是否是惯性滑动或其他非用户触摸滑动,mScrollState默认为SCROLL_STATE_IDLE
if (mScrollState == SCROLL_STATE_SETTLING) {
// 请求父布局不拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
// 更新滑动状态为SCROLL_STATE_DRAGGING
setScrollState(SCROLL_STATE_DRAGGING);
// 通知父布局停止滑动,类型为TYPE_NON_TOUCH
stopNestedScroll(TYPE_NON_TOUCH);
}
// Clear the nested offsets
mNestedOffsets[0] = mNestedOffsets[1] = 0;
// 获取当前支持的滑动方向
int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
if (canScrollHorizontally) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
}
if (canScrollVertically) {
nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
}
// 通知父布局滑动即将开始
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
break;
case MotionEvent.ACTION_POINTER_DOWN:
// 有新的触摸点,更新触摸点ID和初始X、Y坐标以新的为准
mScrollPointerId = e.getPointerId(actionIndex);
mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
break;
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);
// 判断当前滑动状态是否是SCROLL_STATE_DRAGGING
if (mScrollState != SCROLL_STATE_DRAGGING) {
// 计算滑动偏移量
final int dx = x - mInitialTouchX;
final int dy = y - mInitialTouchY;
// 标记是否有任一方向可以滑动
boolean startScroll = false;
// 判断是否构成滑动,mTouchSlop为最小滑动距离
if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
// 保存刚开始滑动时的坐标
mLastTouchX = x;
startScroll = true;
}
if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
// 保存刚开始滑动时的坐标
mLastTouchY = y;
startScroll = true;
}
if (startScroll) {
// 可以构成滑动,更新状态为SCROLL_STATE_DRAGGING,
setScrollState(SCROLL_STATE_DRAGGING);
}
}
} break;
case MotionEvent.ACTION_POINTER_UP: {
// 有一个触摸点离开,若该触摸点是mScrollPointerId,会更新触摸点ID和坐标为其他触摸点
onPointerUp(e);
} break;
case MotionEvent.ACTION_UP: {
mVelocityTracker.clear();
// 通知父容器停止滑动
stopNestedScroll(TYPE_TOUCH);
} break;
case MotionEvent.ACTION_CANCEL: {
// 停止滑动,重置状态,mScrollState置为SCROLL_STATE_IDLE
cancelScroll();
}
}
// 若当前mScrollState为SCROLL_STATE_DRAGGING,则返回true,表示拦截事件
return mScrollState == SCROLL_STATE_DRAGGING;
}
RecyclerView的onInterceptTouchEvent方法中并没有特殊逻辑,即常规的滑动距离判断拦截。
其中有涉及多点触摸相关说明可参考《ViewGroup事件分发总结-多点触摸事件拆分》。
滑动状态
RecyclerView的mScrollState成员表示当前滑动状态,状态有三种:
- SCROLL_STATE_IDLE:默认状态,当前没有滑动
- SCROLL_STATE_DRAGGING:用户触摸滑动
- SCROLL_STATE_SETTLING:非用户触摸滑动,例如fling惯性滑动、smoothScrollBy指定滑动
通过setScrollState方法更新滑动状态,并触发onScrollStateChanged回调。
onTouchEvent
[RecyclerView#onTouchEvent]
public boolean onTouchEvent(MotionEvent e) {
if (mLayoutSuppressed || mIgnoreMotionEventTillDown) {
return false;
}
// 派发OnItemTouchListener,设置FastScroller或ItemTouchHelper时涉及
if (dispatchToOnItemTouchListeners(e)) {
cancelScroll();
return true;
}
if (mLayout == null) {
return false;
}
// 获取可滑动方向
final boolean canScrollHorizontally = mLayout.canScrollHorizontally();
final boolean canScrollVertically = mLayout.canScrollVertically();
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
boolean eventAddedToVelocityTracker = false;
final int action = e.getActionMasked();
final int actionIndex = e.getActionIndex();
if (action == MotionEvent.ACTION_DOWN) {
mNestedOffsets[0] = mNestedOffsets[1] = 0;
}
// vtev和mNestedOffsets仅用于加速度追踪,可忽略
final MotionEvent vtev = MotionEvent.obtain(e);
vtev.offsetLocation(mNestedOffsets[0], mNestedOffsets[1]);
switch (action) {
case MotionEvent.ACTION_DOWN: {
// 获取触摸点ID和初始坐标
mScrollPointerId = e.getPointerId(0);
// mInitialTouchX记录DOWN时坐标,mLastTouchX记录最后一次触摸坐标
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;
}
// 通知父容器即将开始滑动
startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
} break;
case MotionEvent.ACTION_POINTER_DOWN: {
// 更新触摸点ID和初始坐标
mScrollPointerId = e.getPointerId(actionIndex);
mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
} break;
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;
if (mScrollState != SCROLL_STATE_DRAGGING) {
// 若是刚开始滑动,则根据mTouchSlop微调偏移量
boolean startScroll = false;
if (canScrollHorizontally) {
if (dx > 0) {
dx = Math.max(0, dx - mTouchSlop);
} else {
dx = Math.min(0, dx + mTouchSlop);
}
if (dx != 0) {
startScroll = true;
}
}
if (canScrollVertically) {
if (dy > 0) {
dy = Math.max(0, dy - mTouchSlop);
} else {
dy = Math.min(0, dy + mTouchSlop);
}
if (dy != 0) {
startScroll = true;
}
}
if (startScroll) {
// 可以进行滑动,则更新状态为SCROLL_STATE_DRAGGING
setScrollState(SCROLL_STATE_DRAGGING);
}
}
// 判断当前是否可以滑动
if (mScrollState == SCROLL_STATE_DRAGGING) {
// mReusableIntPair用作对象复用,可避免频繁创建数组
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 通知父容器优先处理滑动,利用mReusableIntPair保存父容器消耗的滑动距离
// mScrollOffset保存RecyclerView左上点相较于父容器的偏移坐标
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH
)) {
// 滑动偏移量减去父布局消耗的距离
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
// Scroll has initiated, prevent parents from intercepting
// 请求父容器不拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
}
// 更新最后一次触摸坐标(这里不直接用x、y,是担心父布局处理滑动时可能造成RecyclerView偏移)
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
// 进一步执行滑动逻辑,若有进行滑动则会返回true
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e)) {
// 请求父容器不拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
}
// 预取ViewHolder相关,根据滑动距离判断是否需要预取
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;
case MotionEvent.ACTION_POINTER_UP: {
// 更新触摸点ID和坐标
onPointerUp(e);
} break;
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))) {
// 若不需要fling,则更新状态为SCROLL_STATE_IDLE
setScrollState(SCROLL_STATE_IDLE);
}
resetScroll();
} break;
case MotionEvent.ACTION_CANCEL: {
cancelScroll();
} break;
}
if (!eventAddedToVelocityTracker) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
// 进了switch语句并且没出错的话,就都返回true
return true;
}
可以看到onTouchEvent中的逻辑和onInterceptTouchEvent大同小异,真正处理滑动在scrollByInternal方法中。在scrollByInternal中先判断延迟的适配器更新操作,然后调用scrollStep方法再进一步处理滑动,之后处理NestedScroll派发、过渡滑动效果、onScrollChanged回调、滚动条等,最后返回是否产生滑动距离消耗。
scrollStep
[RecyclerView#scrollStep]
void scrollStep(int dx, int dy, @Nullable int[] consumed) {
// ···
int consumedX = 0;
int consumedY = 0;
if (dx != 0) {
// 若水平滑动量不为0,调用scrollHorizontallyBy方法
consumedX = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
}
if (dy != 0) {
// 若垂直滑动量不为0,调用scrollVerticallyBy方法
consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
}
// ···
if (consumed != null) {
// 保存LayoutManager进行滑动消耗的量
consumed[0] = consumedX;
consumed[1] = consumedY;
}
}
该方法中判断水平和垂直滑动偏移量若不为0,则调用LayoutManager的对应的scrollHorizontallyBy、scrollVerticallyBy方法,默认返回0,LayoutManager的具体子类重写对应方法实现自己的滑动逻辑。
这里以LinearLayoutManager为例,看看它的垂直滑动相关的处理。
LinearLayoutManager中的处理
RecyclerView将子View的滑动逻辑交由LayoutManager来处理,在LinearLayoutManager的scrollVerticallyBy方法中又调用了scrollBy方法(scrollHorizontallyBy也会调用该方法,两个方法调用前会判断是否是对应的排列方向),进入该方法看看是如何处理垂直滑动的。
[LinearLayoutManager#scrollBy]
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() == 0 || delta == 0) {
return 0;
}
ensureLayoutState();
// 标记可以回收ViewHolder
mLayoutState.mRecycle = true;
// 根据滑动偏移量判断布局方向,delta>0表示手指往上划,对应LAYOUT_END
final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDelta = Math.abs(delta);
// 更新mLayoutState中的成员的值
updateLayoutState(layoutDirection, absDelta, true, state);
// 计算布局填充
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 = absDelta > consumed ? layoutDirection * consumed : delta;
// 子View滑动偏移
mOrientationHelper.offsetChildren(-scrolled);
if (DEBUG) {
Log.d(TAG, "scroll req: " + delta + " scrolled: " + scrolled);
}
mLayoutState.mLastScrollDelta = scrolled;
return scrolled;
}
该方法中有三个比较关键的步骤:1)updateLayoutState;2)fill;3)offsetChildren。
依次看看方法。
updateLayoutState
[LinearLayoutManager#updateLayoutState]
private void updateLayoutState(int layoutDirection, int requiredSpace,
boolean canUseExistingSpace, RecyclerView.State state) {
// ···
mLayoutState.mLayoutDirection = layoutDirection;
// ···
int scrollingOffset;
// 这里以LAYOUT_END(手指上划)为例
if (layoutToEnd) {
mLayoutState.mExtraFillSpace += mOrientationHelper.getEndPadding();
// get the first child in the direction we are going
// 找到最底下的那个child
final View child = getChildClosestToEnd();
// the direction in which we are traversing children
mLayoutState.mItemDirection = mShouldReverseLayout ? LayoutState.ITEM_DIRECTION_HEAD
: LayoutState.ITEM_DIRECTION_TAIL;
mLayoutState.mCurrentPosition = getPosition(child) + mLayoutState.mItemDirection;
// 获取最底下那个child的底边界
mLayoutState.mOffset = mOrientationHelper.getDecoratedEnd(child);
// calculate how much we can scroll without adding new children (independent of layout)
// 计算child底边界和RecyclerView内容底边界的距离,若滑动距离在这个范围内,不需要获取新的ViewHolder
scrollingOffset = mOrientationHelper.getDecoratedEnd(child)
- mOrientationHelper.getEndAfterPadding();
} else {
// ···
}
// requiredSpace即滑动距离绝对值,mAvailable保存滑动空间
mLayoutState.mAvailable = requiredSpace;
// 此时canUseExistingSpace为true
if (canUseExistingSpace) {
// mAvailable变为滑动距离和无新增范围的差值
mLayoutState.mAvailable -= scrollingOffset;
}
// mScrollingOffset保存无新增滚动范围
mLayoutState.mScrollingOffset = scrollingOffset;
}
该方法中计算了一些滑动范围相关的值。
其中mScrollingOffset表示无新增ViewHolder的滑动范围,即当前最接近底部的child的底边界-RecyclerView内容底边界的值,当滑动距离不大于这个范围时,底部不用新添加一个item。
mAvailable表示滑动距离和无新增范围的差值,若该值大于0则说明底部可能需要补充item。
图例为示:
1.手指上划幅度较小⬆️
2.手指上划幅度较大⬆️
fill
fill方法在初始布局时也会被调用,通过该方法进行布局填充。可参考《AndroidX RecyclerView总结-测量布局》。
回到scrollBy方法,在updateLayoutState中计算了LayoutState中的值后,便传入fill方法进行布局填充:
[LinearLayoutManager#fill]
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
// 当滑动时mScrollingOffset会被赋值不等于SCROLLING_OFFSET_NaN
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
// mScrollingOffset又被调整为触摸滑动距离
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
// 根据滑动距离进行回收(以往上划为例,回收的是顶部将要滑出视图的ViewHolder)
recycleByLayoutState(recycler, layoutState);
}
// 当RecyclerView已经填满时,通常此时mExtraFillSpace=0,remainingSpace即为mAvailable
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
// 当remainingSpace>0,意味着底部需要补充ViewHolder,且适配器数据集还有item,则不断循环
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
// 获取一个ViewHolder进行布局
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.endSection();
}
if (layoutChunkResult.mFinished) {
break;
}
layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
/**
* Consume the available space if:
* * layoutChunk did not request to be ignored
* * OR we are laying out scrap children
* * OR we are not doing pre-layout
*/
if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null
|| !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
// we keep a separate remaining space because mAvailable is important for recycling
remainingSpace -= layoutChunkResult.mConsumed;
}
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
// 回收将滑出视图的ViewHolder
recycleByLayoutState(recycler, layoutState);
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;
}
该方法即进行ViewHolder的布局填充,其中会判断是否是滚动情况,并且根据LayoutState事先计算的滑动偏移相关的值判断是否需要回收item和补充item。
关于ViewHolder的回收和复用,可参考《AndroidX RecyclerView总结-Recycler》。
recycleByLayoutState方法中,会根据滑动距离计算滑动到最后,位置仍在RecyclerView中的最接近边界的child,然后回收该child之上或之下的所有ViewHolder。
图示为例:
offsetChildren
回到scrollBy方法中,当完成fill和产生滑动偏移消耗后,会通过mOrientationHelper.offsetChildren并传入滑动消耗量,进行child的整体偏移。
mOrientationHelper.offsetChildren(-scrolled);
mOrientationHelper通过OrientationHelper的静态方法createOrientationHelper创建,根据方向创建对应的不同实现。
在offsetChildren方法中回调LayoutManager的offsetChildrenVertical方法,其中又调用RecyclerView的offsetChildrenVertical方法:
[RecyclerView#offsetChildrenVertical]
public void offsetChildrenVertical(@Px int dy) {
final int childCount = mChildHelper.getChildCount();
for (int i = 0; i < childCount; i++) {
// 依次调用child的offsetTopAndBottom进行整体偏移
mChildHelper.getChildAt(i).offsetTopAndBottom(dy);
}
}
offsetChildren方法最终将滑动偏移量传入RecyclerView的offsetChildrenVertical方法,在其中依次对child进行整体偏移。offsetTopAndBottom方法会根据指定的像素数沿垂直方向整体移动View。
总结
RecyclerView自身的滑动逻辑就是判断方向和滑动距离进行事件拦截和NestedScroll分发,核心逻辑在LayoutManager的具体子类中,LayoutManager子类须重写canScrollHorizontally、canScrollVertically、scrollHorizontallyBy、scrollVerticallyBy完成自身布局的特定逻辑。
在LinearLayoutManager中,会根据滑动方向和距离,对布局两端的ViewHolder进行回收和补充。最后再回调RecyclerView的offsetChildrenVertical方法,对添加的child视图进行整体偏移。
下一篇: 算法-粒子群算法-Matlab实现