本文承接上一篇RecyclerView详解二
ItemTouchHelper 这个类是我们用来给表项添加各种修饰的帮助类,我们可以用它来实现表项的侧滑删除和拖拽等效果。对于这部分内容,我会先讲一点应用,然后从应用入手跟着源码逐步分析原理。
首先,实现一个 Callback 继承自 ItemTouchHelper.Callback。重写它的几个重要函数。
class RecyclerTouchHelpCallBack(var onCallBack: OnHelperCallBack) : ItemTouchHelper.Callback() {
var edit = false
// 该函数是用来设置滑动方向的,比如下面的 dragFlags 和 swipeFlags
override fun getMovementFlags(
recyclerView: RecyclerView
viewHolder: RecyclerView.V
): Int {
if (!edit) {
return 0
}
// 拖拽方向(上下左右)
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
// 侧滑删除(左右)
val swipeFlags = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
return makeMovementFlags(dragFlags, swipeFlags)
}
// 该方法主要用来通知你拖拽的item从哪里移动到了哪里
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
// 如果当前拖拽的item与目标item的类型不一样,将无法交换位置
if (viewHolder.itemViewType != target.itemViewType) return false
val fromPosition = viewHolder.adapterPosition
val targetPosition = target.adapterPosition
onCallBack.onMove(fromPosition, targetPosition)
return true
}
// 侧滑的部分我们以删除为例
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// 调用Callback的remove将侧滑删除的item移出列表
onCallBack.remove(viewHolder, direction, viewHolder.layoutPosition)
}
fun itemMove(adapter: RecyclerView.Adapter<RecyclerView.ViewHolder>, data: List<*>, fromPosition: Int, targetPosition: Int) {
if (data.isEmpty()) {
return
}
if (fromPosition < targetPosition) {
for (i in fromPosition until targetPosition) {
Collections.swap(data, i, i + 1)
}
} else {
for (i in targetPosition until fromPosition) {
Collections.swap(data, i, i + 1)
}
}
adapter.notifyItemMoved(fromPosition, targetPosition)
}
...
// 后面还有一些功能函数我就不一一列举了,知道这么个流程就行
}
上述代码中,主要实现的有三个函数,第一个就是 getMovementFlags() ,该方法主要用来设置拖拽和侧滑的方向,第二个是 onMove() ,该方法用来设置拖拽的 item 的起始位置和目的地。然后通过 itemMove() 方法将拖拽的 item 移动到目的地,剩下的 item 依次前移或后移。第三个就是 onSwiped() 用于实现侧滑的方法。
接下里就是Activity中的代码了,我只展示其中主要的一些代码。
callback = RecyclerTouchHelpCallBack(object : RecyclerTouchHelpCallBack.OnHelperCallBack {
override fun onMove(fromPosition: Int, targetPosition: Int) {
// 移动item
callback.itemMove(adapter, adapter.mData, fromPosition, targetPosition)
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder, actionState: Int) {
// 选中的改变样式
viewHolder.itemView.alpha = 1f
viewHolder.itemView.scaleX = 1.2f
viewHolder.itemView.scaleY = 1.2f
}
override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) {
callback.edit = false
// 完成移动,选中的改变样式
adapter.mData
viewHolder.itemView.alpha = 1f
viewHolder.itemView.scaleY = 1f
viewHolder.itemView.scaleX = 1f
}
override fun remove(
viewHolder: RecyclerView.ViewHolder,
direction: Int,
position: Int
) {
//
adapter.removeData(position)
}
})
ItemTouchHelper(callback).attachToRecyclerView(binding.rvItem)
这里的 Callback 就是为了实现我们定义的接口,然后具体实现其功能,其实最主要的还是最后一行代码,这一行的主要作用我一会会通过源码进行分析。
这就是 ItemTouchHelper 的基本使用,其实挺简单的,作用就是辅助 RecyclerView 对其子视图添加一些额外的功能。但我们学习嘛,就要知其然还要知其所以然。接下来我会从 attachToRecyclerView() 入手,这个就是入口方法,我们逐步分析实现原理。
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
if (mRecyclerView == recyclerView) {
// 如果已经赋值过了,就直接返回,不执行后面的操作
return; // nothing to do
}
// 如果 mRecyclerView 不为 null 就摧毁重置
if (mRecyclerView != null) {
destroyCallbacks();
}
mRecyclerView = recyclerView;
if (recyclerView != null) {
final Resources resources = recyclerView.getResources();
mSwipeEscapeVelocity = resources
.getDimension(R.dimen.item_touch_helper_swipe_escape_velocity);
mMaxSwipeVelocity = resources
.getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity);
setupCallbacks();
}
}
这个方法就是将我们自己的 RecyclerView 与源码中的 RV 绑定,然后对其进行一系列的操作。最后,该方法继续调用 setupCallbacks()
private void setupCallbacks() {
ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
// 注意这个变量 mSlop 后面会提
mSlop = vc.getScaledTouchSlop();
mRecyclerView.addItemDecoration(this);
// 添加 Item 触摸监听器,后面会用到,里面包含 onInterceptTouchEvent()、onTouchEvent()
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
mRecyclerView.addOnChildAttachStateChangeListener(this);
// 启动手势检测,检测是不是长按操作
startGestureDetection();
}
我们沿着调用链一步一步走,startGestureDetection() 启动手势检测
private void startGestureDetection() {
// new 一个监听器对象,对用户手势进行监听
mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener();
mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(),
mItemTouchHelperGestureListener);
}
这就是检测是否为长按动作的具体方法
private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener {
...
@Override
public void onLongPress(MotionEvent e) {
if (!mShouldReactToLongPress) {
return;
}
// 先找到子视图,onLongPress()在 GestureDetectorCompat类中调用,
// 然后将e传进来,通过刚才的startGestureDetection方法
// findChildView()就是通过获取event坐标定位到子视图的
View child = findChildView(e);
if (child != null) {
// 获取子视图对应的ViewHolder,接下来对ViewHolder进行操作
ViewHolder vh = mRecyclerView.getChildViewHolder(child);
if (vh != null) {
if (!mCallback.hasDragFlag(mRecyclerView, vh)) {
return;
}
// 对这个pointerId不了解的请移步最后的补充项
int pointerId = e.getPointerId(0);
// Long press is deferred.
// Check w/ active pointer id to avoid selecting after motion
// event is canceled.
if (pointerId == mActivePointerId) {
final int index = e.findPointerIndex(mActivePointerId);
final float x = e.getX(index);
final float y = e.getY(index);
mInitialTouchX = x;
mInitialTouchY = y;
mDx = mDy = 0f;
if (DEBUG) {
Log.d(TAG,
"onlong press: x:" + mInitialTouchX + ",y:" + mInitialTouchY);
}
if (mCallback.isLongPressDragEnabled()) {
// 通过select()方法选中当前ViewHolder,以便对其进行拖拽操作
// select()方法主要就是选中功能
// 将 FLAG = ACTION_STATE_DRAG 传入,代表是拖拽操作
select(vh, ACTION_STATE_DRAG);
}
}
}
}
}
}
我们可以看一下 findChildView() 是怎么进行寻找子视图的。
View findChildView(MotionEvent event) {
// first check elevated views, if none, then call RV
// 获取触摸点相对于view的坐标
final float x = event.getX();
final float y = event.getY();
// mSelected != null 即当前选中了一个子视图
if (mSelected != null) {
// 获取选中的ViewHolder对应的View,然后将其返回,这样就找到的View实例
// mSelected是一个ViewHolder对象
final View selectedView = mSelected.itemView;
if (hitTest(selectedView, x, y, mSelectedStartX + mDx, mSelectedStartY + mDy)) {
return selectedView;
}
}
...
// 我们先只考虑选中的情况
}
上面的 onLongPress() 是我们用来监听手指是否进行了长按的操作,同样的,肯定还有方法可以监听手指是否进行了侧滑操作,从源码中看,我们发现是 checkSelectForSwipe() 进行侧滑操作的判断。
void checkSelectForSwipe(int action, MotionEvent motionEvent, int pointerIndex) {
// 一些判断是否是侧滑操作的代码,无关紧要,如果不是就返回
if (mSelected != null || action != MotionEvent.ACTION_MOVE
|| mActionState == ACTION_STATE_DRAG || !mCallback.isItemViewSwipeEnabled()) {
return;
}
if (mRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) {
return;
}
...
// mDx and mDy are only set in allowed directions. We use custom x/y here instead of
// updateDxDy to avoid swiping if user moves more in the other direction
final float x = motionEvent.getX(pointerIndex);
final float y = motionEvent.getY(pointerIndex);
// Calculate the distance moved -> 计算滑动距离
final float dx = x - mInitialTouchX;
final float dy = y - mInitialTouchY;
// swipe target is chose w/o applying flags so it does not really check if swiping in that
// direction is allowed. This why here, we use mDx mDy to check slope value again.
final float absDx = Math.abs(dx);
final float absDy = Math.abs(dy);
// 这里的 mSlop 在上面提到的 setupCallbacks() 中被赋值
// 主要用于解决滑动冲突,如果我们侧滑的距离不够则不进行下一步操作(如删除),直接返回
// 只有滑动的距离大于 mSlop 值,才继续执行
// 说起来,和我们的日常习惯对上了
if (absDx < mSlop && absDy < mSlop) {
return;
}
// 下面一部分代码也是判断滑动距离的
...
// 这里也通过 select() 方法进行选中操作
// 将 FLAG = ACTION_STATE_SWIPE 传入,代表是侧滑操作
select(vh, ACTION_STATE_SWIPE);
}
好了,了解了如何判断手势进行了何种操作,接下来我们该重点研究 select() 方法了,这是一个十分重要的方法,我们需要通过它来选中我们想要进行拖拽或侧滑操作的View。然后对其进行具体的操作。
void select(@Nullable ViewHolder selected, int actionState) {
// 如果已经选中就直接返回
if (selected == mSelected && actionState == mActionState) {
return;
}
...
// 赋值操作,之前的状态
final int prevActionState = mActionState;
mActionState = actionState;
// 执行当前状态为'拖拽'的操作
if (actionState == ACTION_STATE_DRAG) {
// 如果没有选中一个item,就抛出异常
if (selected == null) {
throw new IllegalArgumentException("Must pass a ViewHolder when dragging");
}
// 为选中的子视图设置绘制监听,主要为了设置绘制的顺序
mOverdrawChild = selected.itemView;
addChildDrawingOrderCallback();
}
...
if (mSelected != null) {
final ViewHolder prevSelected = mSelected;
if (prevSelected.itemView.getParent() != null) {
// 如果之前的状态是拖拽,则给swipeDir赋值为0,否则执行swipeIfNecessary()操作
final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0
: swipeIfNecessary(prevSelected);
int animationType;
...
// 执行一系列判断操作,代码简单我就不过多解释了
if (prevActionState == ACTION_STATE_DRAG) {
animationType = ANIMATION_TYPE_DRAG;
} else if (swipeDir > 0) {
animationType = ANIMATION_TYPE_SWIPE_SUCCESS;
} else {
animationType = ANIMATION_TYPE_SWIPE_CANCEL;
}
...
} else {
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
mCallback.clearView(mRecyclerView, prevSelected);
}
// 将选中状态置为空
mSelected = null;
}
if (selected != null) {
mSelectedFlags =
(mCallback.getAbsoluteMovementFlags(mRecyclerView, selected) & actionStateMask)
>> (mActionState * DIRECTION_FLAG_COUNT);
mSelectedStartX = selected.itemView.getLeft();
mSelectedStartY = selected.itemView.getTop();
mSelected = selected;
if (actionState == ACTION_STATE_DRAG) {
mSelected.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
}
...
// 这是一个ItemTouchUIUtil的一个接口,需要我们自己来实现具体功能,比如选中改变item的样式
mCallback.onSelectedChanged(mSelected, mActionState);
// 刷新拖拽或侧滑更新后的位置
mRecyclerView.invalidate();
}
我们自己实现的 onSelectedChanged() 如下:
// 长按时调用
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
viewHolder?.let {
onCallBack.onSelectedChanged(viewHolder, actionState)
}
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder, actionState: Int) {
// 选中的改变样式
viewHolder.itemView.alpha = 1f
viewHolder.itemView.scaleX = 1.2f
viewHolder.itemView.scaleY = 1.2f
}
好了,看了这么多源码以及我对部分源码的讲解,我们现在来总结一下整个流程,这个过程也是在教大家如何看源码,在源码中找到关键之处。
我们通过
ItemTouchHelper
实现拖拽,侧滑功能,为了实现这两个功能,我们得先要将我们自己的RecyclerView
和源码中的绑定,然后进行手势操作的监听,监听我们对item
进行了何种操作,是侧滑操作还是长按拖拽操作。我们记得是通过 onLongPress() 和 checkSelectForSwipe() 两个方法来判断的,判断之后,两个方法都调用了 select() 来选中执行的 Item。关于 select() 方法呢,我还有一点补充:
如果处于手势开始阶段,即
selected
不为null,那么会通过getAbsoluteMovementFlags
方法来获取执行我们设置的flag,这个方法就是我们通过 checkSelectForSwipe() 和 onLongPress() 传进来的,上面也讲到了。这样我们就知道执行哪些行为(侧滑或者拖动)和方向(上、下、左和右)。同时还会记录下被选中ItemView
的位置。简而言之,就是一些变量的初始化。如果处于手势释放阶段,即
selected
为null,同时mSelected
不为null,那么此时需要做的事情就稍微有一点复杂。手势释放之后,需要做的事情无非有两件:1. 相关的ItemView
到正确的位置,就比如说,如果滑动条件不满足,那么就返回原来的位置,这个就是一个动画;2. 清理操作,比如说将mSelected
重置为null之类的。
到了这里,我们已经可以对自己的 RecyclerView 的 Item 选中并进行下一步操作了。我们先带着一个疑问来继续分析,那就是我们怎么让选中的 Item 跟随我们的手指进行移动呢?
我们知道,View 的 onTouchEvent() 方法是专门对触摸事件进行操作的,那我们就从源码中找到这个方法。
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
...
// 如果速度轨迹不为空,就是当前有触摸事件,将该event添加进去
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(event);
}
// 当前的手势操作ID为NONE,直接返回
if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
return;
}
// 获取该event的Id,对MotionEvent不太懂的移步 -> '补充'
final int action = event.getActionMasked();
final int activePointerIndex = event.findPointerIndex(mActivePointerId);
// 进行是否为侧滑事件的判断
if (activePointerIndex >= 0) {
checkSelectForSwipe(action, event, activePointerIndex);
}
ViewHolder viewHolder = mSelected;
// 如果没选中 ViewHolder 直接返回
if (viewHolder == null) {
return;
}
switch (action) {
case MotionEvent.ACTION_MOVE: {
// Find the index of the active pointer and fetch its position
if (activePointerIndex >= 0) {
updateDxDy(event, mSelectedFlags, activePointerIndex);
moveIfNecessary(viewHolder);
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
mRecyclerView.invalidate();
}
break;
}
// 被其他事件拦截的处理,将轨迹清除
case MotionEvent.ACTION_CANCEL:
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
// fall through
// 手指抬起,将Id置为NONE,退出switch
case MotionEvent.ACTION_UP:
select(null, ACTION_STATE_IDLE);
mActivePointerId = ACTIVE_POINTER_ID_NONE;
break;
...
}
}
对 onTouchEvent() 我对这个方法讲的很清楚,也相信大家明白了大致流程,其中对我们来说最重要的就是 ACTION_MOVE ,在这里面的就是我们手指滑动过程中进行的。我们对这里面的代码一行一行分析。
case MotionEvent.ACTION_MOVE: {
// Find the index of the active pointer and fetch its position
if (activePointerIndex >= 0) {
updateDxDy(event, mSelectedFlags, activePointerIndex);
moveIfNecessary(viewHolder);
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
mRecyclerView.invalidate();
}
break;
}
第一步:更新mDx
和mDy
的值。mDx
和mDy
表示手指在x轴和y轴上分别滑动的距离,将mSelectedFlags
和activePointerIndex
作为参数传过去,第一个代表选中的操作,是侧滑还是拖拽;第二个是当前触摸事件的Id,唯一标识。
第二步:如果需要,移动其他ItemView
的位置。这个主要针对拖动行为,我们具体来看看这部分的源码。
void moveIfNecessary(ViewHolder viewHolder) {
// 下面这些代码都是不符合 move 的条件
...
// 找可能会交换位置的ItemView
List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
if (swapTargets.size() == 0) {
return;
}
// may swap. -> 找到符合条件交换的ItemView
ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y);
if (target == null) {
mSwapTargets.clear();
mDistances.clear();
return;
}
final int toPosition = target.getAdapterPosition();
final int fromPosition = viewHolder.getAdapterPosition();
// 回调Callback里面的onMove方法,这个方法需要我们手动实现,在基本使用里有,大家可以上去翻翻
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
// keep target visible
mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
target, toPosition, x, y);
}
}
总的来说,分为三步:
调用
findSwapTarget
方法,寻找可能会跟选中的Item
交换位置的Item
。这里判断的条件是只要选中的Item
跟某一个Item
重叠,那么这个Item
可能会跟选中的Item
交换位置。调用Callback的
chooseDropTarget
方法来找到符合交换条件的Item
。这里符合的条件是指,选中的Item
的bottom
大于目标Item
的bottom
或者Item
的top
大于目标Item
的top
。一般我们可以重写chooseDropTarget
方法,来定义什么条件下就交换位置。回调
Callback
的onMove
方法,这个方法需要我们自己实现。这里需要注意的是,如果onMove
方法返回为true的话,会调用Callback
另一个onMove
方法来保证target可见。为什么必须保证target可见呢?从官方文档上来看的话,如果target不可见,在某些滑动的情形下,target会被remove掉。刚才说,
findSwapTargets()
是找到可能会交换位置的item
,而chooseDropTarget()
是找到会交换位置的item
就直接交换,听起来好抽象,那二者具体有什么区别呢?其中findSwapTarget
方法是找到可能会交换位置的ItemView
,chooseDropTarget
方法是找到一定会交换位置的ItemView
,这是两个方法的不同点。同时,如果此时在拖动,但是拖动的ItemView
还未达到交换条件,也就是跟另一个ItemView
只是重叠了一小部分,这种情况下,findSwapTargets
方法返回的集合不为空,但是chooseDropTarget
方法寻找的ItemView
为空。
第三步:如果当页展示的Item
不符合条件,需要拖拽到更远的地方,这时就需要滑动RecyclerView
。这个主要针对拖拽行为,此时如果拖动一个ItemView
达到RecyclerView
的底部或者顶部,会滑动RecyclerView
。
final Runnable mScrollRunnable = new Runnable() {
@Override
public void run() {
if (mSelected != null && scrollIfNecessary()) {
if (mSelected != null) { //it might be lost during scrolling
moveIfNecessary(mSelected);
}
mRecyclerView.removeCallbacks(mScrollRunnable);
ViewCompat.postOnAnimation(mRecyclerView, this);
}
}
};
在run
方法里面通过scrollIfNecessary
方法来判断RecyclerView
是否滚动,如果需要滚动,scrollIfNecessary
方法会自动完成滚动操作。
第四步:这步就是更新侧滑或者拖拽完成之后的视图了。ItemView
在随着手指移动时,变化的是translationX
和translationY
两个属性,所以只需要调用invalidate
方法就行。调用invalidate
方法之后,相当于RecyclerView
会重新绘制一次,那么所有ItemDecoration
的onDraw
和onDrawOver
方法都会被调用,而恰好的是,ItemTouchHelper
继承了ItemDecoration
。而绘制的方法就是 onDraw() 。我们具体来看一下。
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
// we don't know if RV changed something so we should invalidate this index.
mOverdrawChildPosition = -1;
float dx = 0, dy = 0;
if (mSelected != null) {
getSelectedDxDy(mTmpPosition);
dx = mTmpPosition[0];
dy = mTmpPosition[1];
}
// 这里又调用了 Callback 的 onDraw 方法
mCallback.onDraw(c, parent, mSelected,
mRecoverAnimations, mActionState, dx, dy);
}
void onDraw(Canvas c, RecyclerView parent, ViewHolder selected,
List<ItemTouchHelper.RecoverAnimation> recoverAnimationList,
int actionState, float dX, float dY) {
final int recoverAnimSize = recoverAnimationList.size();
...
if (selected != null) {
final int count = c.save();
onChildDraw(c, parent, selected, dX, dY, actionState, true);
c.restoreToCount(count);
}
}
调用onChildDraw
方法,将所有正在交换位置的ItemView
和被选中的ItemView
作为参数传递过去。而在onChildDraw
方法里面,调用了ItemTouchUIUtil
的onDraw
方法。我们从ItemTouchUiUtil
的实现类BaseImpl
找到答案:
@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view,
float dX, float dY, int actionState, boolean isCurrentlyActive) {
view.setTranslationX(dX);
view.setTranslationY(dY);
}
在这里改变了每个ItemView
的translationX
和translationY
,从而实现了ItemView
随着手指移动的效果。从这里,我们可以看出来,一旦调用RecyclerView
的invalidate
方法,ItemTouchHelper
的onDraw
方法和onDrawOver
方法都会被执行。
我找到了一些题外知识,但与RV有关的文章,对于前面有些内容不懂的小伙伴可以参考一下~