从零开始实现SnakeLayoutManager(原创)

从零开始实现SnakeLayoutManager(原创)

界面原型图

图片描述

没错,就长这个亚子,扭来扭去的。

实际效果图

图片描述

我们该怎么去思考呢???

其实仔细一点的同学就会发现,这玩意和网格看起来略有相似之处嘛~ 那不妨用RecyclerView的GridLayoutManager去布局咯,事实上这样的方案是可行的,不过需要处理position,在第2行开始(行数从0开始),将position倒一下序才行,而且还需要让第1列(列数就从0开始)和第3列单独占一行,还得判断是在左边还是右边,这样处理起来还是比较麻烦的,因为你需要在适配器、布局管理器等地方进行设置才行,无疑这是一个不太优雅的解决方案。

我们该如何优雅的实现这个布局呢???

我们知道RecyclerView是通过LayoutManager来实现各种各样的布局效果的,也就是说LayoutManger是控制如何摆放ItemView的,所以我们可以自定义LayoutManger,这样我们就可以像 LinearLayoutManager 一样使用这个效果了!那我们就自定义一个SnakeLayoutManager咯~(因为这个布局和一条蛇一样扭来扭去的??)

找布局的规律

在自定义布局管理器之前,我们先来找找这个Snake布局的规律吧,我们先从简单的开始,其他列数的规律其实是类似的,我用Excel画了个表,用来直观的观察这个规律。

图片描述

可能大家没看出来有啥规律,但是脑瓜子灵光的小伙伴会在这里建立一个坐标系(我就是,哈哈哈哈 皮一下很开心??)


图片描述


那么position和坐标之间的对应关系如下表格所示

图片描述

我们可以看出,x轴的坐标是每隔三个相同,然后加1,完了之后再加1,以此类推,y轴的坐标则是按照 0、1、2、2、2、1、0、0 这样的规律重复出现的,既然每行3列的是这样,那效果图中的每行6列是不是也是类似的规律呢?? 答案是肯定的,有兴趣的小伙伴可以自己去试一下,下面给出了每行3列,每行4列,每行5列的效果图进行对照,至于6列的就没画了(还不是因为懒??)。


图片描述

布局管理器代码部分

既然规律都知道了,那就上代码吧,注释都很详细了,就没必要再解释了,相信大家都能看懂~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
public class SnakeLayoutManger extends RecyclerView.LayoutManager {

private static final String TAG = "SnakeLayoutManger";
//默认一行最多几个
public static final int DEFAULT_SPAN_COUNT = 6;
private int mSpanCount = DEFAULT_SPAN_COUNT;
public static final int DEFAULT_TURNING_POINT_POSITION = 5;
//转折点的位置
private int mTurningPointPosition = DEFAULT_TURNING_POINT_POSITION;

public SnakeLayoutManger() {

}

public SnakeLayoutManger(int spanCount, int turningPoint, int... rule) {
mSpanCount = spanCount;
mTurningPointPosition = turningPoint;
mXIndexList.clear();
for (int xPosition : rule) {
mXIndexList.add(xPosition);
}
}

public SnakeLayoutManger(int spanCount, int turningPoint, @NonNull List<Integer> rule) {
mSpanCount = spanCount;
mTurningPointPosition = turningPoint;
mXIndexList.clear();
mXIndexList.addAll(rule);
}

/**
* 获取一行最多有多少列
*
* @return
*/
public int getSpanCount() {
return mSpanCount;
}

/**
* 获取拐点的位置
*
* @return
*/
public int getTurningPointPosition() {
return mTurningPointPosition;
}

/**
* 生成默认布局参数(这个方法必须重写)
*
* @return
*/
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT);
}

//是否已经布局过了
private boolean isLayoutChildren = false;
//存放摆放ItemView规律的集合
private List<Integer> mXIndexList = new ArrayList<>();

/**
*
* ==========第一次完整的规律 start=========
* (0,0) (0,1) (0,2)
* (1,2)
* (2,0) (2,1) (2,2)
* (3,0)
* ==========第一次完整的规律 end===========
*
* ==========第二次完整的规律 start=========
* (4,0) (4,1) (4,2)
* (5,2)
* (6,0) (6,1) (6,2)
* (7,0)
* ==========第二次完整的规律 end===========
*
*/
{
//x轴坐标排列的规律(需要一次完整的规律,后续的摆放规律都从这里获取)
mXIndexList.add(0);
mXIndexList.add(1);
mXIndexList.add(2);
mXIndexList.add(3);
mXIndexList.add(4);
mXIndexList.add(5);
mXIndexList.add(5);
mXIndexList.add(5);
mXIndexList.add(4);
mXIndexList.add(3);
mXIndexList.add(2);
mXIndexList.add(1);
mXIndexList.add(0);
mXIndexList.add(0);
}

