最近项目又开始大刀阔斧的改版迭代,PM也再次开始了其疯狂借鉴大法。不过对此早已习以为常了,毕竟我们也曾经看懂过这么一本书《RR is PM》。哈哈,有点扯远了,回归正题,先来看看这次要实现的交互效果:
简单描述下,界面就是一个横向列表,滑动的时候,背景图跟着一起滑动,并且附带视差效果,随着滑动距离增加,背景图一直在循环展示。
看到这种效果,列表方案肯定是首选RecyclerView
,接着看这背景视差效果,首先想到的就是通过绘制background
的方式实现。大家都知道,RecyclerView
有这么一个内部类ItemDecoration
,可以提供绘制前景,背景,Item
分割线能力,所以我们可以通过构建一个ItemDecoration
来绘制我们的背景。
通过滑动RecyclerView
仔细观察背景的内容,发现它是在一直循环展示的,因此猜测背景应该是一系列的图片横向并排拼凑成一个长图。为了验证我们的猜想,把对方的apk解压,找到对应的资源文件。果然证实了之前的猜想,背景长图是一系列的同尺寸图片拼接而成。
到此,我们基本上可以确定目标方案:
- 自定义一个
ItemDecoration
,传入一个背景图片集合 - 在
ItemDecoration
的onDraw
方法中,计算出当前RecyclerView
的滑动距离 - 根据
RecyclerView
的滑动距离和parallax
视差系数,计算出当前背景的滑动距离 - 根据背景的滑动距离换算成坐标,绘制到
RecyclerView
的Canvas
上 - 需要特别处理循环绘制逻辑,以及只绘制当前屏幕可见数量的图片
-
首先看下下面这两张图:
- 上面这张,屏幕完全可见的背景图片数量为
3
,当bg3
的右边距与screen
的右边距相差1px
时,说明bg4
有1px
的内容显示在屏幕上,所以当前屏幕最大可见图片数量为4
。 - 再来看看下面这张图,假设上面那张图
bg3
的右边距与screen
的右边距相差2px
时,并且在滑动过程中出现下面这张图的场景,也就bg2
的左边距和scrren
的左边距,bg4
的右边距和screen
的右边距都相差1px
时,说明当前屏幕完全可见图片数量为3
,但是最大可见数量为5
。 - 因此,我们可以得出以下结论:
<ParallaxDecoration.kt>
...
// 完全可见的图片数量 = 屏幕宽度 / 单张图片宽度
val allInScreen = screenWidth / bitmapWidth
// 当前展示完完全可见图片数量后,距离屏幕边缘的剩余像素空间
val outOfScreenOffset = screenWidth % bitmapWidth
// 如果剩余像素 > 1px,说明会出现上面图2的场景
val outOfScreen = outOfScreenOffset > 1
// 因此得出最大可见数 = 屏幕剩余像素>1px ? 完全可见数+2 : 完全可见数+1
val maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
- 这样我们就知道在滑动过程中,我们需要在
onDraw
方法中绘制多少张图片了。
- 下一步,我们需要找到绘制的起点,因为
RecyclerView
是可滑动的,所以屏幕内第一张可见的图片肯定不是固定的,我们只要找到当前可见的第一张图片在我们初始化背景图集合中的索引,我们就可以根据上面计算出来的需要绘制的图片数量,按顺序绘制出来就行了。同样,先来看一张图:
- 我们暂时不考虑视差系数,获取到当前
RecyclerView
的滑动距离:
<ParallaxDecoration.kt>
...
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 滑动距离 / 单张图片宽度 = 当前是第几张图片
// 这里我们对图片集合的长度进行求余运算,即可获得当前第一个可见的图片索引
val firstVisible = (scrollOffset / bitmapWidth).toInt() % bitmapPool.size
// 获取当前第一张图片左边缘距离屏幕左边缘的偏移量
val firstVisibleOffset = scrollOffset % bitmapWidth
- 我们确定了当前屏幕第一张可见的图片索引,以及第一张图片与屏幕左边缘的偏移量,下面就可以开始真正的绘制了:
<ParallaxDecoration.kt>
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
...
c.save()
// 把画布平移到第一张图片的左边缘
c.translate(-firstVisibleOffset, 0f)
// 循环绘制当前屏幕可见的图片数量
for ((i, currentIndex) in (firstVisible until firstVisible + bestDrawCount).withIndex()) {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
i * bitmapWidth.toFloat(),
0f,
null
)
}
// 恢复画布
c.restore()
}
- 上面在循环绘制过程中,我们进行了优化取值
bestDrawCount
,具体计算逻辑是,当firstVisibleOffset = 0
时说明当前第一张可见图与屏幕左边缘对其,相当于初始状态,所以最大可见数为maxVisibleCount - 1
。虽然需要每循环bitmapPool.szie
次才会触发一次该条件,但是在RecyclerView
持续滑动过程中频繁触发此处的onDraw
回调,降低一次循环对性能的提升还是可观的,同时我们在计算firstVisible
的时候先不对bitmapCount
进行取余操作,因为draw
的时候我们依旧要取余保证索引的准确性:
<ParallaxDecoration.kt>
// 上面我们得出的maxVisibleCount
val maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
val bestDrawCount = if (firstVisibleOffset.toInt() == 0) maxVisibleCount - 1 else maxVisibleCount
// 计算firstVisible时暂不作取余,此时firstVisible = n * bitmapCount + firstIndex
val firstVisible = (scrollOffset / bitmapWidth).toInt()
- 此时我们的背景图已经能够跟随
RecyclerView
滑动而循环展示了,对于视差效果,只需要在计算scrollOffset
时添加一个视差系数parallax
即可:
<ParallaxDecoration.kt>
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 添加视差系数,换算成背景的滑动距离,与RecyclerView产生视差效果
val parallaxOffset = scrollOffset * parallax
好了,到此一个支持背景视差效果的
ItemDecoration
就完成了。最后还有一个问题,就是当我们的背景图不能铺满RecyclerView
的高度时,我们需要怎么处理呢?这个对于熟悉绘制的同学来说应该很简单,只需要在绘制的时候对canvas.scale
进行缩放处理,就能绘制出自动填充的背景图。这里需要注意的是我们在计算滑动距离offset
和firstVisible
时,需要将bitmapWidth*scale
才是实际的bitmapWidth
,逻辑比较简单,这里就不展开了,同时还需要对RecyclerView
的LayoutManager
的方向进行区分处理,有兴趣的可自行阅读源码。最后,下面是
ParallaxDecoration.onDraw
的核心逻辑,完整项目和使用方式见底部链接:
<ParallaxDecoration.kt>
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
if (bitmapPool.isNotEmpty()) {
// if layoutManager is null, just throw exception
val lm = parent.layoutManager!!
// step1. check orientation
isHorizontal = lm.canScrollHorizontally()
// step2. check maxVisible count
// step3. if autoFill, calculate the scale bitmap size
if (isHorizontal && screenWidth == 0) {
screenWidth = c.width
screenHeight = c.height
if (autoFill) {
scale = screenHeight * 1f / bitmapHeight
scaleBitmapWidth = (bitmapWidth * scale).toInt()
}
val allInScreen = screenWidth / scaleBitmapWidth
val outOfScreen = screenWidth % scaleBitmapWidth > 1
maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
} else if (!isHorizontal && screenHeight == 0) {
screenWidth = c.width
screenHeight = c.height
if (autoFill) {
scale = screenWidth * 1f / bitmapWidth
scaleBitmapHeight = (bitmapHeight * scale).toInt()
}
val allInScreen = screenHeight / scaleBitmapHeight
val outOfScreen = screenHeight % scaleBitmapHeight > 1
maxVisibleCount = if (outOfScreen) allInScreen + 2 else allInScreen + 1
}
// step4. find the firstVisible index
// step5. calculate the firstVisible offset
val parallaxOffset: Float
val firstVisible: Int
val firstVisibleOffset: Float
if (isHorizontal) {
parallaxOffset = lm.computeHorizontalScrollOffset(state) * parallax
firstVisible = (parallaxOffset / scaleBitmapWidth).toInt()
firstVisibleOffset = parallaxOffset % scaleBitmapWidth
} else {
parallaxOffset = lm.computeVerticalScrollOffset(state) * parallax
firstVisible = (parallaxOffset / scaleBitmapHeight).toInt()
firstVisibleOffset = parallaxOffset % scaleBitmapHeight
}
// step6. calculate the best draw count
val bestDrawCount =
if (firstVisibleOffset.toInt() == 0) maxVisibleCount - 1 else maxVisibleCount
// step7. translate to firstVisible offset
c.save()
if (isHorizontal) {
c.translate(-firstVisibleOffset, 0f)
} else {
c.translate(0f, -firstVisibleOffset)
}
// step8. if autoFill, scale the canvas to draw
if (autoFill) {
c.scale(scale, scale)
}
// step9. draw from current first visible bitmap, the max looper count is the best draw count by step6
for ((i, currentIndex) in (firstVisible until firstVisible + bestDrawCount).withIndex()) {
if (isHorizontal) {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
i * bitmapWidth.toFloat(),
0f,
null
)
} else {
c.drawBitmap(
bitmapPool[currentIndex % bitmapCount],
0f,
i * bitmapHeight.toFloat(),
null
)
}
}
c.restore()
}
}
-
展示一下效果: