自定义View进阶篇《八》——PathMeasure

Path & PathMeasure

顾名思义,PathMeasure是一个用来测量Path的类,主要有以下方法:

构造方法
公共方法

PathMeasure的方法也不多,接下来我们就逐一的讲解一下。

1.构造函数

构造函数有两个

无参构造函数:
 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可以闭合的话)。

在这里有两点需要明确:

  • 不论 forceClosed 设置为何种状态(true 或者 false), 都不会影响原有Path的状态,即 Path 与 PathMeasure 关联之后,之前的的 Path 不会有任何改变。
  • forceClosed 的设置状态可能会影响测量结果,如果 Path 未闭合但在与 PathMeasure 关联的时候设置 forceClosed 为 true 时,测量结果可能会比 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 的正方形的三条边,通过上面的示例就能验证以上两个问题。

  • 1.我们将 Path 与两个的 PathMeasure 进行关联,并给 forceClosed 设置了不同的状态,之后绘制再绘制出来的 Path 没有任何变化,所以与 Path 与 PathMeasure进行关联并不会影响 Path 状态。
  • 2.我们可以看到,设置 forceClosed 为 true 的方法比设置为 false 的方法测量出来的长度要长一点,这是由于 Path 没有闭合的缘故,多出来的距离正是 Path 最后一个点与最开始一个点之间点距离。forceClosed 为 false 测量的是当前 Path 状态的长度, forceClosed 为 true,则不论Path是否闭合测量的都是 Path 的闭合长度。

2.setPath、 isClosed 和 getLength

这三个方法都如字面意思一样,非常简单,这里就简单是叙述一下,不再过多讲解。

setPath 是 PathMeasure 与 Path 关联的重要方法,效果和 构造函数 中两个参数的作用是一样的。

isClosed 用于判断 Path 是否闭合,但是如果你在关联 Path 的时候设置 forceClosed 为 true 的话,这个方法的返回值则一定为true。

getLength 用于获取 Path 的总长度,在之前的测试中已经用过了。

3.getSegment

getSegment 用于获取Path的一个片段,方法如下:

boolean getSegment (float startD, float stopD, Path dst, boolean startWithMoveTo)

方法各个参数释义:


  • 如果 startD、stopD 的数值不在取值范围 [0, getLength] 内,或者 startD == stopD 则返回值为 false,不会改变 dst 内容。
  • 如果在安卓4.4或者之前的版本,在默认开启硬件加速的情况下,更改 dst 的内容后可能绘制会出现问题,请关闭硬件加速或者给 dst 添加一个单个操作,例如: dst.rLineTo(0, 0)

我们先看看这个方法如何使用:

我们创建了一个 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 的取值:


4.nextContour

我们知道 Path 可以由多条曲线构成,但不论是 getLength , getgetSegment 或者是其它方法,都只会在其中第一条线段上运行,而这个 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

通过测试,我们可以得到以下内容:

  • 1.曲线的顺序与 Path 中添加的顺序有关。
  • 2.getLength 获取到到是当前一条曲线分长度,而不是整个 Path 的长度。
  • 3.getLength 等方法是针对当前的曲线(其它方法请自行验证)。
5.getPosTan

这个方法是用于得到路径上某一长度的位置以及该位置的正切值:

boolean getPosTan (float distance, float[] pos, float[] tan)

方法各个参数释义:


这个方法也不难理解,除了其中 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();                                                           // 重绘页面

核心要点:

  • 1.通过 tan 得值计算出图片旋转的角度,tan 是 tangent 的缩写,即中学中常见的正切, 其中tan[0]是邻边边长,tan[1]是对边边长,而Math中 atan2 方法是根据正切是数值计算出该角度的大小,得到的单位是弧度(取值范围是 -pi 到 pi),所以上面又将弧度转为了角度。
  • 2.通过 Matrix 来设置图片对旋转角度和位移,这里使用的方法与前面讲解过对 canvas操作 有些类似,对于 Matrix 会在后面专一进行讲解,敬请期待。
  • 3.页面刷新,页面刷新此处是在 onDraw 里面调用了 invalidate 方法来保持界面不断刷新,但并不提倡这么做,正确对做法应该是使用 线程 或者 ValueAnimator 来控制界面的刷新,关于控制页面刷新这一部分会在后续的 动画部分 详细讲解,同样敬请期待。

关于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 转化为角(单位为弧度)的时候要注意参数顺序。

6.getMatrix

这个方法是用于得到路径上某一长度的位置以及该位置的正切值的矩阵:

boolean getMatrix (float distance, Matrix matrix, int flags)

方法各个参数释义:



其实这个方法就相当于我们在前一个例子中封装 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 方法的确可以节省一些代码,不过这里依旧需要注意一些内容:

Path & SVG

我们知道,用Path可以创建出各种个样的图形,但如果图形过于复杂时,用代码写就不现实了,不仅麻烦,而且容易出错,所以在绘制复杂的图形时我们一般是将 SVG 图像转换为 Path。

你说什么是 SVG?

SVG 是一种矢量图,内部用的是 xml 格式化存储方式存储这操作和数据,你完全可以将 SVG 看作是 Path 的各项操作简化书写后的存储格式。

Path 和 SVG 结合通常能诞生出一些奇妙的东西,如下:



该图片来自这个开源库 ->PathView
SVG 转 Path 的解析可以用这个库 -> AndroidSVG

限于篇幅以及本人精力,这一部分就暂不详解了,感兴趣的可以直接看源码,或者搜索一些相关的解析文章。

Path使用技巧

话说本篇文章的名字不是叫 玩出花样么?怎么只见前面啰啰嗦嗦的扯了一大堆不明所以的东西,花样在哪里?

前面的内容虽然啰嗦繁杂,但却是重中之重的基础,如果在修仙界,这叫根基,而下面讲述的内容的是招式,有了根基才能演化出千变万化的招式,而没有根基只学招式则是徒有其表,只能学一样会一样,很难适应千变万化的需求。

先放一个效果图,然后分析一下实现过程:



这是一个搜索的动效图,通过分析可以得到它应该有四种状态,分别如下:



这些状态是有序转换的,转换流程以及转换条件如下:
其中 正在搜索 这个状态持续时间长度是不确定的,在没有搜索完成前,应该一直处于搜索状态。

简单的分析了其大致的流程之后,就到了制作的重点:对细节对把握。

Path 划分

为了制作对方便,此处整个动效用了两个 Path, 一个是中间对放大镜, 另一个则是外侧的圆环,将两者全部画出来是这样子的。



其中 Path 的走向要把握好,如下(只是一个放大镜,并不是♂):



其中圆形上面的点可以用 PathMeasure 测量,无需计算。

动画状态与时间关联

此处使用的是 ValueAnimator,它可以将一段时间映射到一段数值上,随着时间变化不断的更新数值,并且可以使用插值器开控制数值变化规律(此处使用的是默认插值器)。

PS: 本来不想提前暴露这个的,准备偷偷留到动画部分(。-_-。) 但实在是没有优雅的替代方案了。

具体绘制

绘制部分是根据 当前状态以及从 ValueAnimator 获得的数值来截取 Path 中合适的部分绘制出来。
查看源码

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

推荐阅读更多精彩内容