动画的进阶
1、使用PathMeasure实现路径动画
1.1、PathMeasure的初始化
方法一:
创建PathMeasure()对象
public PathMeasure()
调用setPath()方法绑定path
public void setPath(Path path, boolean forceClosed)
方法二:
利用其带参构造函数
public PathMeasure(Path path, boolean forceClosed)
无论是哪种方法,都会接收一个参数forceClosed,它表示的是无论与之绑定的Path是否闭合,在使用PathMeasure计算的时候都会按照Path闭合的状态下进行计算,但是这并不会影响Path。
1.2、简单函数的使用
1.2.1、getLength()
public float getLength() //用于计算路径的长度
下面举例看下用法,分别设置forceClosed为true和false
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
paint.color = Color.RED
paint.style=Paint.Style.STROKE
paint.strokeWidth=5f
path.moveTo(0f,0f)
path.lineTo(0f,100f)
path.lineTo(100f,100f)
path.lineTo(100f,0f)
val measure1=PathMeasure(path,false)
val measure2=PathMeasure(path,true)
Log.e(javaClass.simpleName,"measure1 ${measure1.length} , measure2 ${measure2.length}}")
canvas?.drawPath(path,paint)
}
上面代码绘制出的路径图如下
image.png
从图中可以看出我们绘制的只是边长为100的正方形的三条边,而日志打印如下:
E/PathView: measure1 300.0 , measure2 400.0}
很明显,当forceClosed为false时,测出的是当前Path的长度,而当forceClosed为true时, 则不论path是否闭合,测量的都是Path闭合状态的长度。
1.2.2、isClosed()
函数的声明如下
public boolean isClosed()
该函数用于判断在测量Path时是否计算闭合。所以在关联Path时设置forceClosed为true则这个函数的返回值一定为true。如果这个Path是闭合的,即使forceClosed设置为false,这个函数也一定返回true。
1.2.3、nextContour()
函数的声明如下:
public boolean nextContour()
我们知道Path可能有多条曲线构成,但是不论getLength()、getSegment()还是其他函数,都是针对第一条曲线进行计算的,而nextContour()就是用于跳转到下一条曲线的函数。如果跳转成功了,则返回true,如果跳转失败了,则返回false。
我们创建了一个Path并使其包含三条闭合的曲线,如下图所示,下面我们就用PathMeasure测量这三条曲线的长度。
canvas?.translate(150f,150f)
paint.color = Color.RED
paint.style=Paint.Style.STROKE
paint.strokeWidth=5f
path.addRect(-50f,-50f,50f,50f,Path.Direction.CCW)
path.addRect(-100f,-100f,100f,100f,Path.Direction.CCW)
path.addRect(-120f,-120f,120f,120f,Path.Direction.CCW)
val measure=PathMeasure(path,false)
canvas?.drawPath(path,paint)
do{
val len=measure.length
Log.e(javaClass.simpleName,"len=$len")
}while (measure.nextContour())
输出log如下:
E/PathView: len=400.0
E/PathView: len=800.0
E/PathView: len=960.0
从输出结果可知:
- 通过PathMeasure.nextContour()得到的曲线顺序和添加的顺序一致。
- getLength()获取的当前曲线的长度,不是整条曲线的长度。
1.2.4、getSegment()
函数的声明如下
public boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)
这个函数是用于截取整个Path中的某个片段,通过startD和stopD控制截取的长度,并将截取后的结果保存在dst中。最后一个参数startWithMoveTo表示起始点是否使用moveTo将路径的新起始点移到结果Path的起始点,通常设置为true。以保证每次截取的Path都是正常的、完整的,通常和dst一起使用,因为dst中保存的Path是被不断添加的,而不是每次被覆盖的,如果设置为false,则新增的Path会从上一次Path的终点开始计算,这样可以保证Path片段的连续性。
参数:
- float startD:开始截取位置距Path起点的长度
- float stopD:结束截取位置距Path起点的长度
- Path dst:截取的Path将会被添加到dst中。
注意是添加,而不是替换
- boolean startWithMoveTo:起始点是否使用moveTo
**注意: - 1、如果startD、stopD的数值不在取值范围[0,getLength]内或者startD==stopD,则返回false,而且不会改变dst中的内容。
- 2、开启硬件加速后,绘图会出现问题。使用getSegment()需要禁用硬件加速功能。**
示例一
canvas?.translate(150f,150f)
paint.color = Color.RED
paint.style=Paint.Style.STROKE
paint.strokeWidth=5f
canvas?.drawPoint(0f,0f,paint)
path.addRect(-50f,-50f,50f,50f,Path.Direction.CCW)
canvas?.drawPath(path,paint)
val measure=PathMeasure(path,true)
val dstPath=Path()
measure.getSegment(50f,150f,dstPath,true)
measure.getSegment(200f,350f,dstPath,true)
paint.color=Color.BLACK
canvas?.drawPath(dstPath,paint)
绘制完成后的如下图
image.png
红色矩形为原Path的路径,黑色为截取后的片段
当startWithMoveTo设置为false后,并不会调用moveTo将路径起点移动到裁剪路径的起点,效果如下图
image.png
示例二:路径加载动画
路径绘制是PathMeasure的常用的功能,下面看下如何实现一条圆形路径如何从0增加到这个圆,代码如下
lass PathView(context: Context, sttrs: AttributeSet) : View(context, sttrs) {
private val path = Path()
private val paint = Paint()
private var stopD: Float = 0f
private lateinit var measure: PathMeasure
private val dstPath = Path()
init {
setLayerType(LAYER_TYPE_SOFTWARE,null)
paint.color = Color.RED
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
path.addCircle(0f, 0f, 100f, Path.Direction.CCW)
measure = PathMeasure(path, true)
val animtor = ValueAnimator.ofFloat(0f, measure.length)
animtor.addUpdateListener {
Log.e(javaClass.simpleName,"$stopD")
dstPath.rewind()
stopD = it.animatedValue as Float
measure.getSegment(0f, stopD, dstPath, true)
invalidate()
}
animtor.duration = 1000
animtor.repeatMode=REVERSE
animtor.repeatCount= INFINITE
animtor.start()
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.translate(150f, 150f)
canvas?.drawPath(dstPath, paint)
}
}
1.2.5、getPosTan()
函数的声明如下
public boolean getPosTan(float distance, float[] pos, float[] tan)
该函数用于获取路径上某一段距离的位置和正切值。
参数:
- float distance:距Path起点的一段距离
- float[] pos:该点的坐标值,该点在画布上的位置有两个点,pos[0]表示点的x坐标,pos[1]表示点的y坐标。
-
float[] tan:表示该点的正切值
下图展示了坐标系中某点的正切值的计算方法
image.png
比如上图中我们要求A点的正切值,就是将A点与坐标原点链接起来,所形成的夹角a的正切值就是A点的正切值。
而getPosTan()函数中获取的正切值也是一个二维数组,它代表了一个坐标(x,y),而通过y/x得到的就是对应点的正切值。而这个二维数组所代表的坐标对应的就是半径为1的圆的对应点。
getPosTan()函数返回的是半径为1的圆中对应点的x,y坐标,那怎么求得夹角的值呢?
在Math类中,有两个求反正切值的函数
double atan(double d)
double atan2(double y,double x)
这两个函数都可以根据一个正切值求得对应的夹角数。函数atan(double d)的参数是一个弧度值,即正切的结果值。而函数atan2(double y,double x)的参数x,y就是正切的点的坐标。
很显然我们通过atan2()函数就能得到夹角度数。
而这个夹角的用处非常大,比如下图中有一个沿着圆形旋转的箭头,而当箭头围绕圆形旋转时, 应该实时的旋转箭头的方向,以使它的头与圆形边线吻合,比如从X轴开始移动了,移动了a角度后的情形如下图所示:
在移动a角度后,三角形应该旋转多少度才能跟圆形边线吻合呢?只有箭头一直沿着切线的方向 ,才能与圆形边线吻合,所以∠C就是我们要旋转的角度,由于∠a+∠b=90°,∠b+∠c=90°,所以∠a=∠c,正切夹角是多少度就旋转多少度。
所以,如果想让移动点旋转至与切线重合,则旋转角度要与正切角度相同。
示例:箭头加载动画
这里将利用getPosTan()实现下面箭头加载动画
image.png
具体代码如下:
class PathView(context: Context, sttrs: AttributeSet) : View(context, sttrs) {
private val path = Path()
private val paint = Paint()
private var stopD: Float = 0f
private lateinit var measure: PathMeasure
private val dstPath = Path()
private val posArray = FloatArray(2)
private val tanArray = FloatArray(2)
private val arrow = BitmapFactory.decodeResource(context.resources, R.mipmap.arrow)
private val mMatrix=Matrix()
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
paint.color = Color.RED
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
path.addCircle(0f, 0f, 100f, Path.Direction.CCW)
measure = PathMeasure(path, true)
val animtor = ValueAnimator.ofFloat(0f, measure.length)
animtor.addUpdateListener {
dstPath.rewind()
stopD = it.animatedValue as Float
measure.getSegment(0f, stopD, dstPath, true)
mMatrix.reset()
measure.getPosTan(stopD, posArray, tanArray)
val degree=Math.toDegrees(Math.atan2(tanArray[1].toDouble(),tanArray[0].toDouble()))
mMatrix.postRotate(degree.toFloat(),arrow.width/2.toFloat(),arrow.height/2.toFloat())
mMatrix.postTranslate(posArray[0],posArray[1])
invalidate()
}
animtor.duration = 3000
animtor.repeatMode = REVERSE
animtor.repeatCount = INFINITE
animtor.start()
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.translate(150f, 150f)
canvas?.drawBitmap(arrow,mMatrix,paint)
canvas?.drawPath(dstPath, paint)
}
}
需要注意的是:
- 通过Math.atan2(tanArray[1].toDouble(),tanArray[0].toDouble())得到的是弧度值,而不是角度值,所以这里使用Math.toDegrees()将弧度值转换成了角度值。
- 先利用matrix.postRotate()将图片
围绕图片的中心点
旋转指定角度,以便和切线重合。然后利用matrix.postTranslate()将图片从默认的(0,0)移动到当前路径的最前端。最后将图片绘制到画布上。
但效果图却如下图
image.png
从效果图中可以看出箭头虽然沿着路径,但是有点偏差,图片移动的情况如下图
image.png
在移动图片时,以图片的左上角为起始点开始移动,所以原来的(0,0)点移动(pos[0],pos[1])距离后,图片的左上角在(pos[0],pos[1])位置上。这说明我们移动过头了,少移动半个图片就够了。
将移动的半个图片加以改造,少移动半个图片即可
mMatrix.postTranslate(posArray[0]-arrow.width/2,posArray[1]-arrow.height/2)
1.2.6、getMatrix()
函数声明如下
public boolean getMatrix(float distance, Matrix matrix, int flags)
这个函数用于得到路径上某一长度的位置以及该位置的正切值
的矩阵。
- float distance:距离Path起点的长度。
- Matrix matrix:根据flags封装好的matrix会根据flags的设置而存入不同的内容
- int flags:用于指定哪些内容存入matrix中。flags的值有两个:PathMeasure.POSITION_MATRIX_FLAG表示位置信息,PathMeasure.TANGENT_MATRIX_FLAG表示当前位置点的切边信息,使得图片按照Path旋转。可以指定一个,也可以使用 ‘|’位运算符同时指定。
很明显:getMatrix()是getPosTan()的另一种实现而已,只不过getPosTan()是将位置信息和切边信息分别存在了pos和tan的数组中。而getMatrix()直接将信息存入matrix数组中。
下面尝试使用getMatrix()替换getPosTan()实现箭头动画
init {
//.....代码相同 省略
animtor.addUpdateListener {
dstPath.rewind()
stopD = it.animatedValue as Float
measure.getSegment(0f, stopD, dstPath, true)
mMatrix.reset()
// measure.getPosTan(stopD, posArray, tanArray)
// val degree=Math.toDegrees(Math.atan2(tanArray[1].toDouble(),tanArray[0].toDouble()))
// mMatrix.postRotate(degree.toFloat(),arrow.width/2.toFloat(),arrow.height/2.toFloat())
// mMatrix.postTranslate(posArray[0]-arrow.width/2,posArray[1]-arrow.height/2)
//上面注释代码是使用getPosTan()实现的,下面两句是通过getMatrix()方法实现的 measure.getMatrix(stopD,mMatrix,PathMeasure.POSITION_MATRIX_FLAG or PathMeasure.TANGENT_MATRIX_FLAG)
mMatrix.preTranslate(-arrow.width/2.toFloat(),-arrow.height/2.toFloat())
invalidate()
}
animtor.duration = 3000
animtor.repeatMode = REVERSE
animtor.repeatCount = INFINITE
animtor.start()
}