巧用RecyclerView ItemDecoration 实现炫酷视差效果

目录

概述

分析

实现


概述

最近项目又开始大刀阔斧的改版迭代,PM也再次开始了其疯狂CX大法。不过对此早已习以为常了,毕竟我们也曾经看懂过这么一本书《RR is PM》。哈哈,有点扯远了,回归正题,先来看看这次要实现的交互效果(CX目标):

简单描述下,界面就是一个横向列表,滑动的时候,背景图跟着一起滑动,并且附带视差效果,随着滑动距离增加,背景图一直在循环展示。看到这种效果,列表方案肯定是首选RecyclerView,接着看这背景视差效果,首先想到的就是通过绘制background的方式实现。大家都知道,RecyclerView有这么一个内部类ItemDecoration,可以提供绘制前景,背景,Item分割线能力,所以我们可以通过构建一个ItemDecoration来绘制我们的背景。

分析

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

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

  1. 自定义一个ItemDecoration,传入一个背景图片集合
  2. 在ItemDecoration的onDraw方法中,计算出当前RecyclerView的滑动距离
  3. 根据RecyclerView的滑动距离和parallax视差系数,计算出当前背景的滑动距离
  4. 根据背景的滑动距离换算成坐标,绘制到RecyclerView的Canvas上
  5. 需要特别处理循环绘制逻辑,以及只绘制当前屏幕可见数量的图片

首先看下下面这张图:

上面这部分,屏幕完全可见的背景图片数量为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是可滑动的,所以屏幕内第一张可见的图片肯定不是固定的,我们只要找到当前可见的第一张图片在我们初始化背景图集合中的索引,我们就可以根据上面计算出来的需要绘制的图片数量,按顺序绘制出来就行了。

同样,先来看一张图:

实现

1. 我们暂时不考虑视差系数,获取到当前RecyclerView的滑动距离:

<ParallaxDecoration.kt>
...
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 滑动距离 / 单张图片宽度 = 当前是第几张图片
// 这里我们对图片集合的长度进行求余运算,即可获得当前第一个可见的图片索引
val firstVisible = (scrollOffset / bitmapWidth).toInt() % bitmapPool.size
// 获取当前第一张图片左边缘距离屏幕左边缘的偏移量
val firstVisibleOffset = scrollOffset % bitmapWidth

2. 我们确定了当前屏幕第一张可见的图片索引,以及第一张图片与屏幕左边缘的偏移量,下面就可以开始真正的绘制了:

<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()
}

3. 上面在循环绘制过程中,我们进行了优化取值bestDrawCount,具体计算逻辑是,当firstVisibleOffset = 0时说明当前第一张可见图与屏幕左边缘对齐,相当于初始状态,所以最大可见数为maxVisibleCount - 1。虽然需要每循环bitmapPool.szie次才会触发一次该条件,但是在RecyclerView持续滑动过程中频繁触发此处的onDraw回调,降低一次循环对性能的提升还是可观的,同时我们在计算firstVisible的时候先不对bitmapCount进行取余操作,因为draw的时候我们依旧要取余保证索引的准确性:

<ParallaxDecoration.kt>
// 当前recyclerView的滑动距离
val scrollOffset = RecyclerView.layoutManager.computeHorizontalScrollOffset(state)
// 添加视差系数,换算成背景的滑动距离,与RecyclerView产生视差效果
val parallaxOffset = scrollOffset * parallax

4. 此时我们的背景图已经能够跟随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()
    }
}

项目地址:https://github.com/seagazer/parallaxdecoration

猜你喜欢

转载自blog.csdn.net/CallmeZhe/article/details/112727952