onTouchEvent
滑动事件的处理肯定在 onTouchEvent() 中,直接看 move 事件
// 注意这里:mLastTouchX/Y 是 down 时记录下的位置,并不会随着每一次 move 动态更新
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
// 这里是判断是否是拖动的逻辑,所有 touch 事件的统一处理步骤
// 自定义 view 时可以抄抄
if (mScrollState != SCROLL_STATE_DRAGGING) {
boolean startScroll = false;
if (canScrollHorizontally) {
// 省略水平拖动的判断
}
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) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}
if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH
)) {
// 首先处理嵌套滑动,忽略
}
// 记录下本次滑动距离
mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];
// 处理自己的滑动
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e, TYPE_TOUCH)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
}
scrollByInternal() 最终到 LayoutManager#scrollVerticallyBy() 中(以垂直滑动,LinearLayoutManager 为例),后者又调用到自己的 scrollBy()
LM#scrollBy
精简一下代码,一共就几步
第一步:更新 LayoutState,该对象主要用于辅助记录各种滑动信息。State 就是状态,MVI 模式中熟悉的概念
第二步:调用 fille() 方法。该方法见注释
第三步:调用 offsetChildren() 给各个子 View 移动位置。这一部分没啥好说的,只要记住 view 的移动在所有 view 都 add 到 RV 以后再执行。因此,在填充新 view 阶段,旧 view 位置不会发生变化
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() == 0 || delta == 0) {
return 0;
}
// 保证 mLayoutState 一定有值
ensureLayoutState();
mLayoutState.mRecycle = true;
final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDelta = Math.abs(delta);
// 【代码一】,更新 mLayoutState
updateLayoutState(layoutDirection, absDelta, true, state);
// 调用 fill() 方法。该方法内部会调用 layoutChunk() 从缓存中读取 view
// 然后将对 view 进行测量、布局,再 addView() 到 RV 中
// 【代码二】
final int consumed = mLayoutState.mScrollingOffset
+ fill(recycler, mLayoutState, state, false);
final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta;
// 【代码三】通过 offsetChildren 移动各个子 View
mOrientationHelper.offsetChildren(-scrolled);
return scrolled;
}
updateLayoutState
这个方法主要就以下几行,关键是 scrollingOffset 的理解。以垂直为例,RV 显示后,最后一个 item 很大可能只显示出一部分,scrollingOffset 就是它未显示部分的高度。再换句话解释:它表示在不添加新 item 前提下,rv 最大能滑动距离,一旦超过这个距离,rv 就没有内容可显示了。
// 省略关于 scrollingOffset 的计算
mLayoutState.mAvailable = requiredSpace;
mLayoutState.mScrollingOffset = scrollingOffset;
fill
该方法大部分内容属于一个大循环
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
// 调用 Trace#beginSection(),所以使用 systrace 时可以看到相应的信息
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
// 它大体就是从缓存中读取下一个 view,然后测量、布局、addView() 到 rv 中
// 它内部会将新 view 的高度(以垂直为例)记录到 layoutChunkResult#mConsumed 中
// 整体逻辑如上,不详细分析
layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.endSection();
}
// 省略一部分加载的 view 不消费空间的逻辑
// 比如预布局阶段,某个要被 remove 的 view 并不消费空间
// 这就使用 rv 有多余的空间去加载一些未出现在界面上的 view,从而得知将要显示的 view 的起始位置
// 最终才好执行动画
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// 更新 mScrollingOffset
// 【【【【【【【【【【【【 重要 】】】】】】】】】】】】
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
// 对 view 进行回收
// 该方法最终到 recycleViewsFromStart 或 recycleViewsFromEnd
recycleByLayoutState(recycler, layoutState);
}
}
这里挑 recycleViewsFromStart() 分析,最核心的就是下面一个 for 循环
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// limit 值就是 layoutState.mScrollingOffset
// !!!!!!!!!!!!!!!!! limit 逻辑 !!!!!!!!!!!!!!!
// 上面我们知道,每添加一个新的 item 时,limit 的值会增加新 item 的高度
// 所以这个 limit 是 rv 可滑动的最大距离(超出该距离后,就没有 item 可供显示了)
// 而所有 view 的滑动是在所有 item 添加完成后统一处理。因此,在添加新 item 时,旧 item 的位置不变
// 但一旦开始滑动,必然有些 item 会被滑出屏幕,要被回收。如何判断哪些 view 要被回收呢?
// 很简单:rv 滑动 Limit 时所有被滑动到屏幕外面的 item 都应该被回收。
// 哪些会被滑动到屏幕外呢?肯定是现在距离 rv 顶部不足 limit 高度的 item。
// 这就是第一个判断的意义。
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
// stop here
// 回收 item
recycleChildren(recycler, 0, i);
return;
}
}
// recycleChildren() 最终执行到下面方法
public void removeAndRecycleViewAt(int index, @NonNull Recycler recycler) {
final View view = getChildAt(index);
removeViewAt(index); // 从 rv 中 remove 掉
recycler.recycleView(view); // 进行回收
}
removeViewAt() 也会等下一个同步信号到来时才会将 view 从屏幕移除,但那个时候有新的 item 显示到屏幕上。所以在添加新元素时直接移除旧的 item 不会造成屏幕上旧 item 位置出现空白情况。
getScrollY
纵观整个滑动处理流程,可以发现并不是 RV 调用 scrollBy() 自已进行滑动,而是根据滑动距离动态加载下一个 item,然后通过 offsetTopAndBottom 调整里面各 item 的位置
也就是说,整个滑动过程中 RV 的位置并没有变化,变化的是各 item 的位置。所以 getScrollY() 返回的是 0
总结
- 在滑动时,
RV 会根据滑动的距离不断添加 item,每添加一个 item 后会回收要被滑动屏幕外的 item。所以滑动时边添加边回收 - 滑动过程中,会动态调整 limit 线。它表示 rv 最大可滑动距离,超过该距离后无 item 可供显示