Android 源码浅析:RecyclerView 源码浅析(4)—— ItemDecoration

1,518 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

目录

前言

上一篇博客内容对 LayoutManager 进行了一定的了解,末尾 Demo 中自定义 LayoutManager 实现的分页导航,仅仅实现了布局,一般来说在底部会有一个分页指示器,这篇内容就来利用 ItemDecoration 来实现这个需求,ItemDecoration 在我看来算是 RecyclerView 中比较浅显易懂的部分了。

源码分析

由于 ItemDecoration 的源码很简单,直接进入源码:

public abstract static class ItemDecoration {
    
    public void onDraw(Canvas c, RecyclerView parent,State state) {
        onDraw(c, parent);
    }

    public void onDrawOver(Canvas c, RecyclerView parent, State state) {
        onDrawOver(c, parent);
    }

    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
        getItemOffsets(outRect, ((LayoutParams)view.getLayoutParams()).getViewLayoutPosition(), parent);
    }
    
    // 方法标记删除 使用上面的 onDraw
    @Deprecated
    public void onDraw(Canvas c, RecyclerView parent) {
    }

    // 方法标记删除 使用上面的 onDrawOver
    @Deprecated
    public void onDrawOver(Canvas c, RecyclerView parent) {
    }

    // 方法标记删除 使用上面的 getItemOffsets
    @Deprecated
    public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
        outRect.set(0, 0, 0, 0);
    }
}

六个方法,三个标记删除,那么针对三个方法挨着查看其调用时机即可。

onDraw、onDrawOver

这两个方法,一眼看上去就是绘制对吧?说到绘制,在第二篇博客中分析 RecyclerView 绘制流程时调用过这两个方法,回顾下 RecyclerView 的绘制,首先是 draw 方法:

RecyclerView.java

public void draw(Canvas c) {
    super.draw(c);
    // draw 方法上来就循环 mItemDecorations 逐个调用了 onDrawOver 方法
    // mItemDecorations 就不贴代码了 就是个 ArrayList 存储 ItemDecoration 对象
    final int count = mItemDecorations.size();
    for (int i = 0; i < count; i++) {
        mItemDecorations.get(i).onDrawOver(c, this, mState);
    }
    // ...
}

View 的 draw 方法中会调用 onDraw,那么接着看一下 onDraw 方法:

RecyclerView.java

public void onDraw(Canvas c) {
    super.onDraw(c);
    final int count = mItemDecorations.size();
    for (int i = 0; i < count; i++) {
        // 同样是遍历 这次调用的是 ItemDecoration 的 onDraw 方法
        mItemDecorations.get(i).onDraw(c, this, mState);
    }
}

调用顺序注意一下, RecyclerView 的 draw 方法先调用了 super.draw(),在父类 View 的 draw 方法中先调用了 onDraw,然后再执行RecyclerView 重写后剩余 draw 方法中的代码,所以 ItemDecoration onDraw 方法是先执行,onDrawOver 则是后执行。

getItemOffsets

public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
    outRect.set(0, 0, 0, 0);
}

先来个示意图来理解一下 getItemOffsets 中 outRect 的含义:

getItemOffsets 设置偏移前后的区别: image.png

就是给 itemView 设置了一个内部偏移,说的再简单点就是 itemView 内容的绘制区域缩小了。

接着查看下其在 RecyclerView 的调用时机,在 RecyclerView 中搜索后发现仅有一处调用:

RecyclerView.java

Rect getItemDecorInsetsForChild(View child) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    // ...
    // 从 LayoutParams 中取出
    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 进行累加
        insets.left += mTempRect.left;
        insets.top += mTempRect.top;
        insets.right += mTempRect.right;
        insets.bottom += mTempRect.bottom;
    }
    lp.mInsetsDirty = false;
    return insets;
}

可以看出每个 ItemDecoration 设置的偏移量都进行累加存储在了 LayoutParams 的 mDecorInsets 中,继续查看下 getItemDecorInsetsForChild 的调用时机:

RecyclerView.java


// 测量 View
public void measureChild(@NonNull View child, int widthUsed, int heightUsed) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    // 获取 mDecorInsets 累加完成后的结果
    final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
    widthUsed += insets.left + insets.right;
    heightUsed += insets.top + insets.bottom;
    final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
            getPaddingLeft() + getPaddingRight() + widthUsed, lp.width,
            canScrollHorizontally());
    final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
            getPaddingTop() + getPaddingBottom() + heightUsed, lp.height,
            canScrollVertically());
    if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
        child.measure(widthSpec, heightSpec);
    }
}

// 和上面 measureChild 是一样的 
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
    //...
    // 区别在于 widthSpec heightSpec 计算时增加了 Margin
    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());
    //...
}

