自定义控件——用贝塞尔曲线实现直播点赞效果

最近在逛博客的时候学到一个新的东西,正如标题那样,用贝塞尔曲线实现直播点赞的动画效果,动画效果看着不错,而且感觉以后开发中遇到这种功能的几率还是很大,所以学习一下,下面是对整个学习过程的记录。

先上实现的效果图:

点赞效果图

仔细观察这个效果图,可以将其分为两个部分来实现,首先是在屏幕底部中心生成图片,伴随着图片的生成,同时还有缩放动画和透明度动画,然后这个图片会沿着一条曲线向上移动,同时伴随着透明度变化,这条曲线就是此次学习到的新东西——贝塞尔曲线。总结下流程:

1、每次点击在屏幕底部中心生成一张图片,伴随缩放动画和渐变动画;
2、第一部分动画执行完成后,图片沿着贝塞尔曲线移动,同时伴随渐变动画。

流程梳理清楚,接下来开始编写代码,首先是第一部分的实现,先往数组中放三张图,每次点击都随机取出一张图片放到容器中指定的位置,下面看看核心部分代码的实现:

    /**
     * 动画效果
     * @param iv 动画target
     */
    private fun getAnimatorSet(iv: ImageView): AnimatorSet {
        //透明度
        val alphaAni = ObjectAnimator.ofFloat(iv,"alpha",0.3f,1f)
        //x方向缩放
        val scaleX = ObjectAnimator.ofFloat(iv,"scaleX",0.2f,1f)
        //方向缩放
        val scaleY = ObjectAnimator.ofFloat(iv,"scaleY",0.2f,1f)
        val createAnimatorSet = AnimatorSet() //图片生成动画
        createAnimatorSet.playTogether(alphaAni,scaleX,scaleY) //图片的生成伴随着三种动画的同时发生
        createAnimatorSet.duration = 500
        return createAnimatorSet 
    }

这里主要是实现了属性动画和多个属性动画一起执行的效果。到这里再加上一些资源的配置就能实现图片出现在底部的效果了,下面是配置资源代码以及效果:

class LikedEffectLayout @JvmOverloads constructor(context: Context,attr:AttributeSet ?= null,defAttr:Int = 0) : RelativeLayout(context,attr,defAttr){
    private lateinit var mRed : Drawable //红心心
    private lateinit var mPink : Drawable //粉心心
    private lateinit var mBlue : Drawable //蓝心心
    private lateinit var mDrawables : ArrayList<Drawable> //图片集合 随机选中一张图片
    private lateinit var mInterpolators : ArrayList<Interpolator> //插值器集合 随机选中一个插值器
    private var mDrawableHeight = 0 //图片高度
    private var mDrawableWidth = 0 //图片宽度
    private var mHeight = 0 //布局高度
    private var mWidth = 0 //布局宽度
    private var mParams : LayoutParams //图片参数
    private var mRandom = Random() //随机数

    init {
        initDrawable() //初始化图片集
        initInterpolator() //初始化插值器集
        mParams = LayoutParams(mDrawableWidth/5,mDrawableHeight/5) //设置图片参数 因为找的图片尺寸太大了  所以缩小了5倍
        mParams.addRule(CENTER_HORIZONTAL, TRUE) //设置图片水平居中
        mParams.addRule(ALIGN_PARENT_BOTTOM, TRUE) //设置图片位于容器底部
    }

    /**
     * 初始化插值器集
     */
    private fun initInterpolator() {
        mInterpolators = arrayListOf()
        mInterpolators.add(LinearInterpolator())
        mInterpolators.add(AccelerateDecelerateInterpolator())
        mInterpolators.add(AccelerateInterpolator())
        mInterpolators.add(DecelerateInterpolator())
    }

