LEEDOM

May 17, 2022

自定义LayoutManager实现无限轮播

方案演进

本着最快开发以实现的原则,第一版的实现方案是以ViewPager2来实现的,这个版本的大致逻辑如下:

  • 实现无限列表:通过AdapterItemCount+2,在原有数据的count上加了两个,相当于在首尾新增了两个转换用的position,当滑动到最后一个position时,在RecyclerViewonSelect()方法里,通过setCurrentItem来跳到数据源的第一个item,也就是对应的position = 1。当滑动到第一个position时,也通过setCurrentItem来跳到数据源的最后一个item,也就是对应的position = data.lastIndex
  • 两边变小通过添加ViewPagerTransform来实现,直接给item设置Scale属性就行了;

这个方案在设计的时候感觉没有什么问题,将就也能使用,但是最后实现发现还是有一些瑕疵,主要有以下问题:

  1. 无限列表的效果上,因为ViewPager2是基于RecyclerView来实现的,所以他提前Load的是下一个position的item,我们的item跳转逻辑是在业务层实现的,没有干预到这个,就会有当滑动到数据源的边界(0或者data.lastIndex)时,左边的或者右边的Item会整个加载一下,因为LayoutManager实际上没有生成边界值之后的item,这个加载一下没有办法从业务层解决;

  2. UI小姐姐要求的item之间的间距显示效果是相对一定的,而我在最后实现的版本里,是效果不一定的,会根据不同的设备有差异,这是因为item要求高度自适应,所以item是按照比例来决定高度的,整个ViewPager也是由item的高度来决定的,因为没有干预到onMeasure的过程,这个时候没有办法再去计算间距,所以间距是一开始就设置死了的;而写死就会导致在不同设备的显示效果有差异;

  3. UI小姐姐要求整个列表在垂直上处于整体偏上的位置,翻译成Android的话就是约束布局的verticalBais = 0.3,这个方案目前也没有办法实现;

  4. UI小姐姐要求可以Fling滑动;

  5. 无线列表的实现是业务层做的,搞了很多骚操作的逻辑,后来业务里多了好几个无限列表的场景,复用起来很困难;

所以在第二版的方案里选择了自定义LayoutManager,以解决上诉问题。

整体思路

针对上诉的问题,对应的解决方案思路:

  1. LayoutManager中实现无限列表可以说比ViewPager2的实现更加简介一些,因为直接干预的是onLayout的过程,这个逻辑里在布局时,可以直接获取逻辑上的position,比如居中的是数据源上的最后的一个item,那么右边的item就应该是数据源的第一个item,在layout时,可以通过计算直接去获取position = 0的View进行布局,这样实际在那儿的也是对应的position = 0的视图;
  2. 同样的因为干预了layout的过程,所以间距的适配也比较容易,在自定义的LayoutManager中的onMeasure方法里可以进行测量,从而决定自己的高度,这样更加符合Android布局测量的过程,第3点也对应解决了;
  3. 自定义LayoutManager本来就是Fling的;
  4. 自定义LayoutManager从实现上屏蔽了很多业务逻辑,对于使用者来说和普通使用LayoutManager 是一样的,暴露的方法和行为都是一致的;

不过使用自定义也需要解决一些新的问题,如ViewPager2处理的滑动翻页和居中;之前考虑的使用SnapHelper来实现居中滑动,测试使用LinearLayoutManager是很完美的,但是自定义的LayoutManager就没有那么完美了,因为滑动的处理是根据物理模型推算的滑动距离,所以会有滑过或者没有滑动到正好居中,带来的问题就是滑动的Fling结束后,会被SnapHelpersnapToExistingView()方法再滑动到最中间的位置,这样会有一个顿挫感;

本来想尝试自定义SnapHelper来实现,但是尝试之后发现不行,因为SnapHelper的滑动实现是基于Position的,通过Position找到对应的View,再计算出距离,而我们实现无限列表的逻辑,是无法基于Position来计算距离的,因为你不知道需要找的那个Position是循环了多少次后的位置,而SnapHelper只会寻找真实的Position

所以直接使用系统的SnapHelper是没法实现对应的效果,这里通过阅读了SnapHelper的源码,发现可以通过自己计算来实现对应的效果。具体的逻辑就是,自己继承RecyclerView.OnFlingListener来处理onFling方法,onFling方法中会传入在RecyclerView处理好的速度,我们拿到这个速度后,根据自己设置的Scroller来计算会滑动的距离,然后直接调用RecyclerViewsmoothScrollBy()方法,这个方法最终会调用到LayoutManagerscrollHoriznortalBy,也就进入到我们自己的逻辑范围里了,那么这一整套逻辑我们的控制权就都掌握了。

