这么用GridLayoutManager,你可能还真没尝试过

前言

上周我在《抽丝剥茧RecyclerView - LayoutManager》一文中提到利用GridLayoutManager可以实现一个如下的首页:

上期分享

有同学对此表示很感兴趣,奈何没有现成的案例,于是自己就简单实现了一个,最终效果如下:
效果

相信很多同学都和我有一样的感觉,认为GridLayoutManager只能实现标准的网格布局,直到我前段时间决定研究RecyclerView,看了GridLayoutManager的源码,才发现,原来它可以做更多的事,比如说,写一个首页。

阅读本文之前,你需要的一些知识储备

  1. View的绘制流程有一些简单的了解。
  2. Canvas的简单实用。
  3. RecyclerView+GridLayoutManager的使用。

目录

目录

一、场景

使用RecyclerView+GridLayoutManager+ItemDecoration定制首页适用的场景:

  • 有多个功能模块
  • 子视图多个样式
  • 最后一个模块需要刷新(如果有这样的功能,肯定也是通过RecyclerView实现的),例如QQ音乐中往下滑推荐用户可能感兴趣的音乐。

个人觉得该方案的意义在于减少布局的嵌套,让界面管理变得更加简单,但是对于业务特别复杂的情况下可能会不适用。

二、思路

实现以上功能需要解决两个难点

  1. 如何给不同行展示不同数量的子视图
  2. 每个模块标题的绘制

这两个问题的解决方案分别对应着GridLayoutManagerItemDecoration,我们挨个了解。

1. GridLayoutManager

GridLayoutManager其实我们已经很熟悉了,只是我们平时没有了解SpanSize这个概念,先看如下一段代码:

GridLayoutManager gll = new GridLayoutManager(this, 6);
mRecyclerView.setLayoutManager(gll);

上面的代码中我们创建了一个纵向、每行最多容量6个子View的GridLayoutManager,默认情况下,一行总的SpanSize为6,每个子视图默认的SpanSize为1,所以不做处理的情况下GridLayoutManager会将每一行分成6份,每一份展示一个子视图,如下图的第一行:

样式

这时,我如果将子视图的SpanSize都设置为2,那么这个子视图将占整个RecyclerView可用宽度的2/6,如上图第二行,同理,我将SpanSize上升为3,那么该子视图的宽度也就上升为可用的宽度的3/6,如上图第三行,这也是GridLayoutManager能够在不同行设置不同数量的子视图的原因,当然了,你也可以将同一行里面的三个子视图SpanSize分别设置为1、2、3。

好了,距离代码实战还差一个如何绘制标题。

2. ItemDecoration

分割线ItemDecoration是一个很有意思的东西,因为它可以实现一些好玩的东西,比如以下的通讯录的字母标题时间轴

通讯录字母标题
时间轴

还可以利用它做一些特殊的效果,例如字母标题的吸顶,这里我分别推荐两个库:

这里简单的介绍一下ItemDecoration的原理,这里我就默认同学们已经了解View的测绘流程,主要分为两部分:

  1. 将分隔线绘制在RecyclerView子视图的下层,因为分隔线ItemDecoration第一个绘制方法ItemDecoration#onDraw发生在绘制RecyclerView子视图之前,如果你想让其显示出来,需要给ItemDecoration设置偏移量,让子视图偏移,从而不会遮挡ItemDecoration
  2. 将分隔线绘制在RecyclerView子视图的上层,因为其绘制方法ItemDecoration#onDrawOver发生在RecyclerView子视图绘制绘制完成以后,这也是ItemDecoration能够实现吸顶的效果。

三、代码实战

有了上面的知识储备,下面就简单了。

1. 自定义ItemDecoration

自定义ItemDecoration需要实现的三个方法,跟我们上面提及的原理相关:

方法名 解释
onDraw 绘制子视图下层的分隔线
getItemOffsets 通常为了显示下层分隔线而预留的空间
onDrawOver 绘制上层的分隔线

我们的任务仅仅是绘制一个标题,所以使用上面的两个方法就够了。

1.1 定义数据接口
/**
 * 数据约束
 */
public interface IGridItem {
    /**
     * 是否启用分割线
     * @return true
     */
    boolean isShow();

    /**
     * 分类标签
     */
    String getTag();

    /**
     * 权重
     */
    int getSpanSize();
}
1.2 自定义ItemDecoration类

核心代码就100多行:

/**
 * 适用于GridLayoutManager的分割线
 */
public class GridItemDecoration extends RecyclerView.ItemDecoration {
    // 记录上次偏移位置 防止一行多个数据的时候视图偏移
    private List<Integer> offsetPositions = new ArrayList<>();
    // 显示数据
    private List<? extends IGridItem> gridItems;
    // 画笔
    private Paint mTitlePaint;
    // 存放文字
    private Rect mRect;
    // 颜色
    private int mTitleBgColor;
    private int mTitleColor;
    private int mTitleHeight;
    private int mTitleFontSize;
    private Boolean isDrawTitleBg = false;
    private Context mContext;
    // 总的SpanSize
    private int totalSpanSize;
    private int mCurrentSpanSize;

    //... 省略一些方法

    @Override
    public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
        super.onDraw(c, parent, state);
        // 绘制标题的逻辑:
        // 如果该行的数据的需要显示的标题不同于上行的标题,就绘制标题
        final int paddingLeft = parent.getPaddingLeft();
        final int paddingRight = parent.getPaddingRight();
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = parent.getChildAt(i);
            RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
            int pos = params.getViewLayoutPosition();
            IGridItem item = gridItems.get(pos);
            if (item == null || !item.isShow())
                            continue;
            if (i == 0) {
                drawTitle(c, paddingLeft, paddingRight, child
                                        , (RecyclerView.LayoutParams) child.getLayoutParams(), pos);
            } else {
                IGridItem lastItem = gridItems.get(pos - 1);
                if (lastItem != null && !item.getTag().equals(lastItem.getTag())) {
                    drawTitle(c, paddingLeft, paddingRight, child,
                                                (RecyclerView.LayoutParams) child.getLayoutParams(), pos);
                }
            }
        }
    }
    /**
     * 绘制标题
     *
     * @param canvas 画布
     * @param pl     左边距
     * @param pr     右边距
     * @param child  子View
     * @param params RecyclerView.LayoutParams
     * @param pos    位置
     */
    private void drawTitle(Canvas canvas, int pl, int pr, View child, RecyclerView.LayoutParams params, int pos) {
        if (isDrawTitleBg) {
            mTitlePaint.setColor(mTitleBgColor);
            canvas.drawRect(pl, child.getTop() - params.topMargin - mTitleHeight, pl
                                , child.getTop() - params.topMargin, mTitlePaint);
        }
        IGridItem item = gridItems.get(pos);
        String content = item.getTag();
        if (TextUtils.isEmpty(content))
                    return;
        mTitlePaint.setColor(mTitleColor);
        mTitlePaint.setTextSize(mTitleFontSize);
        mTitlePaint.setTypeface(Typeface.DEFAULT_BOLD);
        mTitlePaint.getTextBounds(content, 0, content.length(), mRect);
        float x = UIUtils.dip2px(20f);
        float y = child.getTop() - params.topMargin - (mTitleHeight - mRect.height()) / 2;
        canvas.drawText(content, x, y, mTitlePaint);
    }

    @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);
        IGridItem item = gridItems.get(position);
        if (item == null || !item.isShow())
                    return;
        if (position == 0) {
            outRect.set(0, mTitleHeight, 0, 0);
            mCurrentSpanSize = item.getSpanSize();
        } else {
            if (!offsetPositions.isEmpty() && offsetPositions.contains(position)) {
                outRect.set(0, mTitleHeight, 0, 0);
                return;
            }
            if (!TextUtils.isEmpty(item.getTag()) && !item.getTag().equals(gridItems.get(position - 1).getTag())) {
                mCurrentSpanSize = item.getSpanSize();
            } else
                            mCurrentSpanSize += item.getSpanSize();
            if (mCurrentSpanSize <= totalSpanSize) {
                outRect.set(0, mTitleHeight, 0, 0);
                offsetPositions.add(position);
            }
        }
    }
}

