PathMeasure的API讲解与实战——Android高级UI

6,574 阅读11分钟

目录
一、前言
二、API讲解
三、实战
四、更多案例
五、写在最后

一、前言

2019年了,然而2017计划写的东西还没开始😂,这次的拖延症来的比平常早却去的比平常晚。今天进行分享的是UI中的PathMeasure,同时记录自己在使用过程中的几个疑惑点。话不多说,开始进入正题。

二、API讲解

这一小节主要是对PathMeasure的构造方法公有方法进行讲解

1、构造方法

(1)PathMeasure()

public PathMeasure() 

方法描述: 创建一个空的PathMeasure,但是使用之前需要先调用 setPath 方法来与 Path 进行关联。被关联的 Path 必须是已经创建好的,如果关联之后 Path 内容进行了更改,则需要使用 setPath 方法重新关联。

(2)PathMeasure(Path path, boolean forceClosed)

public PathMeasure(Path path, boolean forceClosed) 

方法描述: 创建 PathMeasure 并关联一个指定的Path,且Path需要已经创建完成。 这个构造方法其实 和 使用 PathMeasure() 后调用 setPath方法 进行关联一个Path的效果是一样的;当然,被关联的 Path 也必须是已经创建好的,如果关联之后 Path 内容进行了更改,则需要使用 setPath 方法重新关联。

参数解析: 第一个参数 path: 被关联的 Path,也就是需要测量的Path; 第二个参数 forceClosed: 是否要闭合Path。 设置为true:则不论Path是否闭合,都会自动闭合该 Path(如果Path可以闭合的话),然后进行测量; 设置为false:则Path保持原来的样子,进行测量;

值得注意的两个小点:(敲黑板了!!!)
1、不论 forceClosed 设置为何种状态(true 或者 false),都不会影响原有Path的状态,即 Path 与 PathMeasure 关联之后,之前的Path 不会有任何改变
2、forceClosed 的设置状态可能会影响测量结果,如果 Path 未闭合但在与 PathMeasure 关联的时候设置 forceClosed 为 true 时,测量结果可能会比 Path 实际长度稍长一点,具体请看下面的例子。

举个栗子🌰

完整代码请看这里,传送门

代码主要画了如下图的路径,然后对使用PathMeasure与该path进行关联,一个对forceClosed设置为true,一个为false,然后进行日志打印。 可以清楚的设置为true的路径长度为800(五段折线加起来是600,再加上头尾相连的长度200,正好是800),而为false的长度为600(正好是五段折线加起来是600) 如果你的Path已经是闭合的(即头尾相连的),则此时forceClosed设置为true或false,其长度结果是一样的。

Path mPath;
Paint mPaint;
int width;
int height;
boolean isInit = false;
PathMeasure closePathMeasure;
PathMeasure noClosePathMeasure;

@Override
protected void init(Context context) {
    mPath = new Path();
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setColor(ContextCompat.getColor(context, R.color.color_blue));
    mPaint.setStyle(Paint.Style.STROKE);
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    if (!isInit) {
        isInit = true;
        width = getMeasuredWidth() / 2;
        height = getMeasuredHeight() / 2;
        
        mPath.lineTo(0, 100);
        mPath.lineTo(100, 100);
        mPath.lineTo(100, -100);
        mPath.lineTo(200, -100);
        mPath.lineTo(200, 0);

        closePathMeasure = new PathMeasure(mPath, true);
        float closeLength = closePathMeasure.getLength();

        noClosePathMeasure = new PathMeasure(mPath, false);
        float noCloseLength = noClosePathMeasure.getLength();
        
        Log.i(TAG, "[closeLength:" + closeLength +
                "; noCloseLength:" + noCloseLength + "]");
    }
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(width, height);
    canvas.drawPath(mPath, mPaint);
}

日志输出:

2、共有方法

(1)setPath

public void setPath(Path path, boolean forceClosed) 

