Android - RecyclerView进阶(2)—ItemDecoration与城市列表

我的CSDN: ListerCi
我的简书: 东方未曦

写在前面

本系列博客的demo都上传到了github:RecyclerViewDemo
如果有帮助到你的话不妨给我点个star~

在介绍ItemDecoration之前我们不妨先看下它能实现什么功能。
这是一个国内大部分城市的列表,通过城市拼音对其排序,通过拼音首字母对其分组。在滑动到某一组的城市时,它的Header会在顶部保持不动,下一组滑动上来时,新的Header会把上一组“顶”上去,这个效果就是ItemDecoration实现的。当然,为了功能的完整性,我还添加了侧边栏用于搜索查找。

gif-城市列表demo.gif

看完效果后是不是对ItemDecoration充满了兴趣?现在让我们一步一步去认识它并实现这个功能吧。

一、ItemDecoration简介

1.1 API介绍

ItemDecoration是定义在RecyclerView内部的抽象类,排除过时的方法,它提供了3个可重写的方法,代码如下。

public abstract static class ItemDecoration {
        /**
         * 绘制提供给RecyclerView的装饰
         * 任何由此方法绘制的内容都会在item绘制之前就绘制完毕,因此它是处于item下层的
         */
        public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
            onDraw(c, parent);
        }

        /**
         * 此方法与onDraw相对,方法中的内容是在item绘制完毕后开始绘制的
         * 因此会显示在item上层
         */
        public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
                @NonNull State state) {
            onDrawOver(c, parent);
        }

        /**
         * 通过outRect表示当前item距离left、top、right、bottom 4个方向的距离
         */
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                @NonNull RecyclerView parent, @NonNull State state) {
            getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                    parent);
        }
    }

其中onDraw(...)onDrawOver(...)方法的参数中传入了画布Canvas,通过画布我们可以在任何坐标绘制任何事物。
getItemOffsets(...)方法用于指定每个item距离左上右下4个方向的距离,效果同margin。参数中传入的view表示当前的item,如果你想根据item的数据设置不同margin的话,可以通过RecyclerView的getChildAdapterPosition(View)得到该item在Adapter中的position。

1.2 DividerItemDecoration分析

ItemDecoration最简单的用法就是添加分隔线,如果使用DividerItemDecoration,之后你会发现item之间多了一根细细的分隔线。

RecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));

那么这个细线是怎么画出来的呢?回忆一下ItemDecoration中方法的作用,步骤应该是这样的:首先我们通过getItemOffsets()在item之间添加间隔,然后通过onDraw()或者onDrawOver()在这段间隔内绘制线段。

为了验证我们的想法,来看一下DividerItemDecoration的源码。我们使用的时候是垂直方向,代码中水平方向的代码我就省略掉了。

public class DividerItemDecoration extends RecyclerView.ItemDecoration {
    
    private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
    private Drawable mDivider;
    // 垂直或水平方向
    private int mOrientation;

    private final Rect mBounds = new Rect();

    public DividerItemDecoration(Context context, int orientation) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0); // 默认的分隔线
        a.recycle();
        setOrientation(orientation);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (mOrientation == VERTICAL) {
            drawVertical(c, parent);
        }
    }

    private void drawVertical(Canvas canvas, RecyclerView parent) {
        canvas.save();
        final int left;
        final int right;
        // 根据RecyclerView是否有padding获取线段的left和right的坐标
        if (parent.getClipToPadding()) {
            left = parent.getPaddingLeft();
            right = parent.getWidth() - parent.getPaddingRight();
            canvas.clipRect(left, parent.getPaddingTop(), right,
                    parent.getHeight() - parent.getPaddingBottom());
        } else {
            left = 0;
            right = parent.getWidth();
        }
        // 得到当前显示的每个child的线段的top和bottom坐标
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            parent.getDecoratedBoundsWithMargins(child, mBounds);
            final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
            final int top = bottom - mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(canvas);
        }
        canvas.restore();
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
            RecyclerView.State state) {
        if (mDivider == null) {
            outRect.set(0, 0, 0, 0);
            return;
        }
        if (mOrientation == VERTICAL) {
            // 设置每个item与下方的间隔为mDivider.getIntrinsicHeight()
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }
}

