Android 仿多红包切换效果

1,623 阅读11分钟

1. 前言

这里主要是为了介绍ViewPager的自定义切换动画ViewPager2.PageTransformer的效果,通过一个真实的需求来做例子,凸显出该功能的强大(这里先知讲使用,不讲源码)

可以先来看看一个最终的效果(素材图片是随便从网上拿的)

f4a7a834-52f1-404f-9612-1be65489b4d0.gif

2. 自定义ViewPager2.PageTransformer说明

一般我们的ViewPager2提供的默认切换效果就是一个Banner的效果,左滑的话,就是当前页从右到左水平移出屏幕,下一页也是从右到左进入屏幕

但是它却能提供方法来让我们自己进行切换动画的自定义设计。不光如此,它甚至还会在官方提供一些动画的Demo,如果合适的话可以直接从官方把Demo的代码拷贝下来使用。

developer.android.com/develop/ui/…

我们先拿官方的一个Demo来看看效果

class DepthPageTransformer : ViewPager2.PageTransformer {  
  
    private val MIN_SCALE = 0.75f  

    override fun transformPage(page: View, position: Float) {  
        page.apply {  
            val pageWidth = width  
            when {  
                position < -1 -> { // [-Infinity,-1)  
                    alpha = 0f  
                }  

                position <= 0 -> { // [-1,0]  
                    alpha = 1f  
                    translationX = 0f  
                    translationZ = 0f  
                    scaleX = 1f  
                    scaleY = 1f  
                }  

                position <= 1 -> { // (0,1]  
                    alpha = 1 - position  
                    translationX = pageWidth * -position  
                    translationZ = -1f  
                    val scaleFactor = (MIN_SCALE + (1 - MIN_SCALE) * (1 - Math.abs(position)))  
                    scaleX = scaleFactor  
                    scaleY = scaleFactor  
                }  

                else -> { // (1,+Infinity]  
                    alpha = 0f  
                }  
            }  
        }  
    }  
  
}

得到效果

05374a70-67f8-4051-8971-bcef4b576d29.gif

这就是一个平时需求也可能会有的卡片切换效果。

现在我们根据这个Demo来分析如何自定义PageTransformer

首先要自定义一个PageTransformer来继承ViewPager2.PageTransformer,并且实现transformPage()方法,而所有的动画效果的设置,只需要在这个transformPage()方法里面做就行,怎样,是不是很简单

fun transformPage(page: View, position: Float)

我们可以看到这个方法有两个参数,page就是滑动的控件,而position指的就是滑动的位置,现在来详细讲解下这两个参数。

这里很重要,要注意听,光抄是没办法领悟这个方法的精髓,我的这个思路在网上也没看到有人说的这么详细,自己摸索的话就是会耗费时间,所以我直接把思路给你,跟着我的思路你就能完全懂得怎么用它

首先这个position,在我们可见范围内,它分为四个区间【-2,-1】,【-1,0】【0,1】【1,2】

你看官方的Demo不是有贴

image.png

其实它不止会是在这个范围内,他也有【-3,-2】【2,3】等区间的值,这个就要看你的viewpager的item有多少个,但是,可见范围就这4个区间【-2,-1】,【-1,0】【0,1】【1,2】,我们也只需要关注着4个区间,因为不可见的地方我们去改变这个item也没有任何意义。

拿我这个图来举例,把页面上的item分为1、2、3

image.png

(注意,后续的举例基本都会基于这张图)

Item1向左滑动的过程,position的变化就是从-1到-2,同理,假设Item1从屏幕的左边(看不见)移一次到现在的位置,position的变化就是从-2到-1

Item3向右滑动的过程,position的变化就是从1到2,同理从屏幕右边划到这个位置,position的变化就是从2到1

Item2向左滑动到Item1这个位置的过程,position的变化就是从0到-1,同理,Item1向右滑到Item2的位置,就是position从-1到0

Item2向右滑动到Item3这个位置的过程,position的变化就是从0到1,同理,Item3向左滑到Item2的位置,就是position从1到0

假如,Item3的右边还有Item4,Item4的右边还有Item5,这时候左滑,Item5会滑动到Item4的位置,这时候position的变化就是从3到2,但是这个过程,Item4不会出现到屏幕上,所以在这个Demo里面,上面我说了,只需要关心这4个区间就行,只有这4个区间内的控件,是会显示出来的。

OK,到这里我觉得是能讲清楚position了,再来讲讲view。

上面的官方Demo中能看出这个过程对view做alpha、translationX、scaleX等赋值。所以拿到View我们的目的是去改变它的属性,从而实现一个view的属性随position变化而变化的过程