总的逻辑就是:

  1. 如果所处的RecyclerView子视图的位置处在标题的下方,那么就需要预留空间,设置在outRect中,需要注意的是,同一行的多个子视图都需要预留空间。
  2. 对不同于上一个数据标题的当前数据进行标题的绘制。
  3. 重复执行1、2。

2. 界面部分

public class SpecialGridActivity extends AppCompatActivity {

    // GridItem实现了IGridItem接口
    private List<GridItem> values;
    private RecyclerView mRecyclerView;
    private GridItemDecoration itemDecoration;
    // 自己封装的RecyclerAdapter
    private RecyclerAdapter<GridItem> mAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_special_grid);

        initWidget();
    }

    private void initWidget() {
        mRecyclerView = findViewById(R.id.rv_content);

        // 创建GridLayoutManager,并设置SpanSizeLookup
        GridLayoutManager gll = new GridLayoutManager(this, 3);
        gll.setSpanSizeLookup(new SpecialSpanSizeLookup());
        mRecyclerView.setLayoutManager(gll);
        values = initData();
        
        // 自己封装的RecyclerAdapter
        mRecyclerView.setAdapter(mAdapter = new RecyclerAdapter<GridItem>(values,null) {
            @Override
            public ViewHolder<GridItem> onCreateViewHolder(View root, int viewType) {
                switch (viewType) {
                    case R.layout.small_grid_recycle_item:
                        return new SmallHolder(root);
                    case R.layout.normal_grid_recycle_item:
                        return new NormalHolder(root);
                    case R.layout.special_grid_recycle_item:
                        return new SpecialHolder(root);
                    default:
                        return null;
                }

            }

            @Override
            public int getItemLayout(GridItem gridItem, int position) {
                switch (gridItem.getType()) {
                    case GridItem.TYPE_SMALL:
                        return R.layout.small_grid_recycle_item;
                    case GridItem.TYPE_NORMAL:
                        return R.layout.normal_grid_recycle_item;
                    case GridItem.TYPE_SPECIAL:
                        return R.layout.special_grid_recycle_item;
                }
                return 0;
            }
        });
        
        //...

        // 分隔线生成
        // 之前的GridItemDecoration代码中我将构建者模式部分省略了
        itemDecoration = new GridItemDecoration.Builder(this,values, 3)
                .setTitleTextColor(Color.parseColor("#4e5864"))
                .setTitleFontSize(22)
                .setTitleHeight(52)
                .build();
        mRecyclerView.addItemDecoration(itemDecoration);
    }

    // 数据初始化
    private List<GridItem> initData() {
        List<GridItem> values = new ArrayList<>();
        values.add(new GridItem("我很忙", "", R.drawable.head_1,"最近常听",1,GridItem.TYPE_SMALL));
        values.add(new GridItem("治愈:有些歌比闺蜜更懂你", "", R.drawable.head_2,"最近常听",1,GridItem.TYPE_SMALL));
        values.add(new GridItem("「华语」90后的青春纪念手册", "", R.drawable.head_3,"最近常听",1,GridItem.TYPE_SMALL));
      
        values.add(new GridItem("流行创作女神你霉,泰勒斯威夫特的创作历程", "", R.drawable.special_2
                ,"更多为你推荐",3,GridItem.TYPE_SPECIAL));
        values.add(new GridItem("行走的CD写给别人的歌", "给「跟我走吧」几分,试试这些", R.drawable.normal_1
                ,"更多为你推荐",3,GridItem.TYPE_NORMAL));
        values.add(new GridItem("爱情里的酸甜苦辣,让人捉摸不透", "听完「靠近一点点」,他们等你翻牌", R.drawable.normal_2
                ,"更多为你推荐",3,GridItem.TYPE_NORMAL));
        values.add(new GridItem("关于喜欢你这件事,我都写在了歌里", "「好想你」听罢,听它们吧", R.drawable.normal_3
                ,"更多为你推荐",3,GridItem.TYPE_NORMAL));
        values.add(new GridItem("周杰伦暖心混剪,短短几分钟是多少人的青春", "", R.drawable.special_1
                ,"更多为你推荐",3,GridItem.TYPE_SPECIAL));
        values.add(new GridItem("我好想和你一起听雨滴", "给「发如雪」几分,那这些呢", R.drawable.normal_4
                ,"更多为你推荐",3,GridItem.TYPE_NORMAL));
        values.add(new GridItem("油管周杰伦热门单曲Top20", "「周杰伦」的这些哥,你听了吗", R.drawable.normal_5
                ,"更多为你推荐",3,GridItem.TYPE_NORMAL));

        return values;
    }

    class SpecialSpanSizeLookup extends GridLayoutManager.SpanSizeLookup {

        @Override
        public int getSpanSize(int i) {
            // 返回在数据中定义的SpanSize
            GridItem gridItem = values.get(i);
            return gridItem.getSpanSize();
        }
    }

    class SmallHolder extends RecyclerAdapter.ViewHolder<GridItem> {    
        //... 代码省略,就是设置图片和文字的操作
        // 小的Holder
    }

    class NormalHolder extends RecyclerAdapter.ViewHolder<GridItem> {
        //... 中等的Holder
    }

    class SpecialHolder extends RecyclerAdapter.ViewHolder<GridItem> {
        //... 横向大的Holder
    }
}

与我们平时使用GridLayoutManager不一样的是,GridLayoutManager需要设置SpanSizeLookUp,就是需要我们给每个子视图的设置SpanSize,因为我们每个数据都实现了IGridItem接口,该接口会向外提供SpanSize,所以这里返回我们在数据中设置的SpanSize即可。

限于篇幅,布局文件以及ReyclerAdapter的封装就不贴了,感兴趣的同学可以查看一下源代码。以下就是我们完成的效果:


效果

四、总结

总结

源码中的一些细节是很有趣的,正是因为阅读了GridLayoutManager的源码,才有了本文的出现。读完本文之后,相信你和我一样,对RecyclerView有了更深的了解。

Demo地址:https://github.com/mCyp/Orient-Ui

如果你希望和RecyclerView有着更深入的交流,欢迎阅读我的抽丝剥茧RecyclerView系列文章

第一篇:《抽丝剥茧RecyclerView - LayoutManager》
第二篇:《抽丝剥茧RecyclerView - 化整为零》
第三篇:《抽丝剥茧RecyclerView - ItemAnimator & Adapter》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容

  • 前言 抽丝剥茧RecyclerView系列文章的目的在于帮助Android开发者提高对RecyclerView的认...
    九心_阅读 7,711评论 5 112
  • RecyclerView 概要 RecyclerView是Android 5.0开始提供一个可回收容器,它比 Li...
    rexyren阅读 5,535评论 10 27
  • 这篇文章分三个部分,简单跟大家讲一下 RecyclerView 的常用方法与奇葩用法;工作原理与ListView比...
    LucasAdam阅读 4,320评论 0 27
  • 孤单一人走,天寒汗透身。 独休山下石,思念却深深。
    徐一村阅读 329评论 0 1
  • 仿佛梦跌到在了这茫茫的黑夜里窗户敞开着被关在房子里的灯,放一些光出去寻它卖花的小女孩推销给我的那支玫瑰还在床前花瓶...
    马骥北阅读 2,270评论 51 53