DividerItemDecoration的实现逻辑果然与我们想的一样,先在getItemOffsets()中设置每个item与下方的间隔,随后在onDraw(...)中调用drawVertical(Canvas canvas, RecyclerView parent)得到线段的边界坐标并绘制。

二、城市列表实现

2.1 分析实现方式

虽然demo中的效果是下一组的Header将上一组的Header顶了上去,但实现的逻辑并非如此,如果把Header的背景色调整为半透明,效果是这样的。

gif-半透明Header.gif

Header半透明之后,它就露出了马脚,仔细观察后我们可以总结实现这个效果所需的步骤:
① 每个分组的第一个城市item的上方都有一个Header,例如“阿坝”和“白城”的上方都有一个Header
② 当前RecyclerView所展示的第一个item的分类会显示在RecyclerView的最上方,例如当前RecyclerView第一个item是“阿克苏”、“安庆”等城市时,RecyclerView最上方会漂浮一个"A"类Header
③ 当某个分组的最后一个item滑出RecyclerView时,Header会随着这个item一起滑走,这也是“顶上去”效果的由来。例如当“澳门”即将滑出RecyclerView时,"A"类Header会随着“澳门”item一起滑走,并且我们很容易得到他们坐标之间的关系:item.bottom = Header.bottom

2.2 具体实现

分析完步骤,即可开始实现这个效果。项目中的城市数据保存在arrays文件中,数据格式如下所示,我已经先为每个城市添加了拼音并为所有城市进行了排序,数据中还包括城市ID,完整数据请去博客开头的github下载。

<string-array name="city">
        <item>阿坝</item>
        <item>aba</item>
        <item>101271901</item>
        <item>阿克苏</item>
        <item>akesu</item>
        <item>101130801</item>
        <item>阿勒泰</item>
        <item>aletai</item>
        <item>101131401</item>
        <item>阿里</item>
        <item>ali</item>
        <item>101140701</item>
        <item>安康</item>
        <item>ankang</item>
        <item>101110701</item>
        <item>安庆</item>
        <item>anqing</item>
        <item>101220601</item>
        <item>鞍山</item>
        <item>anshan</item>
        <item>101070301</item>
        ......
</string-array>

在绘制Header时,我们需要知道一个item是不是它分组的第一个或者是最后一个,那么需要构建这样的一个实体类:

public class CityInfo {

    private String mCityName;
    private String mPinYin;
    private String mGroup;
    private String mCityID;
    private boolean mIsFirstInGroup;
    private boolean mIsLastInGroup;

    public CityInfo(String cityName, String pinYin, String cityID,
                    boolean isFirstInGroup, boolean isLastInGroup) {
        this.mCityName = cityName;
        this.mPinYin = pinYin;
        this.mGroup = mPinYin.substring(0, 1).toUpperCase();
        this.mCityID = cityID;
        this.mIsFirstInGroup = isFirstInGroup;
        this.mIsLastInGroup = isLastInGroup;
    }

    // setters and getters...
}

再来将数据都解析成实体类。由于数据是已经排好序的,那么判断一个item是不是它分组的第一个或最后一个可以用这种方式:如果第i个数据与第i-1个的group不相同,那么i就是i的分组的第一个item;而i-1就是i-1的分组的最后一个item。

    private void prepareCityInfo() {
        mCityInfoList = new ArrayList<>();
        String[] cityArray = getResources().getStringArray(R.array.city);
        String curGroup = "0";
        // 每 3 个String构成一个CityInfo
        for (int i = 0; i < cityArray.length; i += 3) {
            CityInfo cityInfo = new CityInfo(cityArray[i], cityArray[i + 1], cityArray[i + 2],
                    false, false);
            if (!cityInfo.getGroup().equals(curGroup)) {
                // 如果当前城市的 group 信息与保存的不一致, 那么就是该 group 的第一个
                cityInfo.setIsFirstInGroup(true);
                // 同时将该 group 信息添加到索引中
                indexList.add(cityInfo.getGroup());
                // 它的上一个城市就是上一个 group 的最后一个
                if (i > 0) {
                    mCityInfoList.get(mCityInfoList.size() - 1).setIsLastInGroup(true);
                }
                curGroup = cityInfo.getGroup();
            }
            mCityInfoList.add(cityInfo);
        }
    }

