在做了这么长时间的Android开发,还没有遇到过这个需求,不过看了别人的很多效果,感觉很棒,所以找了时间就研究了一下,现在做一些记录,等以后有了相关需要可以快速回顾。
在我学习的过程中,不可避免的遇到了很多问题,有的已经解决,有的还未解决,所以这个 Demo 就是看个乐呵吧。
自定义一个 LayoutManager 整体给我的感觉是与实现自定义 ViewGroup 的 onLayout 比较像。其他的测量绘制方法都不需要我们实现,测量方法还有很多可以直接使用的:
androidx.recyclerview.widget.RecyclerView.LayoutManager#measureChild
androidx.recyclerview.widget.RecyclerView.LayoutManager#measureChildWithMargins
这两个方法可以测量 child 的大小,一个不计算 child.layoutParams,一个计算。
测量完成之后,我们就可以获取 child 的大小了:
androidx.recyclerview.widget.RecyclerView.LayoutManager#getDecoratedMeasuredWidth
androidx.recyclerview.widget.RecyclerView.LayoutManager#getDecoratedMeasuredHeight
这里不直接使用 child.getMeasureWidth 显然是因为 RecyclerView 是有一个 ItemDecorate 可以设置,不设置就是获取的 getMeasureWidth 的值。
更完美的是,layout child 的时候,也有相应的方法:
androidx.recyclerview.widget.RecyclerView.LayoutManager#layoutDecoratedWithMargins
使用这个方法,我们就可以将 child 摆到我们想要摆放的位置了,我们只需要传递四大金刚的位置:l,t,r,b。网上看到一个效果就是,它计算出某个 path 的所有点,然后存起来,根据滚动的距离,来取相应的点,然后根据这个点以及child的大小,就可以将这个 child 摆到 path 的路径上,实现一个 item 跟随 path 的效果。只要想清楚了还是不难的。
了解这些,我们还需要了解一下 RecyclerView 的回收机制,因为 RecyclerView 最吊的地方就是回收复用,如果你搞了一个 LayoutManager 但是却无法回收复用,那岂不是很沙雕,关于回收这里就不仔细讲了,看[我的另一篇文章](https://github.com/aprz512/blog4aprz512/blob/master/Blog/Android-View/RecyclerView 的缓存机制.md) 吧。
有了上面这些基础,我们就可以开始动手写了。
首先,我们需要确定我们想要的效果,我们先看一下这个效果图:
[外链图片转存失败(img-FzCl7VoA-1565004606632)(https://github.com/aprz512/pic4aprz512/blob/master/Blog/Android-View/layoutmanager/Gif_20190805_140935.gif?raw=true)]
可以看到:
对效果了然于胸,我们就可以开始敲代码了,首先自然是继承父类:
androidx.recyclerview.widget.RecyclerView.LayoutManager#LayoutManager
它只有一个抽象方法:
override fun generateDefaultLayoutParams(): LayoutParams {
return LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT
)
}
首先,网上一致都是这样实现的,我就很蛋疼了,都不说为什么。
然后,我就去查看了注释,它是这样说的:为 RecyclerView 的 child 生成一个默认的 LayoutParams。那么为何要生成一个默认的 LayoutParams 呢?比如,有的同学在 Adapter 里面加载布局的时候,parent 会传 null,这个时候 child 的就是没有 LayoutParams 的,所以需要生成一个。同样的,PopupWindow 也会遇到。这里我就清楚了,传递 WRAP_CONTENT 是一个保险行为,有最好,没有就使用这个。
注释还说了,这个返回的是 RecyclerView.LayoutParams,所以如果你还想带一些自带的信息在 LayoutParams 里面,你可以继承这个 LayoutParams,然后实现下面3个方法:
androidx.recyclerview.widget.RecyclerView.LayoutManager#checkLayoutParams
androidx.recyclerview.widget.RecyclerView.LayoutManager#generateLayoutParams(android.view.ViewGroup.LayoutParams)
androidx.recyclerview.widget.RecyclerView.LayoutManager#generateLayoutParams(android.content.Context, android.util.AttributeSet)
就 OK 了。
搞定了唯一的一个抽象方法,我们就可以正常运行了,但是没啥效果,我们还需要实现 child 的摆放与回收。首先我们搞定 child 的摆放。child 的摆放是在
androidx.recyclerview.widget.RecyclerView.LayoutManager#onLayoutChildren
这个方法里面。我们复写一下:
override fun onLayoutChildren(recycler: Recycler?, state: State?) {
super.onLayoutChildren(recycler, state)
}
好奇的点进去看看父类做了什么:
public void onLayoutChildren(Recycler recycler, State state) {
Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, State state) ");
}
嗯,他要我们必须重写这个方法,但是又不是抽象的方法,嗯…有趣的女人…
一开始,我是完全不知道应该怎么搞了,所以我看了别人的 demo,发现他们都在最开头搞了这样的一个开头:
// 这个方法看不出来有啥意义啊,应该是根据
// androidx.recyclerview.widget.LinearLayoutManager.onLayoutChildren
// copy 出来的
if (state?.itemCount == 0) {
recycler?.apply {
removeAndRecycleAllViews(this)
}
return
}
嗯,我是很奇怪的,为啥要写这个玩意啊,后来我戳到 LinearLayoutManager 学习了一下,发现它有一个类似的代码,但是它是嵌套在 if 里面的:
if (mPendingSavedState != null || mPendingScrollPosition != RecyclerView.NO_POSITION) {
if (state.getItemCount() == 0) {
removeAndRecycleAllViews(recycler);
return;
}
}
这里,我是真的没有搞懂哦。除非是 itemCount 突然变成 0 了,那么需要将所有 child 都移除并且放到 recycler 里面,但是为啥 LinearLayoutManager 是有条件的呢???嗯,猜一下与动画相关吧…
好的,我们继续往下,这里只不过是一个前置处理,处理某些特殊情况,下面我们开始摆放 child,为了方便我们写一个方法:
override fun onLayoutChildren(recycler: Recycler?, state: State?) {
super.onLayoutChildren(recycler, state)
// 这个方法看不出来有啥意义啊,应该是根据
// androidx.recyclerview.widget.LinearLayoutManager.onLayoutChildren
// copy 出来的
if (state?.itemCount == 0) {
recycler?.apply {
removeAndRecycleAllViews(this)
}
return
}
layoutChildren(recycler, state)
}
这样看着会舒服一点,一般我不知道咋下手的时候,就会抽一个方法出来,把能写的都写了,至少思路清晰。
开始摆放 child,我们需要计算出第一个 item 的 left:
var left = paddingLeft
嗯,很简单。
这个时候,就有一个问题浮现出来了,我们的每个 item 是一样大吗???
如果是的话,好说,但是 RecyclerView 就成了岳不群了。如果不是的话,那就复杂了。这里为了简单,我们要求 item 是一样的,毕竟,不一样大也没法堆叠。所以其实仔细想想,实现一个看起来特别酷的效果,限制也很多。
我们定义两个索引值,一个指向屏幕上的第一个 item,一个指向屏幕上的最后一个 item 的后一个位置,就像一个半开区间。
private var firstVisiblePos = 0
private var lastVisiblePos = 0
然后,我们随便取一个 item,测量一下它的大小:
// 需要每个child一样大小
val firstView: View = recycler.getViewForPosition(firstVisiblePos)
measureChildWithMargins(firstView, 0, 0)
unitDistance = getDecoratedMeasuredWidth(firstView) + gap
// 这个时候,还没有开始摆放,所以用完了再放回去,为了后面的逻辑统一处理
recycler.recycleView(firstView)
这里的逻辑很简单,算出 child 的大小,加上 item 之间的距离,算作一个单元距离,就是一个item从一个位置移动到相邻位置的绝对距离。
这里要说的一个重要的点就是,如果我们需要摆一个 child,只能像 recycler 要,并且,用完了还给 recycler,这样我们神不知鬼不觉的就达成了复用的效果。
接着,我们根据滚动的距离来计算出第一个item的索引:
// 根据 scroll 的距离来计算 firstPos 的位置
firstVisiblePos = floor(abs(scrollX).toDouble() / unitDistance).toInt()
这里,我们也可以利用 RecyclerView 的宽度算出 lastVisiblePos 的值,但是就有点重复了,我们在摆放 child 的时候去动态的计算会更好一点,所以这里,我们将 lastVisiblePos 赋值为 itemCount。
// 该值会动态更新
lastVisiblePos = state.itemCount
计算好了两个索引值,我们还需要处理一个问题,就是根据滚动的距离来计算出 View 的偏移距离。
val frac: Float = (abs(scrollX) % unitDistance) / (unitDistance * 1f)
val stackOffset = (frac * stackGap).toInt()
val viewOffset = (frac * unitDistance).toInt()
unitDistance 是相邻item的单位距离,frac就表示移动到的百分比。利用这个百分比换算出堆叠区域和普通区域布局起始位置的偏移量,然后可以开始布局了。
// 属于堆叠区域
if (i - firstVisiblePos < MAX_STACK_COUNT) {
// 手指向左滑动,则 scrollX 的值会越来越大,frac 也会慢慢变大(0 -> 1 为一个周期)
// item 会向右移动
// 这里需要减去,item 才会向左移动
left -= stackOffset
}
这里又有几个问题:
if (i - firstVisiblePos < MAX_STACK_COUNT) {
if (!stackOffsetDone) {
left -= stackOffset
stackOffsetDone = true
}
left += stackGap
}
这个 left 就是 child 布局时用到的值了。对于非堆叠区域同样处理。
后面的代码就简单了:
val view = recycler.getViewForPosition(i)
addView(view)
measureChildWithMargins(view, 0, 0)
val l = left
val t = paddingTop
val r = l + getDecoratedMeasuredWidth(view)
val b = t + getDecoratedMeasuredHeight(view)
layoutDecoratedWithMargins(view, l, t, r, b)
把上面的这些逻辑放入循环就搞定了。
这里效果基本就实现了,但是实际上测试的时候会发现,回收复用会有问题。这个有两个方面的问题:
所以,我们可以不用自己一个一个的手动回收,而是可以这样:
在 layout 的前面调用 detachAndScrapAttachedViews ,然后在最后回收。
private fun layoutChildren(
recycler: Recycler?,
state: State?
) {
if (recycler == null || state == null) {
return
}
detachAndScrapAttachedViews(recycler)
...
val scrapList = recycler.scrapList
for (i in scrapList.indices) {
val holder = scrapList[i]
removeAndRecycleView(holder.itemView, recycler)
}
}
实际上我测试的效果比之前好了很多,但是我还是不太满意,因为有的时候还是会出现 createViewHolder 的调用,虽然次数极少,但是肯定是哪里出了问题,不然是不会这样的。
所以建议还是手动的一个个标记回收。我也对回收这个问题还有疑问,所以这里就不说下去了。
除了回收还有一个问题,就是关于 fling 效果。我测试这个demo 的时候,发现我从最后一个一下滑动到第一个的时候,item停留的位置总是不对,我一直以为是我的计算有问题:
/**
* dx(dy) 表示本次较于上一次的偏移量,<0为 向右(下) 滚动,>0为向左(上) 滚动;
* 这个算法还是无法满足 fling 的要求,fling 的时候 停留的位置不对
* 查了一些资料,可能还需要自定义一个 SnapHelper -> https://www.jianshu.com/p/0e4a93d8e2de
*/
private fun consume(dx: Int): Int {
val consumed: Int
// dx < 0 表示向右滚动,需要显示左边的内容
if (dx < 0) {
// 到了最左边
if (scrollX + dx < 0) {
consumed = if (scrollX > 0) {
dx - scrollX
} else {
0
}
scrollX = 0
return consumed
}
}
// dx > 0 表示向左滚动,右边的内容需要显示出来
if (dx > 0) {
if (scrollX + dx > maxScrollX) {
consumed = if (scrollX < maxScrollX) {
maxScrollX - scrollX
} else {
0
}
scrollX = maxScrollX
return consumed
}
}
scrollX += dx
return dx
}
但是,经过我的打印,发现,这个计算是没有问题了。
顺便说一下,要想滑动,需要重写 LayoutManager 的方法:
override fun canScrollHorizontally(): Boolean {
return true
}
然后,在 scrollHorizontallyBy 返回消耗的值:
override fun scrollHorizontallyBy(dx: Int, recycler: Recycler?, state: State?): Int {
if (dx == 0 || state?.itemCount == 0) {
return 0
}
layoutChildren(recycler, state)
return consume(dx)
}
我就感觉这个与 NestedScrolling 接口很相似。
跑题了,关于 fling,查了一些资料,可能还需要自定义一个 SnapHelper -> https://www.jianshu.com/p/0e4a93d8e2de。
当然你也可以自己监控滑动状态,然后自动的调整滑动位置,就像画廊一样。
项目地址: