前言
事情是这样的,前段时间正好有个RecyclerView用GridLayoutManager实现网格布局的需求,然后要做分割线,一般这种都是信手捏来的东西,然后我发现这个分割线竟然对不齐。
当然,如果要实现这样的功能,会有很多种方法,包括在itemView加margin、padding等等,都能有办法去实现分割线的效果,但是我这种人就是非要弄清楚其中的问题才舒服。
结论
因为涉及到算法,可能要讲得比较多,所以先说说最终的结论,先看看效果
就是实现这种有分割线并均分布局的效果,我研究到最后发现竟然不是简单一两句代码能解决的,其中还暗藏玄机。这里我处理这个问题会涉及一个算法,所以最终会得到一个公式,我不能保证我的公式是最优的解法,如果有其它更好的公式也可以留言告诉我。
1. 简单的处理分割线
我这里的场景是ItemView是填充,意思就是填充除了分割线以外的布局。
ini
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="@color/purple_200"
android:orientation="vertical">
</LinearLayout>
假如我一开始要做分割线,我简单的去做,会是这样的效果
kotlin
rv.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
outRect.left = 60
}
})
然后你会很自然而然的想这个做
kotlin
rv.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val pos = parent.getChildAdapterPosition(view)
if (pos != 0) {
outRect.left = 60
}
}
})
然后你会发现此时的布局不均分,第一个item更多
注意,我这里的处理问题思路是必须用分割线处理,不然用一些方法确实能更快做到,比如上面的情况,我加个padding也能做,但我这里的思路是要完全用outRect去处理这个问题
看到上面的效果和想象中的不同,没关系,我换个思路,我左右都加间距
kotlin
rv.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val pos = parent.getChildAdapterPosition(view)
outRect.left = 30
outRect.right = 30
}
})
可以看到是均分了,但是如果我的场景需要首尾两个item贴边,那这样就不合适,但是你可能会很快的想到这样做,去判断首尾Item
恭喜你,又失败了,可以看到布局又不是均分了。如果你一直按照这样的简单思路去想,是无法处理这个问题的,因为他不是一个简单的公式就能解决的,所以简单的去思考,也只是浪费时间。
首先需要的是理解他的原理
2. 设置分割线getItemOffsets方法的原理
这里简单讲,不是看源码,而是通过图片去分析(我就简单画点图,可能不是很标准,将就着看)
红色是内容,白色是间距,如果不设置的话,红色的区域就是整个白色,可以抽象的理解成它是往内去缩的,所以如果第一个Item不设置Left,最后一个Item不设置Right,他的效果就会是这样
这就是上面Demo的最后一种情况,这里给你们看一个很有意思的现象,假如我的代码这样写(在3列的情况下)
kotlin
rv.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val pos = parent.getChildAdapterPosition(view)
if (pos == 0) {
outRect.right = 40
} else if (pos == 2) {
outRect.left = 40
} else {
outRect.left = 20
outRect.right = 20
}
}
})
可以看到这样就均分了,是不是很神奇,其实这里用图来画出来是这样的
间距是由一个Item更大的间距加上一个Item略小的间距实现的。
你可能会想,懂了,除了首尾之外,其他的就是填一半间距。真的这么简单吗,可以看看4个效果,同样的代码如果把列数从3个变成4个
你就会发现,中间的分割线会更小一点,你可以算算看,左边的分割线是 40 + 20,而中间的分割线是 20 + 20 ,所以不同。 所以我说这个问题不会这个简单
其实当时我处理不了又比较赶时间,我就去google查,找了几个老哥的代码直接拷贝下来用发现用不了,所以我看深入去思考这个问题。
3. 真正的实现分割线均分布局的操作
来了,重点来了,通过上面的原理你能知道,其实就是把首尾两个Item应该多出的间距,平均分配到每个分割线。但是它不会是一个简单的计算,会是一个偏复杂的问题,数据问题。
当我把他变成数学问题,这个问题就是,我给出固定的分割线宽度,你需要分割线宽度相同,Item的宽度也相同,注意是两个相同,这是一个解题的条件
这个问题如果从正向去解释,我觉得很难说清楚,所以我从反向来解答,假如我有10列(我这里为了方便,先用一行来举例)
图画得不太准,因为准的不好手动画,假设看成间距和Item宽度都相同。我10列那就是有9个间距(9个分割线),假设每条间距是10
那我是不是可以这样分:
间距1:(L1)9 + (R1)1
间距2:(L2)8 + (R2)2
间距3:(L3)7 + (R3)3
间距4:(L4)6 + (R4)4
间距5:(L5)5 + (R5)5
间距6:(L6)4 + (R6)6
间距7:(L7)3 + (R7)7
间距8:(L8)2 + (R8)8
间距9:(L9)1 + (R9)9
他们的间距都不同,但是他们加起来都是10,这是满足了第一个条件,分割线间距相同,还有一个条件,他们的Item宽要相同
从上面的原理我们知道,Item的最终宽度就是总宽度减去左右间距的宽度。Item1的左间距是0,右间距是9,它的宽度是AllWeidth - 9 ,Item2的左间距是1,右间距是8,它的宽度是AllWeidth - (8+1),和Item1是相同的,你可以算算其他的,也是相同的。所以这样就能达到一个均分的效果。
OK,我们来凑公式。上面说过,其实这种场景就相当于10个分割线的间距,把其中一个间距分成每一份去加到其他的间距中,而每一份其实就是最小的份,你看看上面的10列,每一份就是1,所以得出一个公式
min = space / n
然后有了最小,我们还需要算出一个最大的Item的间距,从间距相等我们得知
max = space - min
等理解这两个公式之后,我们再往下看。假设我就拿前面2个Item做分析
L1 = 0 // 最左边的Item没左间距这个应该很容易理解吧
R1 = max // 从上面的模型你能看出,Item1的右间距是最大间距
L2 = space - R1 // 根据间距相等这个条件,R1确认了,L2自然就确认
R2 = max - L2 // 这个是什么意思呢,这个是保证Item的宽度相同,从这个条件根据L2来算出R2。简单来说就是根据第一个Item你知道总间距,你后面的Item也要根据左右间距加起来得到的总间距相等
后一个值要根据前一个值的结果算出,是不是很熟悉,介不是某大厂特别喜欢考的动态规划吗?我见过直接算法题的动态规划,倒是第一次见结合到代码场景里面的,没想到一个小小的RecyclerView能玩这么花。
动态规划,老熟人了,我们能根据上面的分析推出一个公式
ini
rv.addItemDecoration(object : ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
val pos = parent.getChildAdapterPosition(view)
val min : Float = space / n
val max = space - min
if (pos == 0) {
outRect.right = max.toInt()
} else if (pos == (n - 1)) {
outRect.left = max.toInt()
} else {
var index = 1
var oldLeft = 0
var oldRight = max
while (index <= pos) {
val left = space - oldRight
val right = max - left
oldLeft = left.toInt()
oldRight = right
index++
}
outRect.left = oldLeft
outRect.right = oldRight.toInt()
}
}
})
这里的pos == 0这些判断是在我只有1行的前提下才这么演示的,实际别这么写。
现在分析下代码,space是间距宽度,n是列数,min和max上面分析过了,pos == 0只有右边间距并且为max,pos是最后一个只有左边间距并且为max,这个就不用解释了,主要是最后的else
当前的Item的间距需要根据前一个Item的间距算出,所以这里我用了循环,holdLef和oldRight表示前一个Item的左间距和右间距。然后就是用我们推出的公式去计算
Ln = space - R(n-1)
Rn = max - Ln
可以看看效果
可以看到是均分的啦。
优化
本来不想说pos == 0这个判断的,我怕有人直接拉代码出问题说我。上面的代码pos == 0只是为了方便演示1行的情况,如果我在多行用
所以正常使用判断要改下
ini
if (pos % n == 0) {
outRect.right = max.toInt()
} else if ((pos + 1) % n == 0) {
outRect.left = max.toInt()
} else {
var index = 1
var oldLeft = 0
var oldRight = max
while (index <= (pos % n) ) {
val left = space - oldRight
val right = max - left
oldLeft = left.toInt()
oldRight = right
index++
}
outRect.left = oldLeft
outRect.right = oldRight.toInt()
}
size为10,n为5
size为8,n为3
除此之外,还可以看出这个算法的复杂度是O(m*n)
因为getItemOffsets是一个循环,里面的while又是一个循环,所以这里可以优化,我有一个想法,可以用hashmap通过空间来换时间,而且你会发现超过n/2的Item都是之前反着的,所以用hashmap的话你只需要记录第一个行的一半Item的间距,我觉得还是很不错的
还要注意一点,计算时要用Float,最后再转Int,否则全程用Int算可能有点偏差
总结
首先写这篇文章的目的是觉得这其中的算法非常有意思,这个动态规划的过程要推导出这个公式,整个推导的过程能在这其中感受到开发的快乐,所以记住这个公式
L0 = 0
R0 = max
Ln = space - R(n-1)
Rn = max - Ln
其次,我也不敢保证我这个是最佳的解法、最佳的公式,但是我测试目前来看是没问题,所以想用的话可以直接把代码拷去用,当然通过其他的方式也是能处理的,不一定要把思维限制在必须使用ItemDecoration去实现。
解算法的过程是痛苦的,但是解出来之后,那就非常的爽