下面重点来看下怎么自定义ItemDecoration,我们根据之前总结的步骤来,首先为每组的第一个item绘制Header,在绘制Header之前需要通过getItemOffsets()方法为Header预留空间。

    @Override
    public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
                               @NonNull RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
        if (cityInfo.isFirstInGroup()) {
            outRect.top = GROUP_ITEM_TOP;
        }
    }

再通过onDraw()绘制,这里parent.getChildCount()获取到的是RecyclerView中当前可见的所有item的数量。

    @Override
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View view = parent.getChildAt(i);
            int position = parent.getChildAdapterPosition(view);
            CityInfo cityInfo = mCityInfoList.get(position);
            if (cityInfo.isFirstInGroup()) {
                int left = parent.getPaddingLeft();
                int top = view.getTop() - GROUP_ITEM_TOP;
                int right = parent.getRight() - parent.getPaddingRight();
                int bottom = view.getTop();
                // 绘制背景
                c.drawRect(left, top, right, bottom, mBackGroundPaint);
                // 绘制文字
                drawText(c, left, top, bottom, cityInfo.getGroup());
            }
        }
    }

再来绘制固定于RecyclerView顶端的Header,这里只要得到RecyclerView所展示的第一个item的group并将其绘制即可。由于这个Header是在RecyclerView上层的,因此需要在onDrawOver()中绘制。

public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        // 得到当前所展示的第一个View
        View view = parent.getChildAt(0);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
            
        int left = parent.getPaddingLeft();
        int top = parent.getPaddingTop();
        int right = parent.getRight() - parent.getPaddingRight();
        int bottom = top + GROUP_ITEM_TOP;
        c.drawRect(left, top, right, bottom, mBackGroundPaint);

        drawText(c, left, top, bottom, cityInfo.getGroup());
    }

最后需要实现就是Header随着当前group的最后一个item移动的效果,移动时Header的bottom与item的bottom一致即可。那什么时候开始移动呢?很显然就是当这个item的bottom与RecyclerView顶部的距离小于Header的高度时开始移动。我们修改onDrawOver()方法如下即可。

    @Override
    public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        View view = parent.getChildAt(0);
        int position = parent.getChildAdapterPosition(view);
        CityInfo cityInfo = mCityInfoList.get(position);
        // 当前第一个item是它group的最后一个
        // 且view的bottom距离RecyclerView顶端小于Header的高度
        if (cityInfo.isLastInGroup() && view.getBottom() < GROUP_ITEM_TOP) {
            int left = parent.getPaddingLeft();
            int top = view.getBottom() - GROUP_ITEM_TOP;
            int right = parent.getRight() - parent.getPaddingRight();
            int bottom = view.getBottom();
            c.drawRect(left, top, right, bottom, mBackGroundPaint);

            drawText(c, left, top, bottom, cityInfo.getGroup());
        } else {
            int left = parent.getPaddingLeft();
            int top = parent.getPaddingTop();
            int right = parent.getRight() - parent.getPaddingRight();
            int bottom = top + GROUP_ITEM_TOP;
            c.drawRect(left, top, right, bottom, mBackGroundPaint);

            drawText(c, left, top, bottom, cityInfo.getGroup());
        }
    }

代码介绍到这里就结束了,如果你对整体的程序感兴趣,欢迎去github下载。

三、参考

RecyclerView探索之通过ItemDecoration实现StickyHeader效果

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值