RV 滑动

191 阅读4分钟

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

总结

  1. 在滑动时,RV 会根据滑动的距离不断添加 item,每添加一个 item 后会回收要被滑动屏幕外的 item。所以滑动时边添加边回收
  2. 滑动过程中,会动态调整 limit 线。它表示 rv 最大可滑动距离,超过该距离后无 item 可供显示