深入分析RecyclerView源码——滑动机制

﹥>﹥吖頭↗ 提交于 2019-12-14 10:08:24

RecyclerView布局之外,最常用的功能应该就是滑动。RecyclerView的事件处理依然是常规的onTouchEvent根据触控事件响应,特别的是RecyclerView采用了嵌套滑动机制,会把滑动事件通知给支持嵌套滑动的父view先做决定,以实现诸如toolBar上划隐藏的效果,还有就是涉及到缓存策略,不过相比布局,滑动的缓存策略要简单的多,仅仅是把划出屏幕的viewHolder存入mCachedViews。

onTouchEvent

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在处理自身滑动时究竟做了什么。

scrollByInternal

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:

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如何分配滑动距离和如何解决滑动冲突的问题,那是另外一回事了。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!