    /**
     * 初始化图片集
     */
    private fun initDrawable() {
        mRed = resources.getDrawable(R.drawable.love_red,null)
        mPink = resources.getDrawable(R.drawable.love_pink,null)
        mBlue = resources.getDrawable(R.drawable.love_blue,null)

        mDrawables = arrayListOf()
        mDrawables.apply {
            add(mRed)
            add(mPink)
            add(mBlue)
        }

        mDrawableHeight = mRed.intrinsicHeight //获取图片高度
        mDrawableWidth = mRed.intrinsicWidth //获取图片宽度
    }


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        mWidth = measuredWidth //获取布局宽度
        mHeight = measuredHeight //获取布局高度
    }

    /**
     * 暴露给外部调用的生成图片的方法
     * 添加点赞效果的图片
     * 点击一次生成一张图片 然后沿着贝塞尔曲线移动
     */
    fun addLove(){
        val loveIv = ImageView(context)
        loveIv.setImageDrawable(mDrawables[mRandom.nextInt(mDrawables.size)]) //从图片集随机取出一张
        loveIv.layoutParams = mParams
        addView(loveIv)

        val finalSet = getAnimatorSet(loveIv)//设置动画效果
        finalSet.start() //动画开始
    }

    /**
     * 动画效果
     * @param iv 动画target
     */
    private fun getAnimatorSet(iv: ImageView): AnimatorSet {
        //透明度
        val alphaAni = ObjectAnimator.ofFloat(iv,"alpha",0.3f,1f)
        //x方向缩放
        val scaleX = ObjectAnimator.ofFloat(iv,"scaleX",0.2f,1f)
        //方向缩放
        val scaleY = ObjectAnimator.ofFloat(iv,"scaleY",0.2f,1f)
        val createAnimatorSet = AnimatorSet() //图片生成动画
        createAnimatorSet.playTogether(alphaAni,scaleX,scaleY) //图片的生成伴随着三种动画的同时发生
        createAnimatorSet.duration = 500
        return createAnimatorSet 
    }
}
生成图片

至此第一部分就算完成了。下面就是怎么让图片沿着曲线动起来,首先来看一下贝塞尔曲线以及它的使用:

贝塞尔曲线三次方公式
class BezierEvaluator(private val p1: PointF, private val p2: PointF) : TypeEvaluator<PointF>{

    override fun evaluate(t: Float, p0: PointF?, p3: PointF?): PointF {
        val point = PointF()
        /**
         * kotlin语言中要注意这种分行得写法
         * 因为kotlin中没有分号 所以一个表达式要么写成一行 要么加上一个括号 否则这个估值器不生效
         */
        point.x = (p0!!.x*(1-t)*(1-t)*(1-t)
                +3*p1.x*t*(1-t)*(1-t)
                +3*p2.x*t*t*(1-t)
                +p3!!.x*t*t*t)
        point.y = p0.y*(1-t)*(1-t)*(1-t) +3*p1.y*t*(1-t)*(1-t) +3*p2.y*t*t*(1-t) +p3.y*t*t*t
        return point
    }
}

上面就是贝塞尔估值器,p1、p2两个拐点需要我们自行计算传进来,起始点p0和终止点p3是设置属性动画的时候设定,这里内部已经帮我们传过来了,直接用就行。总的来看这个估值器就是返回一个点,这个点的x坐标和y坐标根据贝塞尔曲线得到。
接下来看看属性动画对估值器的使用,以及生成的图片是如何沿着贝塞尔曲线移动的:

    /**
     * 贝塞尔曲线动画
     * @param iv target
     */
    private fun getBezierValueAnimator(iv: ImageView) : ValueAnimator{
        //起始点 此次是放在屏幕底部水平中央的位置
        val p0 = PointF(mWidth/2 - mDrawableWidth/10*1f,mHeight - mDrawableHeight/5*1f)
        //第一个的拐点 x坐标在屏幕内随机取 y坐标得保证比第二个拐点得要小
        val p1 = PointF(mRandom.nextInt(mWidth)*1f,mRandom.nextInt(mHeight/2)*1f)
        //第二个拐点
        val p2 = PointF(mRandom.nextInt(mWidth)*1f,mRandom.nextInt(mHeight/2)*1f+mHeight/2)
        //终点 屏幕得顶部随机生成
        val p3 = PointF(mRandom.nextInt(mWidth - mDrawableWidth/5)*1f,0f)
        val evaluator = BezierEvaluator(p1,p2) //传入两个拐点生成贝塞尔估值器
        val animator = ValueAnimator.ofObject(evaluator,p0,p3)//生成属性动画
        /**
         * 监听动画执行过程不断改变图片得坐标 达到动画得效果
         */
        animator.addUpdateListener {
            val point = it.animatedValue as PointF
            iv.x = point.x
            iv.y = point.y
            iv.alpha = (1-it.animatedFraction) //伴随一个透明度得变化
        }
        /**
         * 动画结束从容器中移除target 内存优化
         */
        animator.doOnEnd {
            removeView(iv)
        }
        animator.setTarget(iv)
        animator.interpolator = mInterpolators[mRandom.nextInt(4)] //随机生成一个插值器 控制运动速度
        animator.duration = 3000
        return animator
    }