方法描述: 关联一个Path,该方法的作用是:当路径Path变动后,PathMeasure需要重新关联,否则从PathMeasure得到的数据还是之前关联的Path数据,而并非新的Path数据。

参数解析: 第一个参数 path: 被关联的 Path,也就是需要测量的Path; 第二个参数 forceClosed: 是否要闭合Path。 设置为true:则不论Path是否闭合,都会自动闭合该 Path(如果Path可以闭合的话),然后进行测量; 设置为false:则Path保持原来的样子,进行测量;

值得注意的两个小点:(此处和构造方法PathMeasure(Path path, boolean forceClosed)的描述是一样)
1、不论 forceClosed 设置为何种状态(true 或者 false),都不会影响原有Path的状态,即 Path 与 PathMeasure 关联之后,之前的Path 不会有任何改变
2、forceClosed 的设置状态可能会影响测量结果,如果 Path 未闭合但在与 PathMeasure 关联的时候设置 forceClosed 为 true 时,测量结果可能会比 Path 实际长度稍长一点,具体可看PathMeasure(Path path, boolean forceClosed)方法讲解中的例子。

(2)getLength

public float getLength()

方法描述: 返回当前关联路径轮廓的总长度,或者如果没有路径,则返回0。

(3)isClosed

public boolean isClosed()

方法描述: 测量的路径是否闭合。ture为闭合,false为不闭合。

值得注意 这里的闭合取决于两点: 1、Path 本来就是闭合的,则isClosed返回的就是true。 2、如果 Path 不是闭合的,但在与PathMeasure关联时(通过构造方法关联或是通过setPath关联),将forceClosed设置为true。此时,isClosed返回true。

(4)nextContour

public boolean nextContour()

方法描述: 获取在路径中下一个轮廓,如果有下一个轮廓,则返回true,且PathMeasure切至下一个轮廓的数据;如果没有下一个轮廓则返回false。至于怎么才算一个轮廓,且看下面例子:

举个栗子🌰 这段代码主要是画了三次,即moveTo了三次,所以即使在图中看起来是两个正方形,但在PathMeasure中可以得出三段轮廓。每次调用nextContour,都按我们画的顺序给我们切换,直至最后一个轮廓在调用nextContour时返回false,则中断循环。

Path mNextContourPath;
PathMeasure nextContourPathMeasure;
int width;
int height;
boolean isInit = false;
Paint mPaint;

@Override
protected void init(Context context) {
    mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mPaint.setColor(ContextCompat.getColor(context, R.color.color_purple));
    mPaint.setStyle(Paint.Style.STROKE);

    mNextContourPath = new Path();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    if (!isInit) {
        isInit = true;

        width = getMeasuredWidth() / 2;
        height = getMeasuredHeight() / 2;
    
    	 // 第一个轮廓
        mNextContourPath.moveTo(-100, -100);
        mNextContourPath.lineTo(-100, 100);
        mNextContourPath.lineTo(100, 100);
        mNextContourPath.lineTo(100, -100);
        mNextContourPath.lineTo(-100, -100);

		 // 第二个轮廓
        mNextContourPath.moveTo(-50, -50);
        mNextContourPath.lineTo(-50, 50);
        mNextContourPath.lineTo(50, 50);
        mNextContourPath.lineTo(50, -50);

		 // 第三个轮廓
        mNextContourPath.moveTo(50, -50);
        mNextContourPath.lineTo(-50, -50);

        nextContourPathMeasure = new PathMeasure(mNextContourPath, false);

        int i = 0;
        while (nextContourPathMeasure.nextContour()) {
            ++i;
            Log.i(TAG, "第" + i + "个轮廓的 Length:" + nextContourPathMeasure.getLength());
        }
    }
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(width, height);
    canvas.drawPath(mNextContourPath, mPaint);
}

效果图: 日志输出:

(5)getMatrix

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

方法描述: 用于获取关联的Path上距离起始点长度( 即传入的distance,范围0<=distance<=getLength() )的点的坐标和正切值(两者可选,由flags决定)。

