点赞轨迹动画的实现

背景

最近在开发中遇到这样一个需求:实现一个类似于直播间双击屏幕点赞时右下角出现的动画,举个例子:

这是某直播App中一段录屏,在屏幕上双击的时候,除了按下的位置会有一个动画效果外,每次点赞时右下角都有一个小小的图片左右摇摆向上飘动最后消失。本文就是来仿照他实现一个轨迹动画。

分析

首先分析一下这个动画应该如何来做。将这个动画按特征拆分一下,很容易得到以下几个部分:

  1. 始终沿着某条轨迹运动的路径动画
  2. 在运动过程中伴随着大小和透明度的属性动画
  3. 偏转角度也在以一个特殊的规则不断变化

首先,可以通过自定义View实现,动态的将不同的图片绘制在界面上,再加动画。

第一点,沿着轨迹运动。最简单的情况,可以让图片沿着y轴做垂直向上的运动,同时不断改变x坐标,做左右平移。这样可以简单的实现浮动效果,但是轨迹路径是比较模糊的,不太灵活。所以最好的方案还是根据需要画一条曲线,让图片沿着曲线移动,比如用贝塞尔曲线,绘制好路径 Path ,用路径的 length 构建一个 ValueAnimator ,再借助 PathMeasure ,获取每一时刻在曲线上的位置,再执行 MatrixpostTranslate 方法来移动。

第二点,在运动的过程中,我们可以监听动画运动的时间或者进度,来附加一个属性动画,用 MatrixpostScale 改变图片的大小,改变画笔 Paint 的透明度 alpha 来改变透明度,不过最好是在 postTranslate 之前先执行,这样便于获取图片缩放、旋转时的中心点。

第三点,偏转角度的变化的实现很简单,但是规则比较复杂。可以是完全不动的,也可以始终沿着路径的方向,或是不断左右摇摆,不断旋转等。完全不动很简单,不用做任何处理,固定角度、不断旋转和左右摇摆都可以通过给定一个固定偏转角度或一个 ValueAnimator 来计算,而想要始终沿着路径的方向,就需要借助 PathMeasure 和三角函数结合来计算了。

实战

首先以自定义View的形式,创建一个 AnimView ,在 onDraw() 方法中绘制图片:

1
2
3
4
5
6
7
8
9

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
val matrix = Matrix()
val bmp = bitmap
if (bmp.isRecycled) return
canvas.drawBitmap(bmp, matrix, paint)
}

接下来只需实现动画并在监听中不断绘制即可。

路径动画

首先需要用 Path 绘制需要的路径,这里用贝塞尔曲线来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

val point0 = PointF(0f, 0f)
val point1 = PointF(0f, 0f)
val point2 = PointF(0f, 0f)
val point3 = PointF(0f, 0f)

val control0 = PointF(0f, 0f)
val control1 = PointF(0f, 0f)
val control2 = PointF(0f, 0f)

override fun setUpPath(left: Int, top: Int, right: Int, bottom: Int) {
val width = (right - left).toFloat()
val height = (bottom - top).toFloat()
val dx = width / 2f
val dy = height / 3f

//数据点
point0.set(dx, dy * 3)
point1.set(dx, dy * 2)
point2.set(dx, dy)
point3.set(dx, 0f)

//控制点
control0.set(dx * 0.5f, dy * 2.5f)
control1.set(dx * 1.5f, dy * 1.5f)
control2.set(dx * 0.5f, dy * 0.5f)

path.moveTo(point0.x, point0.y)
path.quadTo(control0.x, control0.y, point1.x, point1.y)
path.quadTo(control1.x, control1.y, point2.x, point2.y)
path.quadTo(control2.x, control2.y, point3.x, point3.y)
}

这里绘制了4个数据点,3个控制点,都是均分整个View的,效果如下:

图中我用绿色标记了控制点,蓝色标记了数据点,就形成了一条很自然的曲线。

路径绘制好了,接下来就是创建一个 ValueAnimator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

//创建一个PathMeasure对象并绑定一个Path,后续路径相关的数据都需要通过它来计算
val pathMeasure = PathMeasure()
pathMeasure.setPath(path, false)