贝塞尔运动轨迹

贝塞尔曲线是一条S型的曲线,所以我们只需要给定四个点的值,接下来的曲线就交给估值器去处理就行,估值器会实时返回曲线上的点,在这里我们需要算出起始点p0,第一个拐点p1,第二个拐点p2和终止点p3,下面我们一一剖析这四个点:

1、其中p0点位于屏幕底部的中心,这个点是一个定点,由于渲染机制,其实这个点不是指的图片的中心点p0,而是图片左上角那个p点,所以我们在计算的时候要把图片的尺寸考虑进去,计算p0的坐标实际是算p点的坐标,所以p0的x坐标值应该是:(布局的一半)-(图片的一半) => mWidth/2 - mDrawableWidth/10(除以10是因为找的图尺寸太大,我缩小了5倍),p0的y坐标值为:(布局的高度)-(图片的高度) => mHeight - mDrawableHeight/5
2、p1点是第一个拐点,它和第二个拐点p2结合起来看,这种S型的曲线,第一个拐点在第二个拐点的下方,所以我们要对两个拐点的y坐标做出约束,x坐标的话只要在屏幕内就可以,所以两个拐点的x坐标就是在屏幕内取随机数,即:mRandom.nextInt(mWidth),第一个拐点p1的y坐标在屏幕下半部分随机取值,即:mRandom.nextInt(mHeight/2)
3、第二个拐点p2在第一个拐点的上方,x坐标也是在屏幕内取值就可以,所以p2的x坐标值为:mRandom.nextInt(mWidth),y坐标要保证是在第一个拐点的上方,所以结合第一个拐点的y坐标可以得其y坐标值为:mRandom.nextInt(mHeight/2)*1f+mHeight/2
4、终止点p3的位置位于屏幕的顶部,为了做出像花束那样的发散效果,所以终止点的x坐标并不固定,在屏幕内随机取值就可以,即:mRandom.nextInt(mWidth - mDrawableWidth/5)*1f,减去图片一个宽度是防止图片在屏幕的两侧就跑出屏幕了,因为在屏幕的顶部,y坐标值为0.

以上就是四个点的计算逻辑,当然只适合我这种情况,不过无论怎么实现这条曲线,都是有迹可循的。四个点找到后,将第一个拐点和第二个拐点传入估值器内生成一个估值器,再根据这个估值器、起始点和终止点
生成图片的属性动画,至此这个动画就完成了,也就是开篇那个效果图的样子。

至此,直播间点赞的那种效果就完成了,最后给动画加了一个插值器,从插值器集中随机取一个出来,实现动画的速率不一致,这样看着就更加华丽(花里胡哨)。还给动画加了一个结束监听,因为这个效果是每次点击就会生成一张图片,所以大量点击后对内存会有很大的消耗,所以在动画结束后,对资源进行回收,即:

/**
  * 动画结束从容器中移除target 内存优化
  */
 animator.doOnEnd {
      removeView(iv)
 }

好了本次对贝塞尔曲线的学习就结束了,下面总结记录下这次学习中的收获的东西和存在的疑惑点:
收获一:插值器和估值器是实现非匀速动画的重要手段,插值器(TimeInterpolator)是根据时间流逝的百分比计算出属性动画的百分比;估值器(TypeEvaluator)是根据当前属性改变的百分比计算出的属性值。
插值器之前用的多一点,系统给定的线性插值器(LinearInterpolator)、减速插值器(DecelerateInterpolator)和加速减速插值器(AccelerateDecelerateInterpolator)等,都跟加速度有关,加速度和时间关联,所以插值器的作用也就好理解了;估值器这是第一次接触,这次使用中,泛型返回的是一个点,对于动画过程来说其实就是一个点的移动过程,所以估值器的作用也就好理解了。