具体实现

实现测量

测量的逻辑比较简单,如前面所说,让子View自己去测量,再将RecyclerView的高度设置为子View的高度就行了,因为子View是根据比例去计算的,我这里就根据需要显示的Item个数计算每一个Item的宽度,再调用View的测量让它自行完成高度的测量,伪代码如下:

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
 override fun onMeasure(
recycler: RecyclerView.Recycler,
state: RecyclerView.State,
widthSpec: Int,
heightSpec: Int
) {
if (state.itemCount == 0) {
super.onMeasure(recycler, state, widthSpec, heightSpec)
return
}

if (state.isPreLayout) return

val wSize = View.MeasureSpec.getSize(widthSpec)
val wMode = View.MeasureSpec.getMode(widthSpec)
val hMode = View.MeasureSpec.getMode(heightSpec)

// 计算中间显示的view的宽度
itemWidth = (wSize - (fixSpace() * (showCardCount - 1))) / (showCardCount - 1)
detachAndScrapView(recycler.getViewForPosition(0).apply {
this@ArtLayoutManager.addView(this)
measure(View.MeasureSpec.makeMeasureSpec(itemWidth, wMode), heightSpec)
itemHeight = getDecoratedMeasuredHeight(this)
}, recycler)
...
setMeasuredDimension(widthSpec, View.MeasureSpec.makeMeasureSpec(itemHeight, hMode))
}

需要记得测量完成后,将这个View进行回收;

实现布局

自定义LayoutManager实现onLayoutChildren的方法,布局主要是三个步骤,首先是回收掉现有的Views,将他们detach掉,这个操作会通过Recycler来实现,具体的逻辑我们交过RecyclerView去做,只需要知道需要先回收他们;

1
2
3
4
5
6
override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
detachAndScrapAttachedViews(recycler)
if (state.isPreLayout || state.itemCount == 0) return
layoutChildrenWithIndex(recycler)
...
}

然后通过Position再从Recycler中去获取对应的View,这个View的来源可能是RecyclerView的几级缓存,也可能是调用AdaptercreateBinder等方法创建的,这里不需要关心他的来源,只需要将这个View添加,测量并布局即可,我们需要计算它应该怎么布局,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 private fun layoutChildrenWithIndex(recycler: RecyclerView.Recycler) {
...
var leftOffset = 0
for (i in 0..showCardCount) {
val position = fixPosition(anchorPosition + i)
attachCacheOrCreate(position) {
val view = recycler.getViewForPosition(position)
addView(view)
measureChild(view, width - itemWidth, 0)
layoutDecorated(
view,
anchorOffset + leftOffset,
0,
anchorOffset + leftOffset + itemWidth,
itemHeight
)
}
leftOffset += (itemWidth + fixSpace())
}
}

核心的逻辑就是根据需要显示的数量,使用锚点position(左边第一个position被我定义为)加显示偏移数获取到对应positionView

然后就是计算需要layout的位置,这里有两个变量来控制anchorOffsetleftOffsetanchorOffset代表初始化时,第一个position的View的初始位置,在没有滑动的情况下,其值为anchorOffset = -itemWidth / 2,这个时候的布局模型如下,很容易能理解这个值的由来,然后分别是左滑和右滑会对应不同的初始化,在后面滑动的时候会解锁;leftOffset比较简单,就是累加的已经布局的数值。

另一个需要注意的点是,遍历的区间是**[0, showCardCount],也就是说实际的个数会是showCardCount+1*,这里之所以需要额外的一个是用来处理滑动过程中正好没有出现最左边或者最右边的Item这种临界状态,如果没有这个多的,调用 offsetChildrenHorizontal()方法会无效,因为子View们加起来的宽度正好是RecyclerView*的宽度,只有多一个才能左右滑动;

最后是fixPosition()方法,就是实现无限列表的逻辑:

1
2
3
4
5
6
7
8
/**
* 实现无限列表的逻辑
*/
private fun fixPosition(position: Int) = when {
position >= itemCount -> position % itemCount
position < 0 -> itemCount - 1
else -> position
}

因为这里每次获取的View数量就是屏幕上的显示数量加1,所以不需要额外进行第三个步骤,回收多余的View,在常规的自定义LayoutManager的实现里,往往在layout(),实际attach的view数量是大于屏幕需要显示的view数量的,所以需要对屏幕外的view进行detach和Scrap操作