mAnimator = ValueAnimator.ofFloat(0f, pathMeasure.length).apply {
duration = 3000L
addUpdateListener {
val value = it.animatedValue as Float
val point = FloatArray(2)
val tan = FloatArray(2)
//关键方法
pathMeasure.getPosTan(value, point, tan)
val x = point[0]
val y = point[1]
//draw...
}
start()
}

这里最关键的方法是 getPosTan()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

/**
* Pins distance to 0 <= distance <= getLength(), and then computes the
* corresponding position and tangent. Returns false if there is no path,
* or a zero-length path was specified, in which case position and tangent
* are unchanged.
*
* @param distance The distance along the current contour to sample
* @param pos If not null, returns the sampled position (x==[0], y==[1])
* @param tan If not null, returns the sampled tangent (x==[0], y==[1])
* @return false if there was no path associated with this measure object
*/
public boolean getPosTan(float distance, float pos[], float tan[]) {
if (pos != null && pos.length < 2 ||
tan != null && tan.length < 2) {
throw new ArrayIndexOutOfBoundsException();
}
return native_getPosTan(native_instance, distance, pos, tan);
}

简而言之,我们在 UpdateListener 中监听到当前时刻路径长度,通过这个方法,传入长度,就可以计算得到两个值,一个是当前点的坐标 pos[] ,一个是该点的正切值 tan[] 。正切值的用途后面再说,这里已经获取了当前点的坐标 xy ,那么直接绘制出当前图片即可:

1
2
3
4
5
val matrix = Matrix()
val bmp = bitmap//要绘制的bitmap
val center = PointF(bmp.width / 2f, bmp.height / 2f)
matrix.postTranslate(x - center.x, y - center.y)
canvas.drawBitmap(bmp, matrix, paint)

xy 是前面获取的当前坐标位置, x-center.xy-center.y 就可以获取到从原图位置到当前点位置,在横纵坐标上的偏移量,这样再用 MatrixpostTranslate() 方法即可完成运动效果:

大小、透明度的变化

大小、透明度等属性的变化就比较简单了,具体看需求是以什么样的规则。前面在 UpdateListener 的监听中已经获取到了当前已经运动了多远,也可以获取当前已经运动了多久,这两点都可以根据需要转化为动画执行的进度,我们可以以这个进度为依据,做属性的变化,比如随便写一个动画总的执行时间是3000ms,在执行到第2600ms时开始变小并渐隐的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

var alpha = 1f
var scale = 1f
addUpdateListener {
val value = it.animatedValue as Float
val time = it.currentPlayTime
//用value或time来实际计算一个进度,这里以time为例
alpha = if (time > 2600) 1 - (min(time, 3000) - 2600) / 400f else 1f
scale = if (time > 2600) 1 - (min(time, 3000) - 2600) / 400f else 1f
}


override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
val matrix = Matrix()
val bmp = bitmap
if (bmp.isRecycled) return

//通过改变画笔的 alpha 来改变透明度
paint.alpha = (255 * alpha).toInt()

//通过matrix改变大小和位置
matrix.postTranslate(x - center.x, y - center.y)
matrix.postScale(scale, scale, centet.x, center.y)
canvas.drawBitmap(bmp, matrix, paint)
}

效果如下:

角度变化

角度变化,最简单的可以固定一个角度(什么都不处理),效果就如前文。

稍微复杂一点,可以设置一个不断旋转,或者左右摇摆的效果。这个效果也可以通过 ValueAnimator 来实现。以左右摇摆为例,在整个动画执行开始时,同步执行一个角度变化的动画:

1
2
3
4
5
6
7
8
9
10
11
12
val mSwingDegree = 0f
mSwingAnimator = ValueAnimator.ofFloat(-24f, 24f).apply {
duration = 600L
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
interpolator = LinearInterpolator()
addUpdateListener {
val value = it.animatedValue as Float
mSwingDegree = value
}
start()
}

这是一个执行时间600ms的,从-24到24变化时不断更新当前角度值 mSwingDegree ,同时在 onDraw() 中加上旋转操作:

1
2

