文章目錄
概述
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視圖進行整體偏移。