//1、在RecyclerView初始化时,会被调用两次。
//2、在调用adapter.notifyDataSetChanged()时,会被调用。
//3、在调用setAdapter替换Adapter时,会被调用。
//4、在RecyclerView执行动画时,它也会被调用。
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = getItemCount();
//如果Item的个数为0,则认为没必要对ItemView进行布局
//state.isPreLayout() 是否支持动画
if (itemCount == 0 || state.isPreLayout()) return;
if (mXIndexList == null || mXIndexList.size() == 0) {
throw new RuntimeException("======== mXIndexList is null or mXIndexList is empty!");
}
if (isLayoutChildren) return;
//分离并报废附加视图(解除ItemView的绑定)
detachAndScrapAttachedViews(recycler);
//容器的宽度
int containerWidth = getWidth();
//ItemView在y轴上的坐标
int yPosition = 0;
//用来判断是否y轴坐标需要增加
int count = 0;
for (int i = 0; i < itemCount; i++) {
View targetView = measure(recycler, i);
//获得ItemView测量后的宽度
int width = getDecoratedMeasuredWidth(targetView);
//获得ItemView测量后的高度
int height = getDecoratedMeasuredHeight(targetView);
int itemViewSize = Math.min(width, height);
//ItemView的margin值 = (总的宽度 - padding值 - 一行Item所占的总宽度)/ 一行Item的个数
int margin = (containerWidth - getPaddingLeft() - getPaddingRight() - itemViewSize * getSpanCount()) / getSpanCount();
int xIndex = i % mXIndexList.size();
//获取ItemView在x轴上的坐标
Integer xPosition = mXIndexList.get(xIndex);
Log.d(TAG, "currentPosition============>xPosition:" + xPosition + " yPosition:" + yPosition + " index:" + i);
//左边界的位置
int left = xPosition * (width + margin);
//有边界的位置
int right = (xPosition + 1) * (width + margin);
//上边界的位置
int top = yPosition * (height + margin);
//下边界的位置
int bottom = (yPosition + 1) * (height + margin);
//根据边界值摆放ItemView
layoutDecorated(targetView, left, top, right, bottom);
if (betweenAnd(0, getTurningPointPosition(), count)) {
//如果在转折点范围之内,并且等于转折点的x轴坐标,则y轴坐标+1
if (count == getTurningPointPosition()) {
yPosition += 1;
}
count++;
} else {
//如果大于转折点的x轴坐标,将当前次数重新初始化,并将y轴坐标+1
count = 0;
yPosition += 1;
}
}
isLayoutChildren = true;
}

/**
* 判断是否在最大值和最小值之间 [min,max]
*
* @param minValue
* @param maxValue
* @param targetValue
* @return
*/
private boolean betweenAnd(int minValue, int maxValue, int targetValue) {
return targetValue <= maxValue && targetValue >= minValue;
}

/**
* 测量并添加item
*
* @param recycler
* @param index
* @return
*/
private View measure(RecyclerView.Recycler recycler, int index) {
//获取该位置的ItemView
View targetView = recycler.getViewForPosition(index);
//将ItemView添加到容器中
addView(targetView);
//测量带边距的孩子
measureChildWithMargins(targetView, 0, 0);
return targetView;
}

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//界面向下滚动的时候,dy为正,向上滚动的时候dy为负
//填充
//fill(dy, recycler, state);
//滚动
offsetChildrenVertical(-dy);
//回收已经离开界面的
//recycleOut(dy, recycler, state);
return dy;
}

@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
//填充
//fill(dy, recycler, state);
offsetChildrenHorizontal(-dx);
//回收已经离开界面的
//recycleOut(dy, recycler, state);
return dx;
}

private void fill(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
//向下滚动
if (dy > 0) {
// TODO: 2020/7/5 向底部填充
} else {
//向上滚动
// TODO: 2020/7/5 向顶部填充
}
}

/**
* 回收ItemView
*
* @param dy
* @param recycler
* @param state
*/
private void recycleOut(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
for (int i = 0; i < getChildCount(); i++) {
View view = getChildAt(i);
if (view == null) return;
if (dy > 0) {
if (view.getBottom() - dy < 0) {
LogUtils.d("recycleOut==============> " + i);
removeAndRecycleView(view, recycler);
}
} else {
if (view.getTop() - dy > getHeight()) {
LogUtils.d("recycleOut==============> " + i);
removeAndRecycleView(view, recycler);
}
}
}
}
}

写在最后

当然,这个SnakeLayoutManager还有很多可以优化的地方,比如处理滚动事件和复用问题。本文只是介绍如何进行布局,感兴趣的同学可以自己实现一下。

如果觉得本文不错,点个赞再走呗~


允许转载至《阳光沙滩》微信公众号,其他转载请联系:[email protected]


从零开始实现SnakeLayoutManager(原创)
https://blog.funkt.cn/2020/07/06/1279956304571441152/
作者
anjiemo
发布于
2020年7月6日
许可协议
未经作者授权,禁止转载