matrix.postRotate(mSwingDegree, center.x, center.y)

效果如下:

还有一种同样不断旋转,但是图片方向始终沿路径前进方向。实现这个就需要用到前面提到的正切值了。还记得前面的代码:

1
2
3
4
5
6
7
8
9

val value = it.animatedValue as Float
val point = FloatArray(2)
val tan = FloatArray(2)

pathMeasure.getPosTan(value, point, tan)
val x = point[0]
val y = point[1]
//draw...

这里的 tan 就是当前点在曲线上的正切值。正切在中学数学中学过,引用百度百科的解释:

正切值是指是直角三角形中,某一锐角的对边与另一相邻直角边的比值。对于任意一个实数x,都对应着唯一的角,而这个角又对应着唯一确定的正切值tanx与它对应,按照这个对应法则建立的函数称为正切函数。

知道正切值,有现成的api可以直接反推出这个角的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

/**
* Returns the angle `theta` of the polar coordinates `(r, theta)` that correspond
* to the rectangular coordinates `(x, y)` by computing the arc tangent of the value [y] / [x];
* the returned value is an angle in the range from `-PI` to `PI` radians.
*
* Special cases:
* - `atan2(0.0, 0.0)` is `0.0`
* - `atan2(0.0, x)` is `0.0` for `x > 0` and `PI` for `x < 0`
* - `atan2(-0.0, x)` is `-0.0` for 'x > 0` and `-PI` for `x < 0`
* - `atan2(y, +Inf)` is `0.0` for `0 < y < +Inf` and `-0.0` for '-Inf < y < 0`
* - `atan2(y, -Inf)` is `PI` for `0 < y < +Inf` and `-PI` for `-Inf < y < 0`
* - `atan2(y, 0.0)` is `PI/2` for `y > 0` and `-PI/2` for `y < 0`
* - `atan2(+Inf, x)` is `PI/2` for finite `x`y
* - `atan2(-Inf, x)` is `-PI/2` for finite `x`
* - `atan2(NaN, x)` and `atan2(y, NaN)` is `NaN`
*/
@SinceKotlin("1.2")
@InlineOnly
public actual inline fun atan2(y: Double, x: Double): Double = nativeMath.atan2(y, x)

注意这里获取的是弧度值,还要再转化为角度值。另外这个角度是以x轴为准的,图片默认如果方向朝上的话要补个90度:

1
2

direction = atan2(tan[1].toDouble(), tan[0].toDouble()) * 180f / Math.PI + 90

最后还是一样在 onDraw() 中加上旋转:

1
2

matrix.postRotate(direction, center.x, center.y)

效果如下:

整合

前面分模块逐步实现了点赞动画的各个部分,当然具体使用的时候还是要封装一下比较好。这里我封装了一个 PathAnimView ,总体管理所有的动画。一个基类 BasePathAnim ,用来扩展不同风格的动画。再结合具体的场景,便能实现一个完整的动画组件了。

直接上代码:

首先是管理所有动画的 PathAnimView :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

class PathAnimView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

private val mBitmapPaint = Paint()
private var mWidth = 0
private var mHeight = 0

private val mAnimList = ArrayList<BasePathAnim>()

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mWidth = w
mHeight = h
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return

for (anim in mAnimList) {
anim.draw(canvas,mBitmapPaint)
}
}

fun addAnim(anim:BasePathAnim) {
anim.setUpPath(0,0,mWidth,mHeight)
anim.setInvalidateCallback {
invalidate()
}
anim.setAnimEndCallback {
anim.clear()
mAnimList.remove(anim)
}
mAnimList.add(anim)
anim.start()
}

fun clear(){
for (anim in mAnimList) {
anim.clear()
}
mAnimList.clear()
}

}

然后是一个动画View的基类 BasePathAnim :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96