返回值: 1、为true时,说明获取成功,数据存进matrix; 2、为false时,说明获取失败,matrix不变动;

参数解析: 第一个参数 distance: 即需要的测量点与当前path起始位置的距离,取值范围:0<=distance<=getLength() ; 第二个参数 matrix: 测量点的矩阵,可以选择包含点的坐标和正切值,所包含的数据由flags决定; 第三个参数 flags: 决定matrix中包含的数据,可以选择的值有:POSITION_MATRIX_FLAG(位置)ANGENT_MATRIX_FLAG(正切) 如果需要两个值时,可以用或“|”将其拼凑后传入,例如:

pathMeasure.getMatrix(distance, matrix, PathMeasure.TANGENT_MATRIX_FLAG | PathMeasure.POSITION_MATRIX_FLAG);

知识点拓展:如果对 POSITION_MATRIX_FLAG|ANGENT_MATRIX_FLAG 这种传值不太理解的童鞋可以查看我写的另外一篇文章《android位运算简单讲解》

(6)getSegment

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

方法描述: 获取关联的path的片段路径,添加至dst路径中(并非替换,是增加)

返回值: 1、为true时,说明截取成功,添加至dst路径中; 2、为false时,说明截取失败,dst路径不变动;

参数解析: 第一个参数 startD: 截取的路径的起始点距离path起始点的长度,取值范围:0<=startD<stopD<=Path.getLength(); 第二个参数 stopD: 截取的路径的终止点距离path起始点的长度,取值范围:0<=startD<stopD<=Path.getLength(); 第三个参数 dst: 截取的路径保存的地方,此处特别注意截取的路径是添加到dst中,而非替换第四个参数 startWithMoveTo: 截取的片段的第一个点是否保持不变; 设置为true:保持截取的片段不变,添加至dst路径中; 设置为false:会将截取的片段的起始点移至dst路径中的最后一个点,让dst路径保持连续

值得一提 如果你在4.4或更早的版本使用在使用这个函数时,需要先调用一下 mDst.lineTo(0, 0); 这句代码,这是因为硬件加速导致的问题;如不调用,会导致没有任何效果。

举个例子🌰 我们以屏幕中心为原点,先画一条从 (0,0) 到 (200,200) 的直线,然后从一个顺时针画的圆中截取 0.25 到 0.5 距离的圆弧放置dst中,先将startWithMoveTo设置为true,具体代码如下:

mGetSegmentPathMeasure = new PathMeasure();
// 顺时针画 半径为400px的圆
mPath.addCircle(0, 0, 400, Path.Direction.CW);
mGetSegmentPathMeasure.setPath(mPath, false);

// 画直线
mDst.moveTo(0, 0);
mDst.lineTo(200, 200);

// 截取 0.25 到 0.5 距离的圆弧放置dst中
mGetSegmentPathMeasure.getSegment(mGetSegmentPathMeasure.getLength() * 0.25f,
         mGetSegmentPathMeasure.getLength() * 0.5f,
         mDst,
         true);

canvas.drawPath(mDst, mPaint);

代码只是截取主要部门,需要查看完整代码的童鞋,请入传送门

效果图

如果将 startWithMoveTo 参数值改为 false,则效果不同,代码如下:

mGetSegmentPathMeasure = new PathMeasure();
// 顺时针画 半径为400px的圆
mPath.addCircle(0, 0, 400, Path.Direction.CW);
mGetSegmentPathMeasure.setPath(mPath, false);

// 画直线
mDst.moveTo(0, 0);
mDst.lineTo(200, 200);

// 截取 0.25 到 0.5 距离的圆弧放置dst中
mGetSegmentPathMeasure.getSegment(mGetSegmentPathMeasure.getLength() * 0.25f,
         mGetSegmentPathMeasure.getLength() * 0.5f,
         mDst,
         false);

canvas.drawPath(mDst, mPaint);

效果图

从两个效果图,可看出startWithMoveTo参数设置为true和false,会导致dst路径的不同。为true时,保持 截取的片段路径 的原样将其添加至 dst路径 中;为false时,会将截取的片段的起始点移至dst路径中的最后一个点,让dst路径保持连续。

值得注意 在写这篇博客时,将startWithMoveTo参数设置为false,在两台测试机(Mate10 Android 8.1.0和oppo A57 Android 6.0.1)上运行,效果有些许不同。 Demo使用的是px作为单位,两台手机的分辨率不同,所以在 A57 机型上按比例缩小了一倍进行绘制 (即圆半径从400px变为200px,斜线从(0,0)->(200,200)变为(0,0)->(100,100) ),从下面👇的OPPO A57的效果图可以很明显的看出,圆弧的路径已经受到dst中最后一个点的影响,改变了形状。(Mate10的效果图请翻阅上面👆)

OPPO A57的效果图

(6)getPosTan

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

方法描述: 获取关联的Path距离起始点长度(distance)的点坐标(pos)余弦(tan[0],即cos)正弦(tan[1],即sin)

返回值 1、为true时,说明获取成功,该点的 坐标 以及 正余弦 将各自存进pos和tan参数 2、为false时,说明获取失败,pos与tan没有变动

参数解析: 第一个参数 distance: 即需要的测量点与当前path起始位置的距离,取值范围:0<=distance<=getLength() ; 第二个参数 pos: 测量点的坐标,pos[0]为x坐标,pos[1]为y坐标; 第三个参数 tan: 测量点的正余弦值,tan[0]为cos,即余弦值或称为单位圆的x坐标;tan[1]为sin,即正弦值或称为单位圆的y坐标

数学小课堂: 单位圆指的是平面直角坐标系上,圆心为原点,半径为1的圆。 cos = 邻边/斜边 = OB/OA = OB(因为OA长度为1)= x sin = 对边/斜边 = AB/OA = AB (因为OA长度为1) = y 在这里插入图片描述

三、实战

转圈的箭头

按照国际惯例,先上效果图

动画解析 让箭头绕着红色圆转圈,同时需要改变箭头的方向,使其朝向当前位置的切线方向

实现思路与代码解析 先进行初始化对象,主要是初始化画笔、图片、路径、PathMeasure、装载变量、估值器,具体为每个对象设置的属性请看下面代码,此处比较简单,就不再赘述

// 初始化 画笔 [抗锯齿、不填充、红色、线条2px]
mCirclePaint = new Paint();
mCirclePaint.setAntiAlias(true);
mCirclePaint.setStyle(Paint.Style.STROKE);
mCirclePaint.setColor(Color.RED);
mCirclePaint.setStrokeWidth(2);

// 获取图片
mArrowBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.arrow, null);

// 初始化 圆路径 [圆心(0,0)、半径200px、顺时针画]
mCirclePath = new Path();
mCirclePath.addCircle(0, 0, 200, Path.Direction.CW);

// 初始化 装载 坐标 和 正余弦 的数组
mPos = new float[2];
mTan = new float[2];

// 初始化 PathMeasure 并且关联 圆路径
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mCirclePath, false);

// 初始化矩阵
mMatrix = new Matrix();

// 初始化 估值器 [区间0-1、时长5秒、线性增长、无限次循环]
valueAnimator = ValueAnimator.ofFloat(0, 1f);
valueAnimator.setDuration(5000);
// 匀速增长
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        // 第一种做法:通过自己控制,是箭头在原来的位置继续运行
        mCurrentValue += DELAY;
        if (mCurrentValue >= 1) {
            mCurrentValue -= 1;
        }

        // 第二种做法:直接获取可以通过估值器,改变其变动规律
		//mCurrentValue = (float) animation.getAnimatedValue();

        invalidate();
    }
});

初始化工作完成后,接下来就是进行绘制工作,我们按照步骤来讲解: 第一步,将屏幕的中心点作为原点,方便操作和绘制

