本文承接上一篇RecyclerView详解二
五、ItemTouchHelper
ItemTouchHelper 这个类是我们用来给表项添加各种修饰的帮助类,我们可以用它来实现表项的侧滑删除和拖拽等效果。对于这部分内容,我会先讲一点应用,然后从应用入手跟着源码逐步分析原理。
(1)ItemTouchHelper基本使用
首先,实现一个 Callback 继承自 ItemTouchHelper.Callback。重写它的几个重要函数。
class RecyclerTouchHelpCallBack(var onCallBack: OnHelperCallBack) : ItemTouchHelper.Callback() {
var edit = false
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)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
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) {
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) {
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() 入手,这个就是入口方法,我们逐步分析实现原理。
(2)ItemTouchHelper原理
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
if (mRecyclerView == recyclerView) {
return;
}
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 = vc.getScaledTouchSlop();
mRecyclerView.addItemDecoration(this);
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
mRecyclerView.addOnChildAttachStateChangeListener(this);
startGestureDetection();
}
我们沿着调用链一步一步走,startGestureDetection() 启动手势检测
private void startGestureDetection() {
mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener();
mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(),
mItemTouchHelperGestureListener);
}
这就是检测是否为长按动作的具体方法
private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener {
...
@Override
public void onLongPress(MotionEvent e) {
if (!mShouldReactToLongPress) {
return;
}
View child = findChildView(e);
if (child != null) {
ViewHolder vh = mRecyclerView.getChildViewHolder(child);
if (vh != null) {
if (!mCallback.hasDragFlag(mRecyclerView, vh)) {
return;
}
int pointerId = e.getPointerId(0);
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(vh, ACTION_STATE_DRAG);
}
}
}
}
}
}
我们可以看一下 findChildView() 是怎么进行寻找子视图的。
View findChildView(MotionEvent event) {
final float x = event.getX();
final float y = event.getY();
if (mSelected != null) {
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;
}
...
final float x = motionEvent.getX(pointerIndex);
final float y = motionEvent.getY(pointerIndex);
final float dx = x - mInitialTouchX;
final float dy = y - mInitialTouchY;
final float absDx = Math.abs(dx);
final float absDy = Math.abs(dy);
if (absDx < mSlop && absDy < mSlop) {
return;
}
...
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) {
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) {
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);
}
}
...
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) {
...
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(event);
}
if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
return;
}
final int action = event.getActionMasked();
final int activePointerIndex = event.findPointerIndex(mActivePointerId);
if (activePointerIndex >= 0) {
checkSelectForSwipe(action, event, activePointerIndex);
}
ViewHolder viewHolder = mSelected;
if (viewHolder == null) {
return;
}
switch (action) {
case MotionEvent.ACTION_MOVE: {
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();
}
case MotionEvent.ACTION_UP:
select(null, ACTION_STATE_IDLE);
mActivePointerId = ACTIVE_POINTER_ID_NONE;
break;
...
}
}
对 onTouchEvent() 我对这个方法讲的很清楚,也相信大家明白了大致流程,其中对我们来说最重要的就是 ACTION_MOVE ,在这里面的就是我们手指滑动过程中进行的。我们对这里面的代码一行一行分析。
case MotionEvent.ACTION_MOVE: {
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) {
...
List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
if (swapTargets.size() == 0) {
return;
}
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();
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
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) {
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) {
mOverdrawChildPosition = -1;
float dx = 0, dy = 0;
if (mSelected != null) {
getSelectedDxDy(mTmpPosition);
dx = mTmpPosition[0];
dy = mTmpPosition[1];
}
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有关的文章,对于前面有些内容不懂的小伙伴可以参考一下~
(1)MotionEvent详解
(2)ListView和RecyclerView缓存对比
|