abstract class BasePathAnim {
//以下属性各子类会用到
protected val path = Path()//核心运动轨道
protected var time = 0L//当前时间
protected var direction = 0.0//当前切线方向
protected open val degree = 0f//旋转偏转角度 默认0
protected open val scale = 1f//缩放比例 默认1
protected open val alpha = 1f//透明度 默认不透明
protected open val duration: Long = 2200L//总时长,默认2200L
protected open val repeatMode: Int = 0
protected open val repeatCount: Int = 0
protected open val interpolator: BaseInterpolator = LinearInterpolator()
protected abstract val bitmap: Bitmap

private val mPathMeasure = PathMeasure()
private val mPathLocation = PointF(0f, 0f)
private var mAnimator: ValueAnimator? = null
private var mInvalidateCallback: (() -> Unit)? = null
private var mAnimEndCallback: (() -> Unit)? = null

fun setInvalidateCallback(callback: () -> Unit) {
mInvalidateCallback = callback
}

fun setAnimEndCallback(callback: () -> Unit) {
mAnimEndCallback = callback
}

fun start() {
mPathMeasure.setPath(path, false)
mAnimator = ValueAnimator.ofFloat(0f, mPathMeasure.length).apply {
duration = this@BasePathAnim.duration
repeatMode = this@BasePathAnim.repeatMode
repeatCount = this@BasePathAnim.repeatCount
interpolator = this@BasePathAnim.interpolator

addUpdateListener {
val value = it.animatedValue as Float
val point = FloatArray(2)
val tan = FloatArray(2)
mPathMeasure.getPosTan(value, point, tan)
mPathLocation.set(point[0], point[1])

direction = atan2(tan[1].toDouble(), tan[0].toDouble()) * 180f / Math.PI + 90
time = it.currentPlayTime
mInvalidateCallback?.invoke()
}

doOnEnd {
mAnimEndCallback?.invoke()
}

doOnCancel {
mAnimEndCallback?.invoke()
}
start()
}
doOnStart()
}

open fun draw(canvas: Canvas, paint: Paint) {
val matrix = Matrix()
val bmp = bitmap
if (bmp.isRecycled) return
val bitmapCenter = PointF(bmp.width / 2f, bmp.height / 2f)
doScale(matrix, bitmapCenter.x, bitmapCenter.y)
doRotate(matrix, bitmapCenter.x, bitmapCenter.y)
matrix.postTranslate(mPathLocation.x - bitmapCenter.x, mPathLocation.y - bitmapCenter.y)
paint.alpha = (255 * alpha).toInt()
canvas.drawBitmap(bmp, matrix, paint)
}

fun clear() {
mAnimator?.cancel()
mAnimator = null
doOnClear()
}

//设置路径
abstract fun setUpPath(left: Int, top: Int, right: Int, bottom: Int)

protected open fun doOnStart() {}

protected open fun doOnClear() {}

protected open fun doScale(matrix: Matrix, centerX: Float, centerY: Float) {
matrix.postScale(scale, scale, centerX, centerY)
}

protected open fun doRotate(matrix: Matrix, centerX: Float, centerY: Float) {
matrix.postRotate(degree, centerX, centerX)
}


}

使用时,只需在布局文件中添加:

1
2
3
4
5
6
<com.netease.gamechat.animviewdemo.PathAnimView
android:id="@+id/pathAnimView"
android:layout_width="88dp"
android:layout_height="440dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

然后创建一个继承自 BasePathAnim 的对象:

1
2
3

val anim = XXXAnim(bitmap)
pathAnimView.addAnim(anim)

这里举两个例子,一个是仿照开篇视频中那样实现的点赞动画,一个是路径飞行动画:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105