// 移至canvas中间
canvas.translate(mWidth / 2, mHeight / 2);

第二步,绘制圆,即箭头走的轨迹,PathMeasure所关联的Path就是此处的mCirclePath,在上面的初始化代码可以清晰的看到

// 画圆路径
canvas.drawPath(mCirclePath, mCirclePaint);

第三步,获取当前点的坐标以及正余弦的值,存放至mPos和mTan变量中

// 测量 pos(坐标) 和 tan(正切)
mPathMeasure.getPosTan(mPathMeasure.getLength() * mCurrentValue, mPos, mTan);

第四步,通过反正弦atan2计算出角度(单位为弧度),所以需要进行将单位在转为度。

// 计算角度
float degree = (float) (Math.atan2(mTan[1], mTan[0]) * 180 / Math.PI);

数学小课堂 我们来拆分下这个公式,先看Math.atan2(mTan[1], mTan[0]) 这段,这里关系到的是直角坐标系与极坐标系的转换,所以我们先来重拾下忘记的第一个知识点 (1)直角坐标系与极坐标系的转换(图片是自己手写的,字迹粗糙勿喷😄) 从图中可以知道 θ 的计算是通过该点的x和y坐标得出,并且还要根据 y/x 计算结果的符号和该点存在的象限来共同决定。而通过getPosTan方法获得的tan[]中的值便可以看作该点的x、y坐标值(具体原因可以查看前面getPosTan方法中的数学小课堂)。

所以只需对(y/x)进行求反正切便可,但这里有存在一个问题,也就是我们刚刚提到的 θ 由 y/x 计算结果的符号和该点存在的象限决定,如果使用 Math.atan(double a) 方法进行求反正切,其结果范围为开区间的 (-pi/2,pi/2),然而一个圆的的范围是(-pi,pi),这显然直接使用是不能满足的。幸好Math类提供了一个让我们省事的API atan2(double y, double x),其返回值的范围正是 (-pi,pi)

到这里已经能通过atan2函数得到该点的角度,但是其单位是弧度,并不能在直角坐标系中直接拿来使用,需要进行转换。所以我们需要引出第二个被遗忘的知识点

(2)弧度制 弧度制是什么这里就不做过多解释。这里涉及到一个公式就是 1° = π/180 rad ,看到这里大家应该就明白为什么要 乘以 180 / Math.PI,因为求出的反正切的值单位为弧度,需要转为我们通常使用的角度制中的度。

第五步,重置矩阵,避免矩阵内有之前遗留的操作。

// 重置矩
mMatrix.reset();

第六步,根据第四步计算得出的角度并且以图片的中心点进行旋转

// 设置旋转角度
mMatrix.postRotate(degree, mArrowBitmap.getWidth() / 2, mArrowBitmap.getHeight() / 2);

第七部,进行偏移,因为直接绘制的话,箭头会在轨道之外,需要挪动箭头的宽和高各一半

// 设置偏移量
mMatrix.postTranslate(mPos[0] - mArrowBitmap.getWidth() / 2,
        mPos[1] - mArrowBitmap.getHeight() / 2);

第八步,使用矩阵将箭头绘制至画布中

// 画箭头,使用矩阵旋转
canvas.drawBitmap(mArrowBitmap, mMatrix, mCirclePaint);

至此,效果已完成。

需要查看完整代码的童鞋,请进传送门

四、更多案例

1、拖动的loading线条

效果图

代码传送门 完整代码请进

2、乘风破浪的小船

效果图

代码传送门 完整代码请进

五、写在最后

PathMeasure可以说是自定义UI的利器之一,熟练的掌握能让我们斩获更多的产品😈。如果各位童鞋在阅读中发现有错误或是晦涩难懂的地方请与我联系,我会及时修改,让我们共同进步。同样如果你喜欢的话,请给个赞并关注我吧😄。

高级UI系列的Github地址:请进入传送门,如果喜欢的话给我一个star吧😄