可以看到,在经过 Path之基本操作、Path之贝塞尔曲线和 Path之完结篇后, Path中各类方法基本上都讲完了,表格中还没有讲解到到方法就是矩阵变换了,难道本篇终于要讲矩阵了? 非也,矩阵这一部分仍在后面单独讲解,本篇主要讲解 PathMeasure 这个类与 Path 的一些使用技巧。
PS:不要问我为什么不讲 PathEffect,因为这个方法在后面的Paint系列中。
先放一个图镇楼,省的下面无聊的内容把你们都吓跑了Σ( ̄。 ̄ノ)ノ
顾名思义,PathMeasure是一个用来测量Path的类,主要有以下方法:
方法名 | 释义 |
---|---|
PathMeasure() | 创建一个空的PathMeasure |
PathMeasure(Path path, boolean forceClosed) | 创建 PathMeasure 并关联一个指定的Path(Path需要已经创建完成)。 |
返回值 | 方法名 | 释义 |
---|---|---|
void | setPath(Path path, boolean forceClosed) | 关联一个Path |
boolean | isClosed() | 是否闭合 |
float | getLength() | 获取Path的长度 |
boolean | nextContour() | 跳转到下一个轮廓 |
boolean | getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo) | 截取片段 |
boolean | getPosTan(float distance, float[] pos, float[] tan) | 获取指定长度的位置坐标及该点切线值 |
boolean | getMatrix(float distance, Matrix matrix, int flags) | 获取指定长度的位置坐标及该点Matrix |
PathMeasure的方法也不多,接下来我们就逐一的讲解一下。
构造函数有两个。
无参构造函数:
PathMeasure ()
用这个构造函数可创建一个空的 PathMeasure,但是使用之前需要先调用 setPath 方法来与 Path 进行关联。被关联的 Path 必须是已经创建好的,如果关联之后 Path 内容进行了更改,则需要使用 setPath 方法重新关联。
有参构造函数:
PathMeasure (Path path, boolean forceClosed)
用这个构造函数是创建一个 PathMeasure 并关联一个 Path, 其实和创建一个空的 PathMeasure 后调用 setPath 进行关联效果是一样的,同样,被关联的 Path 也必须是已经创建好的,如果关联之后 Path 内容进行了更改,则需要使用 setPath 方法重新关联。
该方法有两个参数,第一个参数自然就是被关联的 Path 了,第二个参数是用来确保 Path 闭合,如果设置为 true, 则不论之前Path是否闭合,都会自动闭合该 Path(如果Path可以闭合的话)。
在这里有两点需要明确:
下面我们用一个例子来验证一下:
canvas.translate(mViewWidth/2,mViewHeight/2);
Path path = new Path();
path.lineTo(0,200);
path.lineTo(200,200);
path.lineTo(200,0);
PathMeasure measure1 = new PathMeasure(path,false);
PathMeasure measure2 = new PathMeasure(path,true);
Log.e("TAG", "forceClosed=false---->"+measure1.getLength());
Log.e("TAG", "forceClosed=true----->"+measure2.getLength());
canvas.drawPath(path,mDeafultPaint);
log如下:
com.gcssloop.canvas E/TAG: forceClosed=false---->600.0
com.gcssloop.canvas E/TAG: forceClosed=true----->800.0
绘制在界面上的效果如下:
我们所创建的 Path 实际上是一个边长为 200 的正方形的三条边,通过上面的示例就能验证以上两个问题。
这三个方法都如字面意思一样,非常简单,这里就简单是叙述一下,不再过多讲解。
setPath 是 PathMeasure 与 Path 关联的重要方法,效果和 构造函数 中两个参数的作用是一样的。
isClosed 用于判断 Path 是否闭合,但是如果你在关联 Path 的时候设置 forceClosed 为 true 的话,这个方法的返回值则一定为true。
getLength 用于获取 Path 的总长度,在之前的测试中已经用过了。
getSegment 用于获取Path的一个片段,方法如下:
boolean getSegment (float startD, float stopD, Path dst, boolean startWithMoveTo)
方法各个参数释义:
参数 | 作用 | 备注 |
---|---|---|
返回值(boolean) | 判断截取是否成功 | true 表示截取成功,结果存入dst中,false 截取失败,不会改变dst中内容 |
startD | 开始截取位置距离 Path 起点的长度 | 取值范围: 0 <= startD < stopD <= Path总长度 |
stopD | 结束截取位置距离 Path 起点的长度 | 取值范围: 0 <= startD < stopD <= Path总长度 |
dst | 截取的 Path 将会添加到 dst 中 | 注意: 是添加,而不是替换 |
startWithMoveTo | 起始点是否使用 moveTo | 用于保证截取的 Path 第一个点位置不变 |
我们先看看这个方法如何使用:
我们创建了一个 Path, 并在其中添加了一个矩形,现在我们想截取矩形中的一部分,就是下图中红色的部分。
矩形边长400dp,起始点在左上角,顺时针
代码:
canvas.translate(mViewWidth / 2, mViewHeight / 2); // 平移坐标系
Path path = new Path(); // 创建Path并添加了一个矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);
Path dst = new Path(); // 创建用于存储截取后内容的 Path
PathMeasure measure = new PathMeasure(path, false); // 将 Path 与 PathMeasure 关联
// 截取一部分存入dst中,并使用 moveTo 保持截取得到的 Path 第一个点的位置不变
measure.getSegment(200, 600, dst, true);
canvas.drawPath(dst, mDeafultPaint); // 绘制 dst
结果如下:
从上图可以看到我们成功到将需要到片段截取了出来,然而当 dst 中有内容时会怎样呢?
canvas.translate(mViewWidth / 2, mViewHeight / 2); // 平移坐标系
Path path = new Path(); // 创建Path并添加了一个矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);
Path dst = new Path(); // 创建用于存储截取后内容的 Path
dst.lineTo(-300, -300); // <--- 在 dst 中添加一条线段
PathMeasure measure = new PathMeasure(path, false); // 将 Path 与 PathMeasure 关联
measure.getSegment(200, 600, dst, true); // 截取一部分 并使用 moveTo 保持截取得到的 Path 第一个点的位置不变
canvas.drawPath(dst, mDeafultPaint); // 绘制 Path
结果如下:
从上面的示例可以看到 dst 中的线段保留了下来,可以得到结论:被截取的 Path 片段会添加到 dst 中,而不是替换 dst 中到内容。
前面两个例子中 startWithMoveTo 均为 true, 如果设置为false会怎样呢?
canvas.translate(mViewWidth / 2, mViewHeight / 2); // 平移坐标系
Path path = new Path(); // 创建Path并添加了一个矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW);
Path dst = new Path(); // 创建用于存储截取后内容的 Path
dst.lineTo(-300, -300); // 在 dst 中添加一条线段
PathMeasure measure = new PathMeasure(path, false); // 将 Path 与 PathMeasure 关联
measure.getSegment(200, 600, dst, false); // <--- 截取一部分 不使用 startMoveTo, 保持 dst 的连续性
canvas.drawPath(dst, mDeafultPaint); // 绘制 Path
结果如下:
从该示例我们又可以得到一条结论:如果 startWithMoveTo 为 true, 则被截取出来到Path片段保持原状,如果 startWithMoveTo 为 false,则会将截取出来的 Path 片段的起始点移动到 dst 的最后一个点,以保证 dst 的连续性。
从而我们可以用以下规则来判断 startWithMoveTo 的取值:
取值 | 主要功用 |
---|---|
true | 保证截取得到的 Path 片段不会发生形变 |
false | 保证存储截取片段的 Path(dst) 的连续性 |
我们知道 Path 可以由多条曲线构成,但不论是 getLength , getSegment 或者是其它方法,都只会在其中第一条线段上运行,而这个 nextContour
就是用于跳转到下一条曲线到方法,如果跳转成功,则返回 true, 如果跳转失败,则返回 false。
如下,我们创建了一个 Path 并使其中包含了两个闭合的曲线,内部的边长是200,外面的边长是400,现在我们使用 PathMeasure 分别测量两条曲线的总长度。
代码:
canvas.translate(mViewWidth / 2, mViewHeight / 2); // 平移坐标系
Path path = new Path();
path.addRect(-100, -100, 100, 100, Path.Direction.CW); // 添加小矩形
path.addRect(-200, -200, 200, 200, Path.Direction.CW); // 添加大矩形
canvas.drawPath(path,mDeafultPaint); // 绘制 Path
PathMeasure measure = new PathMeasure(path, false); // 将Path与PathMeasure关联
float len1 = measure.getLength(); // 获得第一条路径的长度
measure.nextContour(); // 跳转到下一条路径
float len2 = measure.getLength(); // 获得第二条路径的长度
Log.i("LEN","len1="+len1); // 输出两条路径的长度
Log.i("LEN","len2="+len2);
log输出结果:
com.gcssloop.canvas I/LEN: len1=800.0
com.gcssloop.canvas I/LEN: len2=1600.0
通过测试,我们可以得到以下内容:
这个方法是用于得到路径上某一长度的位置以及该位置的正切值:
boolean getPosTan (float distance, float[] pos, float[] tan)
方法各个参数释义:
参数 | 作用 | 备注 |
---|---|---|
返回值(boolean) | 判断获取是否成功 | true表示成功,数据会存入 pos 和 tan 中, false 表示失败,pos 和 tan 不会改变 |
distance | 距离 Path 起点的长度 | 取值范围: 0 <= distance <= getLength |
pos | 该点的坐标值 | 当前点在画布上的位置,有两个数值,分别为x,y坐标。 |
tan | 该点的正切值 | 当前点在曲线上的方向,使用 Math.atan2(tan[1], tan[0]) 获取到正切角的弧度值。 |
这个方法也不难理解,除了其中 tan
这个东东,这个东西是干什么的呢?
tan
是用来判断 Path 上趋势的,即在这个位置上曲线的走向,请看下图示例,注意箭头的方向:
点击这里下载箭头图片
可以看到 上图中箭头在沿着 Path 运动时,方向始终与 Path 走向保持一致,保持方向主要就是依靠 tan
。
下面我们来看看代码是如何实现的,首先我们需要定义几个必要的变量:
private float currentValue = 0; // 用于纪录当前的位置,取值范围[0,1]映射Path的整个长度
private float[] pos; // 当前点的实际位置
private float[] tan; // 当前点的tangent值,用于计算图片所需旋转的角度
private Bitmap mBitmap; // 箭头图片
private Matrix mMatrix; // 矩阵,用于对图片进行一些操作
初始化这些变量(在构造函数中调用这个方法):
private void init(Context context) {
pos = new float[2];
tan = new float[2];
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 2; // 缩放图片
mBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.arrow, options);
mMatrix = new Matrix();
}
具体绘制:
canvas.translate(mViewWidth / 2, mViewHeight / 2); // 平移坐标系
Path path = new Path(); // 创建 Path
path.addCircle(0, 0, 200, Path.Direction.CW); // 添加一个圆形
PathMeasure measure = new PathMeasure(path, false); // 创建 PathMeasure
currentValue += 0.005; // 计算当前的位置在总长度上的比例[0,1]
if (currentValue >= 1) {
currentValue = 0;
}
measure.getPosTan(measure.getLength() * currentValue, pos, tan); // 获取当前位置的坐标以及趋势
mMatrix.reset(); // 重置Matrix
float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI); // 计算图片旋转角度
mMatrix.postRotate(degrees, mBitmap.getWidth() / 2, mBitmap.getHeight() / 2); // 旋转图片
mMatrix.postTranslate(pos[0] - mBitmap.getWidth() / 2, pos[1] - mBitmap.getHeight() / 2); // 将图片绘制中心调整到与当前点重合
canvas.drawPath(path, mDeafultPaint); // 绘制 Path
canvas.drawBitmap(mBitmap, mMatrix, mDeafultPaint); // 绘制箭头
invalidate(); // 重绘页面
核心要点:
atan2
方法是根据正切是数值计算出该角度的大小,得到的单位是弧度(取值范围是 -pi 到 pi),所以上面又将弧度转为了角度。Matrix
会在后面专一进行讲解,敬请期待。关于tan
这个参数有很多魔法师不理解,特此拉出来详述一下,tan
在数学中被称为正切,在直角三角形中,一个锐角的正切定义为它的对边(Opposite side)与邻边(Adjacent side)的比值(来自维基百科):
我们此处用 tan
来描述 Path 上某一点的切线方向,主要用了两个数值 tan[0] 和 tan[1] 来描述这个切线的方向(切线方向与x轴夹角) ,看上面公式可知 tan
既可以用 对边/邻边
来表述,也可以用 sin/cos
来表述,此处用两种理解方式均可以(注意下面等价关系):
tan[0] = cos = 邻边(单位圆x坐标)
tan[1] = sin = 对边(单位圆y坐标)
以 sin/cos理解:
在圆上最右侧点的切线方向向下(动图中小飞机朝向和切线朝向一致),切线角度为90度.
sin90 = 1,
cos90 = 0
tan[0] = cos = 0
tan[1] = sin = 1
以 对边/邻边 理解(单位圆上坐标):
按照这种理解方式需要借助一个单位圆,单位圆上任意一点到圆心到距离均为 1,以下图30度为例:
tan30 = 对边/邻边 = AB/OA = B点y坐标/B点x坐标
另外根据单位圆性质同样可以证得: sin30 = 对边/斜边 = AB/OB = AB = B点y坐标 (单位圆边上任意一点距离圆心距离均为1,故OB = 1) cos30 = 邻边/斜边 = OA/OB = OA = B点x坐标
化为通用公式即为: sin = 该角度在单位圆上对应点的y坐标 cos = 该角度在单位圆上对应点的x坐标
即 tan = sin/cos = y/x tan[0] = x tan[1] = y
另外注意,这个单位圆与小飞机路径没有半毛钱关系,例如上一个例子中的90度切线,不要在单位圆上找对应位置,要找对应角度的位置,90度对应的位置是(0,1),所以: tan[0] = x = 0 tan[1] = y = 1
其实绕来绕去全是等价的 (╯°Д°)╯︵ ┻━┻
PS: 使用 Math.atan2(tan[1], tan[0]) 将 tan 转化为角(单位为弧度)的时候要注意参数顺序。
这个方法是用于得到路径上某一长度的位置以及该位置的正切值的矩阵:
boolean getMatrix (float distance, Matrix matrix, int flags)
方法各个参数释义:
参数 | 作用 | 备注 |
---|---|---|
返回值(boolean) | 判断获取是否成功 | true表示成功,数据会存入matrix中,false 失败,matrix内容不会改变 |
distance | 距离 Path 起点的长度 | 取值范围: 0 <= distance <= getLength |
matrix | 根据 falgs 封装好的matrix | 会根据 flags 的设置而存入不同的内容 |
flags | 规定哪些内容会存入到matrix中 | 可选择 POSITION_MATRIX_FLAG(位置) ANGENT_MATRIX_FLAG(正切) |
其实这个方法就相当于我们在前一个例子中封装 matrix
的过程由 getMatrix
替我们做了,我们可以直接得到一个封装好到 matrix
,岂不快哉。
但是我们看到最后到 flags
选项可以选择 位置
或者 正切
,如果我们两个选项都想选择怎么办?
如果两个选项都想选择,可以将两个选项之间用 |
连接起来,如下:
measure.getMatrix(distance, matrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);
我们可以将上面都例子中 getPosTan
替换为 getMatrix
, 看看是不是会显得简单很多:
具体绘制:
Path path = new Path(); // 创建 Path
path.addCircle(0, 0, 200, Path.Direction.CW); // 添加一个圆形
PathMeasure measure = new PathMeasure(path, false); // 创建 PathMeasure
currentValue += 0.005; // 计算当前的位置在总长度上的比例[0,1]
if (currentValue >= 1) {
currentValue = 0;
}
// 获取当前位置的坐标以及趋势的矩阵
measure.getMatrix(measure.getLength() * currentValue, mMatrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);
mMatrix.preTranslate(-mBitmap.getWidth() / 2, -mBitmap.getHeight() / 2); // <-- 将图片绘制中心调整到与当前点重合(注意:此处是前乘pre)
canvas.drawPath(path, mDeafultPaint); // 绘制 Path
canvas.drawBitmap(mBitmap, mMatrix, mDeafultPaint); // 绘制箭头
invalidate(); // 重绘页面
由于此处代码运行结果与上面一样,便不再贴图片了,请参照上面一个示例的效果图。
可以看到使用 getMatrix 方法的确可以节省一些代码,不过这里依旧需要注意一些内容:
matrix
的操作必须要在 getMatrix
之后进行,否则会被 getMatrix
重置而导致无效。preTranslate
调整为图片中心。我们知道,用Path可以创建出各种个样的图形,但如果图形过于复杂时,用代码写就不现实了,不仅麻烦,而且容易出错,所以在绘制复杂的图形时我们一般是将 SVG 图像转换为 Path。
你说什么是 SVG?
SVG 是一种矢量图,内部用的是 xml 格式化存储方式存储这操作和数据,你完全可以将 SVG 看作是 Path 的各项操作简化书写后的存储格式。
Path 和 SVG 结合通常能诞生出一些奇妙的东西,如下:
该图片来自这个开源库 ->PathView SVG 转 Path 的解析可以用这个库 -> AndroidSVG
限于篇幅以及本人精力,这一部分就暂不详解了,感兴趣的可以直接看源码,或者搜索一些相关的解析文章。
话说本篇文章的名字不是叫 玩出花样么?怎么只见前面啰啰嗦嗦的扯了一大堆不明所以的东西,花样在哪里?
前面的内容虽然啰嗦繁杂,但却是重中之重的基础,如果在修仙界,这叫根基,而下面讲述的内容的是招式,有了根基才能演化出千变万化的招式,而没有根基只学招式则是徒有其表,只能学一样会一样,很难适应千变万化的需求。
先放一个效果图,然后分析一下实现过程:
这是一个搜索的动效图,通过分析可以得到它应该有四种状态,分别如下:
状态 | 概述 |
---|---|
初始状态 | 初始状态,没有任何动效,只显示一个搜索标志 |
准备搜索 | 放大镜图标逐渐变化为一个点 |
正在搜索 | 围绕这一个圆环运动,并且线段长度会周期性变化 |
准备结束 | 从一个点逐渐变化成为放大镜图标 |
这些状态是有序转换的,转换流程以及转换条件如下:
其中
正在搜索
这个状态持续时间长度是不确定的,在没有搜索完成前,应该一直处于搜索状态。
简单的分析了其大致的流程之后,就到了制作的重点:对细节对把握。
为了制作对方便,此处整个动效用了两个 Path, 一个是中间对放大镜, 另一个则是外侧的圆环,将两者全部画出来是这样子的。
其中 Path 的走向要把握好,如下(只是一个放大镜,并不是♂):
其中圆形上面的点可以用 PathMeasure 测量,无需计算。
此处使用的是 ValueAnimator,它可以将一段时间映射到一段数值上,随着时间变化不断的更新数值,并且可以使用插值器开控制数值变化规律(此处使用的是默认插值器)。
PS: 本来不想提前暴露这个的,准备偷偷留到动画部分(。-_-。) 但实在是没有优雅的替代方案了。
绘制部分是根据 当前状态以及从 ValueAnimator 获得的数值来截取 Path 中合适的部分绘制出来。
上面的内容是为了帮助大家从把控全局流程以及理解某些细节的设计思路,而更多的内容都藏在代码中,代码总体也不算长,感兴趣的可以自己敲一遍。
Android 性能调优系列:https://0a.fit/dNHYY
Android 车载学习指南:https://0a.fit/jdVoy
Android Framework核心知识点笔记:https://0a.fit/acnLL
Android 音视频学习笔记:https://0a.fit/BzPVh
Jetpack全家桶(含Compose):https://0a.fit/GQJSl
Kotlin 入门到精进:https://0a.fit/kdfWR
Flutter 基础到进阶实战:https://0a.fit/xvcHV
Android 八大知识体系:https://0a.fit/mieWJ
Android 中高级面试题锦:https://0a.fit/YXwVq
后续如有新知识点,将会持续更新,尽请期待……