class LikeAnim(
private val mBitmap1: Bitmap,
private val mBitmap2: Bitmap
) : BasePathAnim() {

private var mSwingAnimator: ValueAnimator? = null
private var mSwingDegree = 0f

override fun doOnStart() {
mSwingAnimator = ValueAnimator.ofFloat(-24f, 24f).apply {
duration = 600L
repeatMode = ValueAnimator.REVERSE
repeatCount = ValueAnimator.INFINITE
interpolator = LinearInterpolator()
addUpdateListener {
val value = it.animatedValue as Float
mSwingDegree = value
}
start()
}

}

override fun setUpPath(left: Int, top: Int, right: Int, bottom: Int) {
val width = (right - left).toFloat()
val height = (bottom - top).toFloat()
val dx = width / 2f
val dy = height / 3f

val point0 = PointF(dx, dy * 3)
val point1 = PointF(dx, dy * 2)
val point2 = PointF(dx, dy)
val point3 = PointF(dx, 0f)


val control0 = PointF(dx * 0.5f, dy * 2.5f)
val control1 = PointF(dx * 1.5f, dy * 1.5f)
val control2 = PointF(dx * 0.5f, dy * 0.5f)

val offset = (Random.nextDouble(-0.25,0.25) * width).toFloat() //随机偏移量
point0.x+=offset * 0
point1.x+=offset * 0.3f
point2.x+=offset * 0.6f
point3.x+=offset * 1f
control0.x += offset * 0.15f
control1.x += offset * 0.45f
control2.x += offset * 0.75f

path.moveTo(point0.x, point0.y)
path.quadTo(control0.x, control0.y, point1.x, point1.y)
path.quadTo(control1.x, control1.y, point2.x, point2.y)
path.quadTo(control2.x, control2.y, point3.x, point3.y)
}

override val bitmap: Bitmap
get() = if (time < 1000) mBitmap1 else mBitmap2

override val duration: Long
get() = 3000L

override val interpolator: BaseInterpolator
get() = AccelerateInterpolator(0.6f)

override val alpha: Float
get() = when {
time > 2600 -> {
1 - (min(time, 3000) - 2600) / 400f
}
else -> {
1f
}
}

override val degree: Float
get() = mSwingDegree

override val scale: Float
get() = when {
time < 200 -> {
time / 200f
}
time in 800..1000 -> {
1 - (time - 800) / 200f
}
time in 1000..1200 -> {
(time -1000) / 200f
}
time > 2600 -> {
1 - (min(time, 3000) - 2600) / 400f
}
else -> {
1f
}
}


override fun doOnClear() {
mSwingAnimator?.cancel()
mSwingAnimator = null
mBitmap1.recycle()
mBitmap2.recycle()
}

}

效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

class FlyAnim(
private val mBitmap: Bitmap
) : BasePathAnim() {


override fun setUpPath(left: Int, top: Int, right: Int, bottom: Int) {
val width = (right - left).toFloat()
val height = (bottom - top).toFloat()
val center = PointF(width / 2f, height / 2f)

val point0 = PointF(center.x - width / 4f, center.y - height / 4f)
val point1 = PointF(center.x + width / 4f, center.y - height / 4f)
val point2 = PointF(center.x + width / 4f, center.y + height / 4f)
val point3 = PointF(center.x - width / 4f, center.y + height / 4f)

val point4 = PointF(center.x - width / 4f, center.y - height / 8f)
val point5 = PointF(center.x + width / 8f, center.y - height / 8f)
val point6 = PointF(center.x + width / 8f, center.y + height / 8f)
val point7 = PointF(center.x - width / 8f, center.y + height / 8f)

val point8 = PointF(center.x - width / 8f, center.y - height / 16f)
val point9 = PointF(center.x + width / 16f, center.y - height / 16f)
val point10 = PointF(center.x + width / 16f, center.y + height / 16f)
val point11 = PointF(center.x - width / 16f, center.y + height / 16f)

path.moveTo(point0.x, point0.y)
path.lineTo(point1.x, point1.y)
path.lineTo(point2.x, point2.y)
path.lineTo(point3.x, point3.y)
path.lineTo(point4.x, point4.y)
path.lineTo(point5.x, point5.y)
path.lineTo(point6.x, point6.y)
path.lineTo(point7.x, point7.y)
path.lineTo(point8.x, point8.y)
path.lineTo(point9.x, point9.y)
path.lineTo(point10.x, point10.y)
path.lineTo(point11.x, point11.y)
}

override val duration: Long
get() = 5 * 1000L

override val interpolator: BaseInterpolator
get() = DecelerateInterpolator()

override val bitmap: Bitmap
get() = mBitmap

override var degree: Float
get() = direction.toFloat()
set(value) {}
}

效果:

总结

以上就是一个简单的点赞动画实现,其实原理很简单,自定义View+Path+动画就可以搞定,我这个组件实现的也比较简陋,具体使用时可以再做优化。