ItemDecoration 及 ItemTouchHelper

1,269 阅读1分钟

总结

  1. itemTouchHelper 通过给 Item 设置 elevation:使 item 拖动时可显示在所有 view 上
  2. 拖动时,通过设置 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 绘制流程很容易看出两者区别:

  1. over 是在绘制完子 view 以后再调用,所以绘制的内容会出现在子 view 之上
  2. 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(),它的作用有二:

  1. 调用 RecyclerView#findChildViewUnder() 根据 MotionEvent 找到触摸到哪个 itemView,然后调用 RecyclerView#getChildViewHolder 拿到对应的 vh
  2. 调用 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 能显示在整个界面上方的原因