实现滑动

滑动的逻辑处理主要在scrollHorizontallyBy()方法中,这个方法在ActionEvent.MOVE事件和Fling中都会被调用,在这个方法里需要做的事主要是根据滑动的距离,偏移子View,如果最左边或者最右边的View已经偏移完了,就需要layout新的Item进来。实现的思路上和*LinearLayout大体上差不过,只不过我这里是一个简版的,很多情况没有去处理。

先根据入参dx的正负来判断滑动的方向,分开处理左滑和右滑:

  • 左滑:dx<0是左滑的情况,先获取到当前最左边的View,去判断它的左边在哪儿,

    • 如果它的左边是小于0的,说明这个Item还没有整个滑出来,那么直接调用offsetChildrenHorizontal(recycler)偏移所有的子View就行了;以下是伪代码

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      dx < 0 -> { // 左滑
      var scrolled = 0
      while (scrolled > dx) {
      val view = getChildAt(0) as View
      val hangingLeft = max(0, -getDecoratedLeft(view))
      val scrollBy = min(hangingLeft, scrolled - dx)
      offsetChildrenHorizontal(scrollBy)
      scrolled -= scrollBy
      ...
      }
      scrolled
      }

      这里通过while不断计算滑动的值,解释下hangingLeftscrollBy的计算逻辑,hangingLeft代表的是当前最左边的View还可以滑动的最大值,对应的就是没有滑出来的部分,当整个View都滑出后,就需要重新layout,这个时候最左边的View就会从当前这个position,变成position-1的View了,那么-getDecoratedLeft(view)就会变成整个item的宽度,又可以整个偏移滑动了;而scrollBy就是看剩下没有滑动的距离LeftView还没有滑出的距离哪个小了,滑动小的部分,以便处理这个Item整个滑出来后还没有来得及layout 的情况;

    • 如果它的左边已经大于0了,说明这个Item整个已经滑动出来了,这个时候就需要重新layout,并对锚点position做一个左偏移了。额外注意的是因为重新调用了layout的方法,那么整体偏移量需要进行重置;

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 已经滑动到边界,需要新增item了
      if (scrollBy == 0 && scrolled > dx) {
      anchorPosition = fixPosition(anchorPosition - 1)
      // 以showCardCount=3为例,整个视图里可以容纳两个完整的card和两个space,所以当滑动到左边界时
      // 新的视图显示的情况是anchorView|space|leftView|space|...|,其中anchorView|space 是需要新layout的
      // ┌─────────────┐
      // │RecyclerView │
      // ┌─┴─────────────┴─┐
      // ┌───────┐├───────┐┌───────┐│
      // │anchor ││ left ││ right ││
      // └───────┘├───────┘└───────┘│
      // └─────────────────┘
      anchorOffset = -itemWidth - fixSpace()
      layoutChildrenWithIndex(recycler)
      }

      代码里有个简易的图来说明anchorOffset的值的计算逻辑,看图应该很清晰了;

完整的代码如下:

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
override fun scrollHorizontallyBy(
dx: Int,
recycler: RecyclerView.Recycler,
state: RecyclerView.State
): Int {
if (itemCount == 0 || state.isPreLayout) return 0
return when {
itemCount == 0 -> 0
dx < 0 -> { // 左滑
var scrolled = 0
while (scrolled > dx) {
val view = getChildAt(0) as View
val hangingLeft = max(0, -getDecoratedLeft(view))
val scrollBy = min(hangingLeft, scrolled - dx)
offsetChildrenHorizontal(scrollBy)
scrolled -= scrollBy
// 已经滑动到边界,需要新增item了
if (scrollBy == 0 && scrolled > dx) {
anchorPosition = fixPosition(anchorPosition - 1)
// 以showCardCount=3为例,整个视图里可以容纳两个完整的card和两个space,所以当滑动到左边界时
// 新的视图显示的情况是anchorView|space|leftView|space|...|,其中anchorView|space 是需要新layout的
// ┌─────────────┐
// │RecyclerView │
// ┌─┴─────────────┴─┐
// ┌───────┐├───────┐┌───────┐│
// │anchor ││ left ││ right ││
// └───────┘├───────┘└───────┘│
// └─────────────────┘
anchorOffset = -itemWidth - fixSpace()
layoutChildrenWithIndex(recycler)
}
}
scrolled
}
dx > 0 -> { // 右滑
var scrolled = 0
while (scrolled < dx) {
val rightView = getChildAt(childCount - 1) as View
val hangingRight = max(0, getDecoratedRight(rightView) - width)
val scrollBy = min(hangingRight, dx - scrolled)
offsetChildrenHorizontal(-scrollBy)
scrolled += scrollBy

// 已经滑动到边界,需要新增item了
if (scrollBy == 0 && scrolled < dx) {
anchorPosition = fixPosition(anchorPosition + 1)
// 以showCardCount=3为例,整个视图里可以容纳两个完整的card和两个space,所以当滑动到右边界时
// 新的视图显示的情况是anchorView|space|leftView|space|centerView|space|rightView,其中anchorView已存在的,其现在的位置就是-itemWidth
// ┌─────────────┐
// │RecyclerView │
// ┌─┴─────────────┴─┐
// ┌───────┤┌───────┐┌───────┤
// │anchor ││left ││right │
// └───────┤└───────┘└───────┤
// └─────────────────┘
anchorOffset = -itemWidth
layoutChildrenWithIndex(recycler)
}
}
scrolled
}
else -> 0
}
}