从上面的代码不难看出,在测量子 View(也就是 itemView)时,整体占用的宽高是包含设置的偏移量的。

示意图

最后再来个示意图将三个方法结合起来理解下: image.png

  1. onDraw 绘制的内容在最底层,itemView 绘制在其内容之上,而 onDrawOver 绘制的内容在最上层;
  2. 图中 onDrawOver 虽然绘制在了 itemView 之中,但其实可以超出 itemView,我怕会有歧义特别说明一下;
  3. 如果想让 onDraw 绘制的内容不被 itemView 遮挡就重写 getItemOffsets 方法设置合适的偏移;

自定义 ItemDecoration

ItemDecoration 源码比较简单,也比较好理解,趁热打铁将上一篇内容中未完成的分页进度条部分通过自定义 ItemDecoration 来实现一下加深理解。

效果图

screen.gif

思路

进度条首先要保证不被 item 遮挡,那么绘制的逻辑要写在 onDrawOver 保证其绘制在最上层。其次也要保证进度条不遮挡 item,需要重写 getItemOffsets 对 item 设置底部偏移。

绘制逻辑比较简单用 drawLine 画进度条背景、进度条即可。进度等于 当滑动距离 / 最大滑动距离 ,获取这两个值可以通过 RecyclerView 的 重写 computeHorizontalScrollOffsetcomputeHorizontalScrollRange 两个方法,可以看下源码:

RecyclerView.java

public class RecyclerView {
    // ...
    public int computeHorizontalScrollOffset() {
        if (mLayout == null) {
            return 0;
        }
        return mLayout.canScrollHorizontally() ? mLayout.computeHorizontalScrollOffset(mState) : 0;
    }
    
    @Override
    public int computeHorizontalScrollRange() {
        if (mLayout == null) {
            return 0;
        }
        return mLayout.canScrollHorizontally() ? mLayout.computeHorizontalScrollRange(mState) : 0;
    }
    // ...
}

可以看出 RecyclerView 又交给了 LayoutManager 去获取,而 LayoutManager 中两个方法默认返回 0,需要我们自己重写实现计算。

实现

上一篇博客中自定义的 LayoutManager 并没有实现 computeHorizontalScrollOffsetcomputeHorizontalScrollRange 两个方法,这两个方法默认返回 0 需要手动实现:

class NavigationGridLayoutManager : RecyclerView.LayoutManager() {
    // ...
    override fun computeHorizontalScrollOffset(state: RecyclerView.State): Int {
        return mOffsetHorizontal
    }
    
    override fun computeHorizontalScrollRange(state: RecyclerView.State): Int {
        return mMaxOffsetHorizontal
    }
}

接着自定义 ItemDecoration:

class NavigationItemDecoration : RecyclerView.ItemDecoration() {

    private val mPaint = Paint().apply {
        strokeCap = Paint.Cap.ROUND
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
        // 滑动偏移量
        val scrollOffset = parent.computeHorizontalScrollOffset().toFloat()
        // 最大滑动距离
        val scrollRange = parent.computeHorizontalScrollRange().toFloat()
        // 进度条高度
        val progressHeight = 4f.dp
        // 进度条整体宽度
        val progressBgWidth = 100f.dp
        // 进度条宽度
        val progressWidth = 10f.dp

        // 先画进度条背景
        mPaint.color = Color.LTGRAY
        mPaint.strokeWidth = progressHeight

        val progressBgStartX = parent.width / 2f - progressBgWidth / 2
        val progressBgEndX = parent.width / 2f + progressBgWidth / 2
        val progressBgStartY = parent.height - progressHeight
        val progressBgEndY = parent.height - progressHeight
        c.drawLine(progressBgStartX, progressBgStartY, progressBgEndX, progressBgEndY, mPaint)

        // 画进度条
        mPaint.color = Color.GREEN
        val progressStartX = min(
            progressBgEndX - progressWidth,
            progressBgStartX + progressBgWidth * (scrollOffset / scrollRange)
        )
        val progressEndX = progressStartX + progressWidth
        val progressStartY = progressBgStartY
        val progressEndY = progressBgEndY
        c.drawLine(progressStartX, progressStartY, progressEndX, progressEndY, mPaint)
    }

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        // 设置底部偏移 防止被遮挡
        outRect.set(0, 0, 0, 10.dp)
    }
}

最后

ItemDecoration 是比较简单的一部分,只需了解 onDraw、onDrawOver、getItemOffsets 三个方法的作用,就能通过自定义 ItemDecoration 实现分割线、滚动条等等效果。

如果我的博客分享对你有点帮助,不妨点个赞支持下!