RecyclerView视差装饰器-ParallaxDecoration

最近项目又开始大刀阔斧的改版迭代,PM也再次开始了其疯狂借鉴大法。不过对此早已习以为常了,毕竟我们也曾经看懂过这么一本书《RR is 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 (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()
        }
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容