2个参数都讲完了,现在来讲一个关键的概念。transformPage方法是所有Item同时调用,刚开始接触这块知识的人可能会有个误区(没看源码的情况下),可能以为transformPage方法是只有中间的Item才会调用,其实不是,是所有Item都会调用,并且是同时调用。(准确来说不是所有,而是你设置的offscreenPageLimit的值,但是为了简单理解,我这里说所有)

我可以证明,并且这个证明的过程也是我习惯调试这个功能的过程。因为是多个Item同时调用这个方法,如果直接在Log打印的话,会怼在一块

image.png

你不懂哪一条是哪个Item的打印,但是我们可以给Item加上id

image.png

打印的时候也把view的id打印出来

Log.d("mmp", "测试position ${page.id} ----> ${position}")

可以看到效果

image.png

这样你就能明显看出滑动的时候所有的Item都执行transformPage并且能区分每个Item的position当前的位置。

小节

这里主要是要理解2个参数和一个概念,不然就没办法自己去清晰的写自定义效果。

(1)position随view滑动的值是怎么变化的 (2)view用来改变其属性而达到自定义的效果 (3)一个重要的概念就是多个Item同时调用transformPage方法,记住,是同时。

搞清楚这些概念,我想应该能稍微看懂一点官方Demo的代码。

3. 多红包切换效果的实现

实现多红包切换的效果,我们就需要去做自定义View。

在开始之前,我还要先说个细节,viewpager内部的view必须是填充的,因为不填充的话,他会报错,那我是如何让3个Item同时显示在一个屏幕上的呢?没错,就是通过scaleX和scaleY进行缩放。

虽然上面讲清楚了原理,你可能会觉得这个东西很简单,但其实并不简单,不简单在哪里?不简单的地方在于要设计出一个公式,这个公式能实现动画的效果,而公式中重要的变量就是position,公式计算出来的值就是View的属性。可以简单写成 view.property = f(position) 即 V = f(p)

比如官方demo中的这个公式:val scaleFactor = (MIN_SCALE + (1 - MIN_SCALE) * (1 - Math.abs(position)))

所以这个公式的推导过程,才是这个流程里面最难的一步

OK,我们要推导这个公式V = f(p),其实V和p我们在某些情况下都能知道,根据这些条件去调试,我们就会推导出f(p)

我们的Item,可以看出静态的情况下存在两种状态,居中和靠边只露出一部分,上面说了,viewpager内部的控件必须是做填充,所以View的初始状态可以看出宽度填充高度自适应。那居中的Item,我这里写,宽度是缩放0.68,高度缩放0.8,而靠边的状态,宽度缩放0.5,高度缩放0.6,那就可以定义一些变量方便后续的计算和理解

private val CENTER_SCAL_X = 0.68f // 中间Item宽度缩放的宽度倍数  
private val CENTER_SCAL_Y = 0.8f // 中间Item宽度缩放的高度倍数  
private val BOTH_SCAL_X = 0.5f // 两边Item宽度缩放的宽度倍数  
private val BOTH_SCAL_Y = 0.6f // 两边Item宽度缩放的高度倍数

OK,我们想一想,我中间的控件,也就是Item2滑到Item1的位置,缩放宽度是从0.68f变成0.5f,这个能理解吧。(时刻心中有个公式V = f(p))

我们其实能得到两个静态的状态,p = 0时,V = 0.68f,p = -1时, V = 0.5f。整个过程是一个减的过程,得到一个公式V = CENTER_SCAL_X - f2(p2),因为p从0到-1是在一个负数区间,所以我们可以写成V = CENTER_SCAL_X + f2(p)

这个得自己想通,感觉也没办法讲得更细了,数学的功底,这个公式,p = -1时, V = 0.5f, 0.68 - 0.18 = 0.5,而0.18是0.68 - 0.5,所以得出最终的公式

val diffX = CENTER_SCAL_X - BOTH_SCAL_X
scaleX = CENTER_SCAL_X + (diffX * position)

同理Y也是

val diffY = CENTER_SCAL_Y - BOTH_SCAL_Y
scaleY = CENTER_SCAL_Y + (diffY * position)

缩放的公式推出来了,接下来就是位移

位移的就更加复杂,我也不知道怎么细说,简单来说说,width * position 就是屏幕的移动(全屏情况下),而我们的初始状态是做了缩放的,所以要做减法,实际我们的移动CENTER_SCAL_X * width * position,两个相减,就是你可以想象这两个同时移动后产生的距离差,就正好是我们要的距离

所以得出位移公式

translationX = -width * position + CENTER_SCAL_X * width * position

最终得出代码

if (position <= 0f) {  
    scaleX = CENTER_SCAL_X + (diffX * position)  
    scaleY = CENTER_SCAL_Y + (diffY * position)  
    translationX = -width * position + width * CENTER_SCAL_X * position 
}else {  
    scaleX = CENTER_SCAL_X - (diffX * position)  
    scaleY = CENTER_SCAL_Y - (diffY * position)  
    translationX = -width * position + width * CENTER_SCAL_X * position 
}

