最近在逛博客的时候学到一个新的东西,正如标题那样,用贝塞尔曲线实现直播点赞的动画效果,动画效果看着不错,而且感觉以后开发中遇到这种功能的几率还是很大,所以学习一下,下面是对整个学习过程的记录。
先上实现的效果图:
仔细观察这个效果图,可以将其分为两个部分来实现,首先是在屏幕底部中心生成图片,伴随着图片的生成,同时还有缩放动画和透明度动画,然后这个图片会沿着一条曲线向上移动,同时伴随着透明度变化,这条曲线就是此次学习到的新东西——贝塞尔曲线。总结下流程:
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中分行写算式时需要加括号。
最后,附上源码地址:源码