需要注意的是右滑的处理中,使用的是childCount - 1,因为childCountRecyclerView维护的当前显示的Item个数;

实现居中滑动

上面的代码加上LinearSnapHelper可以简单的实现居中滑动,但是效果不好,如上所说,有一个顿挫感。自己实现的SnapHelper大体的结构和系统的SnapHelper差不多,唯一区别在最后的计算方式和滚动上:

  1. 首先是计算模型,要正好滑动到目标position的中间,需要先使用Scroller通过加速度计算出最远的滑动距离
  2. 根据计算的滑动距离判断落在了哪个position上,这里的position是把无限列表铺开了后计算的,如position下标排列为3012301230这样。
  3. 因为滑动的起点是居中Item的中间,终点也是中间,中间滑过的距离就是相差的position乘以itemWidth+间距;

通过上面的逻辑实现后,发现还有一个问题,就是在接受到Fling事件时,因为MOVE事件滑动的View已经不居中,所以还需要减去已经偏移量

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
private fun snapFromFling(
velocityX: Int,
velocityY: Int
): Boolean {
// 根据速度计算滑动的距离
scroller.fling(
0,
0,
velocityX,
velocityY,
Int.MIN_VALUE,
Int.MAX_VALUE,
Int.MIN_VALUE,
Int.MAX_VALUE
)
// 根据距离计算能滑动多少个position
val deltaPositions = scroller.finalX / (itemWidth + fixSpace())
// 滑动deltaPositions个item的像素
recyclerView.smoothScrollBy(
(fixSpace() + itemWidth) * deltaPositions + getCenterViewOffset(),
scroller.finalY,
interpolator
)
return true
}

/**
* 获取到居中的view当前便宜中线的偏移量
* 处理场景,1.滑动距离小于半个item时,需要回弹
* 2.fling事件之前是move事件,在fling时已经偏移了一段距离,计算fling最终的position时需要减去这段距离
*/
private fun getCenterViewOffset(): Int {
if (childCount == 0) return 0
var centerView = getChildAt(0) as View
var minDistance =
abs((getDecoratedRight(centerView) - getDecoratedLeft(centerView)) / 2 - width / 2)
for (i in 1 until childCount) {
val view = getChildAt(i) as View
val currentDistance =
abs((getDecoratedRight(view) + getDecoratedLeft(view)) / 2 - width / 2)
if (currentDistance < minDistance) {
centerView = view
minDistance = currentDistance
}
}
return (getDecoratedLeft(centerView) + itemWidth / 2) - width / 2
}

实现变换

变换的实现,目前是直接在layout之后根据每个Item到中心的距离,算出一个百分比,然后进行变换

1
2
3
4
5
6
7
8
9
10
11
12
13
private fun View.applyScale() {
val center = (getDecoratedRight(this) + getDecoratedLeft(this)) / 2
var percent =
if (abs(center - this@ArtLayoutManager.width / 2) > scaleMaxDistance) 0F else 1 - (abs(center - this@ArtLayoutManager.width / 2)) * 1F / scaleMaxDistance
percent = when {
percent > 1F -> 1F
percent < 0 -> 0F
else -> percent
}

scaleX = scaleT + (1 - scaleT) * percent
scaleY = scaleT + (1 - scaleT) * percent
}

剩下的问题

虽然现在很好的实现的UI小姐姐想要的效果,但是这个类还是有小细节需要处理,比如设置的间距小于缩小的距离时,UI的效果还有一些差异;

OLDER > < NEWER