但是在运行中发现,Item1向左滑动或者Item3向右滑动的情况下,好像会和Item2之间的间距会一直变化。(可以自己尝试,这里就不贴图了,因为是细微变化)

那是因为我们在位移的同时,两个Item的大小也是在同时进行缩放,拿Item2和Item3来举例,向左滑动的时候,Item2到Item1的位置会缩小,Item到Item2的位置会放大,所以这一缩一放,它们这个滑动过程中的间距就不会变。

但是如果是向右滑动,Item2到Item3的位置会缩小,按照上面的代码,Item3会到它右边假设Item4的位置,也是会缩小,同时缩小,那就会导致两个Item在滑动的过程中间距会一直变大。

所以要想保持他们的间距不变,我想出了一个办法,那就是Item3到Item4的过程中宽度放大,注意是宽度放大,高度是不会变的,不然就会显得很突兀,这样就又能达到一个放大一个缩小的效果,它们的的间距就会一直保持不变

然后就是我们要继续推公式,原先的scaleX = CENTER_SCAL_X - (diffX * position)公式的基础上,position在【1,2】的区间,从2到1的过程中会缩小,如果要放大,我们就得做个加法。当position为1时,scaleX为0.5,当position为2时,scaleX要变为0.68

假设公式scaleX = CENTER_SCAL_X - (diffX * position) + f3(p3)

f3(p3) = K * (position-1),这里可以说一下为什么是position-1,因为要和前面的进行统一,不然无法衔接上position在【0,1】区间的状态。把scaleX = 0.68, position = 2带入公式:

scaleX = CENTER_SCAL_X - (diffX * position) + K * (position-1)

==》 0.68 = 0.68 - 2*diffX + K

==》K = 2*diffX

所以得到公式:scaleX = CENTER_SCAL_X - (diffX * position) + (2 * diffX * (position - 1))

同理【-2,-1】区间的公式是:translationX = -width * position + CENTER_SCAL_X * width * position

再结合很上边的内容说过,我们只关注【-2,-1】【-1,0】【0,1】【1,2】4个区间,所以我们可以对position为其它区间的场景下不做计算,这样也算是一种优化

最终得出代码


class MyPageTransformer : ViewPager2.PageTransformer {  
  
    private val CENTER_SCAL_X = 0.68f // 中间Item宽度缩放的宽度倍数  
    private val CENTER_SCAL_Y = 0.8f // 中间Item宽度缩放的高度倍数  
    private val BOTH_SCAL_X = 0.5f // 两边Item宽度缩放的宽度倍数  
    private val BOTH_SCAL_Y = 0.6f // 两边Item宽度缩放的高度倍数  

    override fun transformPage(page: View, position: Float) {  
        if (position < -2 || position > 2) {  
            return  
        }  

        val diffX = CENTER_SCAL_X - BOTH_SCAL_X  
        val diffY = CENTER_SCAL_Y - BOTH_SCAL_Y  

        page.apply {  
            when {  
                position < -1 -> {  
                    scaleX = CENTER_SCAL_X + (diffX * position) - (diffX * 2 * (position + 1))  
                    scaleY = (CENTER_SCAL_Y + (diffY * position)).toFloat()  
                    translationX = -width * position + CENTER_SCAL_X * width * position  
                }  

                position <= 0 -> {  
                    scaleX = CENTER_SCAL_X + (diffX * position)  
                    scaleY = CENTER_SCAL_Y + (diffY * position)  
                    translationX = -width * position + width * CENTER_SCAL_X * position  
                }  

                position <= 1 -> {  
                    scaleX = CENTER_SCAL_X - (diffX * position)  
                    scaleY = CENTER_SCAL_Y - (diffY * position)  
                    translationX = -width * position + width * CENTER_SCAL_X * position  
                }  

                else -> {  
                    scaleX = CENTER_SCAL_X - (diffX * position) + (2 * diffX * (position - 1))  
                    scaleY = CENTER_SCAL_Y - (diffY * position)  
                    translationX = -width * position + width * CENTER_SCAL_X * position  
                }  
            }  
        }  
    }  
  
}

在调用的地方

viewpager.setPageTransformer(MyPageTransformer())  
viewpager.offscreenPageLimit = mViews.size()  // 注意一定要写offscreenPageLimit,因为是3个同时显示在屏幕中

4.总结

首先要想做ViewPager2的自定义切换动画,得先理解ViewPager2.PageTransformer,先理解2个参数position、view和一个概念:多个Item同时调用transformPage方法。

在理解了ViewPager2.PageTransformer的基础上,要实现你想要实现的效果,则需要会推导公式,这个就不好教了,如果想直接使用这个效果的话,可以直接拷贝上面的代码去使用。