收获二:多个属性动画的同步执行和顺序执行。
AnimatorSet.playTogether(Animator... items)方法实现多个动画的同步执行,从方法名也能看出它的作用;
AnimatorSet.playSequentially(Animator... items)这个方法看名字看不出来是干啥,点开源码看就知道这个方法是按动画传入的顺序分步执行动画的,放在前面的动画最先执行。下面贴出源码:

    /**
     * Sets up this AnimatorSet to play each of the supplied animations when the
     * previous animation ends.
     *
     * @param items The animations that will be started one after another.
     */
    public void playSequentially(Animator... items) {
        if (items != null) {
            if (items.length == 1) {
                play(items[0]);
            } else {
                for (int i = 0; i < items.length - 1; ++i) {
                    play(items[i]).before(items[i + 1]);
                }
            }
        }
    }

关于属性动画,我之前也专门写过一篇文章记录我的理解,有兴趣可以去瞅瞅,帮我涨涨阅读量。文章地址

疑惑一:第一个疑惑就是四个点的坐标确定那里,第一个拐点和第二个拐点的y坐标确定上,看别人的文章(具体实现的效果也没错)写的是p1的y坐标范围为mRandom.nextInt(mHeight/2),即屏幕的一半,p2的y坐标范围为mRandom.nextInt(mHeight/2)*1f+mHeight/2,即p2的y坐标得比p1得大,但安卓得坐标原点位于左上角,如果p2位于p1的上方,那p2的y坐标应该比p1小啊,难道自定义控件里面计算的坐标原点是在左下角?

疑惑二:这个问题跟kotlin语言特性有关,分行写一个算式时有问题,因为kotlin中没有分号,在编译的时候在每行末尾都会加一个分号,这样就导致一个分行写的算式被分成了很多个世子。
估值器里分行写方程式的时候,一开始我是分行写的,然后一运行始终达不到效果,很是纳闷,找了好久才找到原因,是一个括号引发的血案,最后将kotlin转为java代码才肯定了问题的根源,下面来看看:
这是kotlin代码:

        point.x = p0!!.x*(1-t)*(1-t)*(1-t)
                +3*p1.x*t*(1-t)*(1-t)
                +3*p2.x*t*t*(1-t)
                +p3!!.x*t*t*t
        point.y = p0.y*(1-t)*(1-t)*(1-t) +3*p1.y*t*(1-t)*(1-t) +3*p2.y*t*t*(1-t) +p3.y*t*t*t

转换为java:

      point.x = p0.x * ((float)1 - t) * ((float)1 - t) * ((float)1 - t);
      float var10000 = (float)3 * this.p1.x * t * ((float)1 - t) * ((float)1 - t);
      var10000 = (float)3 * this.p2.x * t * t * ((float)1 - t);

      var10000 = p3.x * t * t * t;
      point.y = p0.y * ((float)1 - t) * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p1.y * t * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p2.y * t * t * ((float)1 - t) + p3.y * t * t * t;

以上就是在kotlin中分行写没加括号时转换成java后的代码,可以看到x的值只有第一行的值,后面的都没有算上,y坐标值在kotlin中没分行写,就没有问题,下面再来看看加上括号的样子:

     float var10001 = p0.x * ((float)1 - t) * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p1.x * t * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p2.x * t * t * ((float)1 - t);

      point.x = var10001 + p3.x * t * t * t;
      point.y = p0.y * ((float)1 - t) * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p1.y * t * ((float)1 - t) * ((float)1 - t) + (float)3 * this.p2.y * t * t * ((float)1 - t) + p3.y * t * t * t;

可以看到此时的x坐标是没有问题的,所以在kotlin中分行写算式时需要加括号。

最后,附上源码地址:源码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 227,428评论 6 531
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,024评论 3 413
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 175,285评论 0 373
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 62,548评论 1 307
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,328评论 6 404
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 54,878评论 1 321
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,971评论 3 439
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,098评论 0 286
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 48,616评论 1 331
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,554评论 3 354
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 42,725评论 1 369
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,243评论 5 355
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,971评论 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,361评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,613评论 1 280
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,339评论 3 390
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 47,695评论 2 370