总结
- itemTouchHelper 通过给
Item 设置 elevation:使 item 拖动时可显示在所有 view 上 - 拖动时,通过设置 item 的 translationX/Y 使 item 可跟随手指移动
ItemDecoration
ItemTouchHelper 继承于 ItemDecoration,所以先说 ItemDecoration。ItemDecoration 一共提供了三个方法:onDraw(),onDrawOver() 以及 getItemOffsets()。先看前两个
onDraw 与 onDrawOver
直接在 RV 中搜两个方法的调用地方如下:
public void draw(Canvas c) {
// 会先调用自己的 onDraw(),再绘制 child
super.draw(c);
// 待子 view 绘制完成后,再调用 drawOver()
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
...
}
public void onDraw(Canvas c) {
super.onDraw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}
结合 view 绘制流程很容易看出两者区别:
over 是在绘制完子 view 以后再调用,所以绘制的内容会出现在子 view 之上draw 是在绘制子 view 以前调用,所以绘制的内容会被子 view 盖住
所以一般 rv 的分隔线其实质就是:各个 item 之间留有空隙,显示出 rv 自己的内容
getItemOffsets
设置 item 相对于原本位置的偏移量。注意:它有可能会侵占 item 应有的空间。比如垂直 rv ,item 的左边正常情况下应与 rv 的左边重合(假设不存在任何 padding,margin),如果设置了水平偏移量,那么 item 就不会紧贴 rv 左边。
原理,需结合 layoutManager 看。以 LinearLayoutManager 为例:
// layoutChunk 方法节选
// 测量
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
int left, top, right, bottom;
if (mOrientation == VERTICAL) {
// 以垂直,自左向右为例
if (isLayoutRTL()) {
} else {
left = getPaddingLeft();
// 与上面的 getDecoratedMeasurement() 类似
// 一个负责测量高度(getDecoratedMeasurement),一个测量长度(getDecoratedMeasurementInOther)
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
bottom = layoutState.mOffset;
top = layoutState.mOffset - result.mConsumed;
} else {
top = layoutState.mOffset;
bottom = layoutState.mOffset + result.mConsumed;
}
} else {
}
// 布局
layoutDecoratedWithMargins(view, left, top, right, bottom);
先看测量部分,最终两个方法如下:
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
// 这里面就会遍历所有的 itemDecoration,并将它们设置的 offset 相加
// 然后设置到 insets 的 left,right,top,bottom 中
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
// 如果没有设置那么 widthUsed 就是 0,如果设置过了,widthUsed 就有值
// 这部分空间就相当于 rv 占用了,留给 item 的空间就会变少
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
getPaddingLeft() + getPaddingRight()
+ lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
canScrollHorizontally());
final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
getPaddingTop() + getPaddingBottom()
+ lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
canScrollVertically());
if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
// 测量 child
child.measure(widthSpec, heightSpec);
}
}
Rect getItemDecorInsetsForChild(View child) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (!lp.mInsetsDirty) {
return lp.mDecorInsets;
}
if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
// changed/invalid items should not be updated until they are rebound.
return lp.mDecorInsets;
}
// 注意这里使用的是 lp 中的 mDecorInsets
// 也就是说该方法执行完成后,lp#mDecorInsets 中已记录了 itemDecorations 中所有设置过的 itemOffset
// 这一点在 layout 时有用
final Rect insets = lp.mDecorInsets;
insets.set(0, 0, 0, 0);
final int decorCount = mItemDecorations.size();
for (int i = 0; i < decorCount; i++) {
mTempRect.set(0, 0, 0, 0);
mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
insets.left += mTempRect.left;
insets.top += mTempRect.top;
insets.right += mTempRect.right;
insets.bottom += mTempRect.bottom;
}
lp.mInsetsDirty = false;
return insets;
}
到这里已经可以看出 itemOffset 的作用了。但有一种特殊情况:结合测量模式可知:如果指定了 item 的宽高,itemOffset 就会无效,这是测量模式的一个特例而已。
回到 layoutChunck() 中,看一下为 view 设置上下左右位置时涉及到的两个函数:
@Override
public int getDecoratedMeasurement(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
// 实际上是 measureHeight + itemOffset。见最下面的两个方法
return mLayoutManager.getDecoratedMeasuredHeight(view) + params.topMargin
+ params.bottomMargin;
}
@Override
public int getDecoratedMeasurementInOther(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
// 同上
return mLayoutManager.getDecoratedMeasuredWidth(view) + params.leftMargin
+ params.rightMargin;
}
public int getDecoratedMeasuredHeight(@NonNull View child) {
// 关键是 mDecorInsets 的赋值
final Rect insets = ((LayoutParams) child.getLayoutParams()).mDecorInsets;
return child.getMeasuredHeight() + insets.top + insets.bottom;
}
public int getDecoratedMeasuredWidth(@NonNull View child) {
final Rect insets = ((LayoutParams) child.getLayoutParams()).mDecorInsets;
return child.getMeasuredWidth() + insets.left + insets.right;
}
从上面可以看出,itemOffset 最终会被算到 item 自己占据的空间中,layoutChunck() 计算出的上下左右是 item 将要显示的区域,但这一区域并不是 item 真正占据的地方:
最后看 layout 过程:
public void layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
int bottom) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final Rect insets = lp.mDecorInsets;
// layoutChunck() 指定了上下左右的值,但 item 真正使用时还必须扣掉 margin 以及 itemOffset
// 剩余的空间才是它自己可以使用的
child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
right - insets.right - lp.rightMargin,
bottom - insets.bottom - lp.bottomMargin);
}
说完了,它就是设置 item 的上下左右的偏移量的。
ItemTouchHelper
继承于 ItemDecoration,没重写 getItemOffset(),onDrawOver() 本身也没处理什么逻辑,忽略。剩余的 onDraw() 主要用于处理 item 在拖动时的移动,后面再说。
在 attachToRecyclerView() 中最关键的就是调用 setupCallbacks():
private void setupCallbacks() {
ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
mSlop = vc.getScaledTouchSlop();
// 添加自己,剩余 onDraw() 方法
mRecyclerView.addItemDecoration(this);
// 处理 item 的 touch 事件。当 item 被拖起来后,手指滑动时 item 会跟着移动,就是在这里处理的
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
// 这个忽略,主要是处理选中的 item detach 时的逻辑
mRecyclerView.addOnChildAttachStateChangeListener(this);
// 这个用于处理 item 的 longclick 事件
// item 的拖动从 longClick 开始的
startGestureDetection();
}
先从拖动开始说起,即 onLongPress(),它的作用有二:
- 调用
RecyclerView#findChildViewUnder()根据 MotionEvent 找到触摸到哪个 itemView,然后调用RecyclerView#getChildViewHolder拿到对应的 vh - 调用 select() 方法。主要记录被选中的 itemView 的初始位置信息,以及使用 mSelect 记录对应的 vh,然后调用 rv 的 invalidate()
再看拖动时,主要看 ACTION_MOVE 时的逻辑:
updateDxDy(event, mSelectedFlags, activePointerIndex);
// 主要是调用 callback#onMove(),也就是应用程序交换 item 的地方
// 这里涉及到寻找交换目标的逻辑,不影响整体分析,忽略
moveIfNecessary(mSelected);
// 这一部分处理 item 拖动到边界时 rv 自己的滚动
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
// 核心
mRecyclerView.invalidate();
拖动的整体逻辑就结束了,关键是都调用了 invalidate() 方法,导致 rv 自己的 onDraw() 被调用,然后调用到 ItemTouchHelper#onDraw(),各种跳转后到 ItemTouchUIUtilImpl#onDraw
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
// 设置 view 的 elevation
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}
// 设置 translationX/Y,这样随着拖动,item 就会动起来
view.setTranslationX(dX);
view.setTranslationY(dY);
}
上面方法会首先设置 item 的 elevation,这是拖动 item 时 item 能显示在整个界面上方的原因。