RecyclerView视差装饰器-ParallaxDecoration

2,864 阅读6分钟

最近项目又开始大刀阔斧的改版迭代,PM也再次开始了其疯狂借鉴大法。不过对此早已习以为常了。哈哈,有点扯远了,回归正题,先来看看这次要实现的交互效果(借鉴目标):
target.gif

简单描述下,界面就是一个横向列表,滑动的时候,背景图跟着一起滑动,并且附带视差效果,随着滑动距离增加,背景图一直在循环展示。

看到这种效果,列表方案肯定是首选RecyclerView,接着看这背景视差效果,首先想到的就是通过绘制background的方式实现。大家都知道,RecyclerView有这么一个内部类ItemDecoration,可以提供绘制前景,背景,Item分割线能力,所以我们可以通过构建一个ItemDecoration来绘制我们的背景。

通过滑动RecyclerView仔细观察背景的内容,发现它是在一直循环展示的,因此猜测背景应该是一系列的图片横向并排拼凑成一个长图。为了验证我们的猜想,把对方的apk解压,找到对应的资源文件。果然证实了之前的猜想,背景长图是一系列的同尺寸图片拼接而成。

到此,我们基本上可以确定目标方案:

  1. 自定义一个ItemDecoration,传入一个背景图片集合
  2. ItemDecorationonDraw方法中,计算出当前RecyclerView的滑动距离
  3. 根据RecyclerView的滑动距离和parallax视差系数,计算出当前背景的滑动距离
  4. 根据背景的滑动距离换算成坐标,绘制到RecyclerViewCanvas
  5. 需要特别处理循环绘制逻辑,以及只绘制当前屏幕可见数量的图片
  • 首先看下下面这两张图: visible.png
  1. 上面这张,屏幕完全可见的背景图片数量为3,当bg3的右边距与screen的右边距相差1px时,说明bg41px的内容显示在屏幕上,所以当前屏幕最大可见图片数量为4
  2. 再来看看下面这张图,假设上面那张图bg3的右边距与screen的右边距相差2px时,并且在滑动过程中出现下面这张图的场景,也就bg2的左边距和scrren的左边距,bg4的右边距和screen的右边距都相差1px时,说明当前屏幕完全可见图片数量为3,但是最大可见数量为5
  3. 因此,我们可以得出以下结论:
<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
  1. 这样我们就知道在滑动过程中,我们需要在onDraw方法中绘制多少张图片了。
  • 下一步,我们需要找到绘制的起点,因为RecyclerView是可滑动的,所以屏幕内第一张可见的图片肯定不是固定的,我们只要找到当前可见的第一张图片在我们初始化背景图集合中的索引,我们就可以根据上面计算出来的需要绘制的图片数量,按顺序绘制出来就行了。同样,先来看一张图: offset.png
  1. 我们暂时不考虑视差系数,获取到当前RecyclerView的滑动距离:
<ParallaxDecoration.kt>
...
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 滑动距离 / 单张图片宽度 = 当前是第几张图片
// 这里我们对图片集合的长度进行求余运算,即可获得当前第一个可见的图片索引
val firstVisible = (scrollOffset / bitmapWidth).toInt() % bitmapPool.size
// 获取当前第一张图片左边缘距离屏幕左边缘的偏移量
val firstVisibleOffset = scrollOffset % bitmapWidth
  1. 我们确定了当前屏幕第一张可见的图片索引,以及第一张图片与屏幕左边缘的偏移量,下面就可以开始真正的绘制了:
<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()
    }
  1. 上面在循环绘制过程中,我们进行了优化取值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()
  1. 此时我们的背景图已经能够跟随RecyclerView滑动而循环展示了,对于视差效果,只需要在计算scrollOffset时添加一个视差系数parallax即可:
<ParallaxDecoration.kt>
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 添加视差系数,换算成背景的滑动距离,与RecyclerView产生视差效果
val parallaxOffset = scrollOffset * parallax
  • 好了,到此一个支持背景视差效果的ItemDecoration就完成了。最后还有一个问题,就是当我们的背景图不能铺满RecyclerView的高度时,我们需要怎么处理呢?这个对于熟悉绘制的同学来说应该很简单,只需要在绘制的时候对canvas.scale进行缩放处理,就能绘制出自动填充的背景图。这里需要注意的是我们在计算滑动距离offsetfirstVisible时,需要将bitmapWidth*scale才是实际的bitmapWidth,逻辑比较简单,这里就不展开了,同时还需要对RecyclerViewLayoutManager的方向进行区分处理,有兴趣的可自行阅读源码。

  • 最后,下面是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 (screenWidth == 0 || screenHeight == 0) {
                screenWidth = c.width
                screenHeight = c.height
                val allInScreen: Int
                val doubleOutOfScreen: Boolean
                if (isHorizontal) {
                    if (autoFill) {
                        scale = screenHeight * 1f / bitmapHeight
                        scaleBitmapWidth = (bitmapWidth * scale).toInt()
                    }
                    allInScreen = screenWidth / scaleBitmapWidth
                    doubleOutOfScreen = screenWidth % scaleBitmapWidth > 1
                } else {
                    if (autoFill) {
                        scale = screenWidth * 1f / bitmapWidth
                        scaleBitmapHeight = (bitmapHeight * scale).toInt()
                    }
                    allInScreen = screenHeight / scaleBitmapHeight
                    doubleOutOfScreen = screenHeight % scaleBitmapHeight > 1
                }
                minVisibleCount = allInScreen + 1
                maxVisibleCount = if (doubleOutOfScreen) allInScreen + 2 else minVisibleCount
            }
            // 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) minVisibleCount 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()
        }
    }