0.前言
pager-layoutmanager: github.com/GcsSloop/pa…
这个是我之前公开分享的一个开源库 【PagerLayoutManager(网格分页布局)】 的详细解析,在开始讲解之前,先看看它能实现的一些效果。
上面是它的应用场景之一,再看一下实现这种场景所需的代码:
// 布局管理器
PagerGridLayoutManager layoutManager = new PagerGridLayoutManager(1, 4, PagerGridLayoutManager.HORIZONTAL);
mRecyclerView.setLayoutManager(layoutManager);
// 滚动辅助器
PagerGridSnapHelper snapHelper = new PagerGridSnapHelper();
snapHelper.attachToRecyclerView(mRecyclerView);
没错,想要实现这样分页滚动的效果,只需要四五行代码就可以了,至于 RecyclerView 和 Adapter 使用官方提供的即可。
1. 摘要
之前项目中有类似的需求,在网上寻找了一些实现方案,结果均不太满意。
有些方案使用起来过于麻烦,例如 ViewPager + GridView,不用我说,用过的都知道,这种方案数据绑定十分麻烦,并且会多一层View嵌套,相对来说会损耗一些性能。
有些则是存在重大缺陷,例如内存泄露,性能问题等,像上面那种场景仅展示几个固定条目的情况还不明显,但是当需要动态加载几百个条目的时候缺陷就显现出来了,会造成严重的滑动卡顿,当数据达到一定数量级的时候,可能直接导致ANR。
在试过诸多方案,踩过很多坑以后,依旧没有找到合适的方案,于是自己动手,丰衣足食,也就有了这个项目。
这个项目已经在公司多个项目上使用,经过十几个版本的迭代更新,基本上已经没有重大bug了,更新日志可以见这里: PagerLayoutManager。
如果你只是需要这样一个组件,那么直接点击上面的链接,看它的说明文档就可以了,本文不是你需要的,但如果你想要知道它的具体实现方案,对它进行改进的话,那么下文的内容可能会对你有所帮助。
2. 基础网格布局解析
2.1 方案选择
首先项目所需要的核心内容主要有以下几点:
- 网格效果
- 分页显示
- 横向排布
所需效果大概就如上图所示,为了避免重复造轮子,在一开始我想要使用一些现有的组件来完成。
-
最先想到的自然是网格布局,但是呢,项目需要动态加载数百条的数据,网格布局本身不带有条目自动回收创建功能,如果同让上百个View存在于一个页面之中,不卡爆才怪。
-
之后想到的是 ViewPager+GridLayout,但是这种方案数据拆分和绑定十分麻烦,遂放弃。
-
然后想 RecyclerView + GridLayoutManager 看起来靠谱一点,首先使用 GridLayoutMnager,作出网格效果,然后监听滚动事件来控制滚动距离,一切看起来都是那么美好,但是,事实证明这种方案还是太难使用。
首先网格布局同时只能控制行数或者列数其中一个,如果想要如果想要像设定那样2行3列,一页整好现实6条数据,那么View的宽高是需要动态计算的,如果设置了固定大小,必然会导致适配问题,
其次,数据不一定是整页,如果是2行3列,一页6条数据,那么使用 GridLayoutManager滑动后可能会出现这样的效果,另外,数据排列顺序也并非我所需要的:
这显然不是我想要的效果,在数据不足一页时,我需要的效果是这样的:
如果想要在不动 GridLayoutManager 的情况下实现需求,则需要执行如下操作:
假设,需要显示2行3列,共8条数据,那么需要执行如下操作:
-
将不足一页部分补足一页
1、2、3、4、5、6、7、8 1、2、3、4、5、6、7、8、空、空、空、空
-
通过数据变换调整数据次序使其显示符合预期
1、2、3、4、5、6、7、8、空、空、空、空 1、4、2、5、3、6、7、空、8、空、空、空
-
通过监听滚动控制滚动距离来实现分页显示
另外,页面数据是分页加载显示的,如果使用上面这种方案,单是数据处理逻辑就能把我绕进去。
-
在经过深思熟虑之后,我决定自定义一个 LayoutManager 来实现这个“简单”的需求。幸好 RecyclerView 的扩展性非常强,自定义一个 LayoutManager 也不是什么难事,下面我们就一步步的的实现一个分页网格布局。
2.2 创建一个基础的 LayoutManager
首先我们创建一个 PagerGridLayoutManager 并继承 RecyclerView.LayoutManager,实现其抽象方法,一个LayoutManager 就可以用了,如下:
public class PagerGridLayoutManager extends RecyclerView.LayoutManager {
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
这是一个符合规范的 LayoutManager,但是目前它不会将任何View显示在界面上,因为它没有对条目进行处理,也就意味着没有条目会添加到界面上。但我们先不着急处理子条目,在真正布局之前,我们先来解决一些简单的基础问题。
2.3 确定行列数数和滚动方向
由于行列数会直接关系到一个页面显示条目的个数,进而影响到总共可以滚动的j距离和页数总数,所以它们要在构造方法中设置。
而滚动方向一般都是确定的,横向或者竖向,并且基本不变,所以,它也可以在构造方法中进行设置,提醒使用者不要忘记滚动方向。
所以为 PagerGridLayoutManager 添加如下的属性和构造方法:
public static final int VERTICAL = 0; // 垂直滚动
public static final int HORIZONTAL = 1; // 水平滚动
@IntDef({VERTICAL, HORIZONTAL})
public @interface OrientationType {} // 滚动类型
@OrientationType
private int mOrientation = HORIZONTAL; // 默认水平滚动
private int mRows = 0; // 行数
private int mColumns = 0; // 列数
private int mOnePageSize = 0; // 一页的条目数量
/**
* 构造函数
*
* @param rows 行数
* @param columns 列数
* @param orientation 方向
*/
public PagerGridLayoutManager(@IntRange(from = 1, to = 100) int rows,
@IntRange(from = 1, to = 100) int columns,
@OrientationType int orientation) {
mOrientation = orientation;
mRows = rows;
mColumns = columns;
mOnePageSize = mRows * mColumns;
}
注意:
- 上面使用了 @IntDef 注解自定义了一个 OrientationType 注解,用于防治用户随意设置数值。
- 使用 @IntRange 方法将行列数限制在一个较合理的范围内。
- 在初始化的时候利用行列数计算出了一页应该有多少个条目,方面后面使用。
在确定了滚动方向后,顺便就可以实现 LayoutManager 以下两个方法了,这两个方法会真正的决定 RecyclerView 可以滚动的方向。
/** 是否可以水平滚动
* @return true 是,false 不是。
*/
@Override
public boolean canScrollHorizontally() {
return mOrientation == HORIZONTAL;
}
/** 是否可以垂直滚动
* @return true 是,false 不是。
*/
@Override
public boolean canScrollVertically() {
return mOrientation == VERTICAL;
}
2.4 计算子条目的宽高
由于我们是分页网格显示,目前已经知道了行列数,如果再知道 RecyclerView 的宽高,就能算出单个自条目的所能占用的宽高了。
因此我们再添加两个方法用于获取 RecyclerView 的可用宽高:
/** 获取可用的宽度
* @return 宽度 - padding
*/
private int getUsableWidth() {
return getWidth() - getPaddingLeft() - getPaddingRight();
}
/** 获取可用的高度
* @return 高度 - padding
*/
private int getUsableHeight() {
return getHeight() - getPaddingTop() - getPaddingBottom();
}
注意:可用宽高要减去 Padding 数值。
有了总的可用宽高,在分别处以行列数就可以得到每一个子条目占用的宽高了。
private int mItemWidth = 0; // 条目宽度
private int mItemHeight = 0; // 条目高度
mItemWidth = getUsableWidth() / mColumns;
mItemHeight = getUsableHeight() / mRows;
2.5 计算条目显示区域
既然知道了条目的宽高,那么只要知道这个条目所在位置就能确切的知道它的显示区域了。
这里使用的计算方案是:条目所在页面的偏移量 + 条目在页面内的偏移量。
同时由于页面可能会反复的滑动,因此不可能每次滚动时都重新计算一下条目的位置,因此计算过的条目用 mItemFrames
存储起来,之后想要获取该条目的显示区域,直接从 mItemFrames
中取出即可,防止重复计算造成的性能浪费。至于存储所耗费的内存空间,其实并不算大,存储 10 万个 Rect 耗费内存也才 4M 左右,正常情况下一般不会超过一万条数据,所耗费的空间一般不会超过
0.5M,大可以放心使用。
```Java
private SparseArray
mItemFrames; // 条目的显示区域
/** 获取条目显示区域
-
@param pos 位置下标
-
@return 显示区域
*/
private Rect getItemFrameByPosition(int pos) {
Rect rect = mItemFrames.get(pos);
if (null == rect) {
rect = new Rect();
// 计算显示区域 Rect
// 1. 获取当前View所在页数
int page = pos / mOnePageSize;// 2. 计算当前页数左上角的总偏移量 int offsetX = 0; int offsetY = 0; if (canScrollHorizontally()) { offsetX += getUsableWidth() * page; } else { offsetY += getUsableHeight() * page;