前面几篇文章已经按照顺序讲解了Paint画笔、Canvas画布、Path相关内容了,也许没有面面俱到,但特地强调了其重点内容。有关Path的内容只讲解了贝塞尔曲线绘制,日后再做补充。此篇文章将介绍另外一个重点内容:PathMeasure。
PathMeasure类明显是用来辅助Path类的,其API方法很少,但是有两个王牌,即截取片段getSegment
方法和获取指定长度的位置坐标及该点切线值tanglegetPosTan
方法。前者容易了解,截取部分曲线或图形片段处理,而后者的获取指定点切线值,这个充满数学魅力的API,
(此系列文章知识点相对独立,可分开阅读,不过笔者建议按照顺序阅读,理解更加深入清晰)
Android 高级UI解密 (四) :花式玩转贝塞尔曲线(波浪、轨迹变换动画
Android 高级UI解密 (三) :Canvas裁剪 与 二维、三维Camera几何变换(图层Layer原理)
Android 高级UI解密 (二) :Paint滤镜 与 颜色过滤(矩阵变换)
Android 高级UI解密 (一) :Paint图形文字绘制 与 高级渲染
此篇涉及到的知识点如下:
顾名思义,PathMeasure是一个用来测量Path的类,它的方法比较少,以下先来介绍API基本使用。
方法名 | 释义 |
---|---|
PathMeasure() | 创建一个空的PathMeasure |
PathMeasure(Path path, boolean forceClosed) | 创建 PathMeasure 并关联一个指定的Path(Path需要已经创建完成)。 |
(1)无参构造函数
PathMeasure()
用这个构造函数可创建一个空的 PathMeasure,但是使用之前需要先调用 setPath
方法来与 Path 进行关联。被关联的 Path 必须是已经创建好的。如果关联之后 Path 内容进行了更改,则需要使用 setPath
方法重新关联。
(2)有参构造函数
PathMeasure (Path path, boolean forceClosed)
用这个构造函数是创建一个 PathMeasure 并关联一个 Path, 其实和创建一个空的 PathMeasure 后调用 setPath
进行关联效果是一样的。同样,被关联的 Path 也必须是已经创建好的。如果关联之后 Path 内容进行了更改,则需要使用 setPath 方法重新关联。
注意forceClosed 参数:
返回值 | 方法名 | 释义 |
---|---|---|
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) | 获取指定长度的位置坐标及该点切线值tangle |
boolean | getMatrix(float distance, Matrix matrix, int flags) | 设置距离为0 <= distance <= getLength(),然后计算相应的矩阵 |
(1)setPath方法
void setPath(Path path, boolean forceClosed)
作用:此方法是 PathMeasure 与 Path 关联的重要方法,效果和构造函数中两个参数的作用是一样的。
(2)isClosed方法
boolean isClosed()
作用:此方法用于判断 Path 是否闭合,但是如果你在关联 Path 的时候设置 forceClosed 为 true 的话,这个方法的返回值则一定为true。
(3)getLength方法
float getLength()
作用:此方法用于获取 Path 路径的总长度。
(4)nextContour方法
boolean nextContour()
作用: Path 可以由多条曲线构成,但不论是 getLength
方法, 还是getgetSegment
或者其它方法,都只会在其中第一条线段上运行。此 nextContour
方法 就是用于跳转到下一条曲线到方法。如果跳转成功,则返回 true, 如果跳转失败,则返回 false。
(5)getSegment方法
boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)
0 <= startD < stopD <= Path总长度
);0 <= startD < stopD <= Path总长度
);作用:用于获取Path路径的一个片段。(如果 startD、stopD 的数值不在取值范围 [0, getLength]
内,或者 startD == stopD
则返回值为 false,不会改变 dst 内容)。
注意:如果在安卓4.4或者之前的版本,在默认开启硬件加速的情况下,更改 dst 的内容后可能绘制会出现问题,请关闭硬件加速或者给 dst 添加一个单个操作,例如: dst.rLineTo(0, 0)
。
(6)getPosTan方法
boolean getPosTan(float distance, float[] pos, float[] tan)
0 <= distance <= getLength
;(x==[0], y==[1])
;(x==[0], y==[1])
;作用:用于获取路径上某点的坐标以及该位置的正切值,即切线的坐标。相当于是getPos
、getTan
两个API的集合。
//用于获取路径上某点的切线角度
(math.atan2(tan[1], tan[0])*180.0 / math.PI)
上面代码是常用的一个公式,用于获取路径上某点的切线角度。通过 tan 得值计算出图片旋转的角度,tan 是 tangent 的缩写,即中学中常见的正切, 其中tan0是邻边边长,tan1是对边边长,而Math中 atan2
方法是根据正切是数值计算出该角度的大小,得到的单位是弧度,所以上面又将弧度转为了角度。
(7)getMatrix方法
boolean getMatrix(float distance, Matrix matrix, int flags)
0 <= distance <= getLength
);作用:用于得到路径上某一长度的位置以及该位置的正切值的矩阵。
setStyle
、setStrokeWidth
方法初始化画笔基本属性。setPath
方法关联Path,并调用getLength
获取路径长度,创建Dst对象,后续会使用。ofFloat(0, 1)
方法,此处的(0, 1)
范围代表百分比例,即绘制圆的比例从0到100%。再设置线性插值器和循环播放,重点在于实现动画的监听事件中获取变化的比例值赋值给成员变量,调用invalidate();
刷新。onDraw
方法中进行绘制,绘制圆的起点当然是0,终点则是随着动画渐变成圆,为mLength * mAnimValue;
,即圆比例值*绘制路径总长度。有了这两个float值后,可使用PathMeasure的getSegment(start, stop, mDst, true)
方法获取到对应路径,接下来再调用熟悉的canvas 绘制drawPath(mDst, mPaint)
即可。注意,在onDraw
方法中一开始除了需要重置mDst外,还需要调用Dst.lineTo(0, 0)
方法,这是Android硬件加速的一个小bug,若不调用则getSegment(start, stop, mDst, true)
方法可能不起作用。
public class PathTracingView extends View {
private Path mDst;
private Path mPath;
private Paint mPaint;
private float mLength;
private float mAnimValue;
private PathMeasure mPathMeasure;
......
public PathTracingView(Context context, AttributeSet attrs) {
super(context, attrs);
//设置Paint画笔基本属性
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPath = new Path();
mDst = new Path();
mPath.addCircle(400, 400, 100, Path.Direction.CW);
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mPath, true);
mLength = mPathMeasure.getLength();
ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
animator.setDuration(1000);
animator.setInterpolator(new LinearInterpolator());
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mAnimValue = (float) valueAnimator.getAnimatedValue();
invalidate();
}
});
animator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mDst.reset();
mDst.lineTo(0, 0);
float stop = mLength * mAnimValue;
float start = 0;
//float start = (float) (stop - ((0.5 - Math.abs(mAnimValue - 0.5)) * mLength));
mPathMeasure.getSegment(0, stop, mDst, true);
//mPathMeasure.getSegment(start, stop, mDst, true);
canvas.drawPath(mDst, mPaint);
}
}
此部分的实现重点在于对PathMeasure的运用,首先获取动画实时变化的圆比例,调用getSegment
方法获取圆的指定路径,canvas将其绘制出来。效果如下:
在见识到PathMeasure的精彩之处后,发现上面这个Loading绘制太普通了,怎么着也要来点特效~只需要改变两行代码就可以实现Windows的开机Loading效果图。
效果如上,比起第一个要酷炫不少吧~只需要将onDraw
方法中将float start = 0;
改成
//修改成Windows的Loading效果
float start = (float) (stop - ((0.5 - Math.abs(mAnimValue - 0.5)) * mLength));
mPathMeasure.getSegment(start, stop, mDst, true);
可以发现stop的值没有修改,仍旧是从[0, 圆周长长度]
之间的变化,可是start值看似有些复杂,决定于stop、mAnimValue的值。先来分析动画效果,可把它分成上半圆、下半圆效果来看。这意味着:
因此可见各种绚丽的动画效果,对坐标进行简单的数学计算就可以实现。
关于轨迹动画的实现,通常是使用VectorDrawable或者Path来实现,但一位Android大神Romain Guy提出了一种新的实现思路:Path Tracing Trick,此小节结合新的思路来实现轨迹动画效果。
如上图所示这几种不同的线条效果,通过设置画笔Paint属性即可完成。重点查看第三种Dash风格,实质是由实线、虚线组合而成,在代码设置Dash风格时需要传入两个参数:实线长度和虚线长度。
那么举一反三,如果要实现一个布景的绘制动画,通过设置画笔Paint的Dash风格,将实线和虚线的长度都设置为布景的长度,那么布景初始时的显示是一条实线或一条虚线,通过最后一个参数偏移量的设置,令全部都是虚线(即空白)的图形不断的被虚线所填充,从而可以实现轨迹动画的效果。
Romain Guy提出的如上思路的确令人耳目一新,以Paint画笔特有的Dash实、虚线风格(即DashPathEffect),再借助动画的偏移量位移,从而可以实现轨迹偏移的动画效果,接下来学习实现这个抽象的思路。
上图中代码演示是Romain Guy博客中截取的内容,可见:
getLength
方法获取Path路径的全长度length;完整代码如下,配上注释并不难理解:
public class PathPaintView extends View {
private Path mPath;
private Paint mPaint;
private float mLength;
private float mAnimValue;
private PathEffect mEffect;
private PathMeasure mPathMeasure;
public PathPaintView(Context context) {
super(context);
}
public PathPaintView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPath = new Path();
//绘制三角形
mPath.moveTo(100, 100);
mPath.lineTo(100, 500);
mPath.lineTo(400, 300);
mPath.close();
//设置PathMeasure
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mPath, true);
//获取轨迹路径全长度
mLength = mPathMeasure.getLength();
//设置动画,线性插值器数值从百分比[0,1]变化
ValueAnimator animator = ValueAnimator.ofFloat(1, 0);
animator.setDuration(2000);
animator.setInterpolator(new LinearInterpolator());
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
//获取动画偏移量
mAnimValue = (float) valueAnimator.getAnimatedValue();
//创建Paint画笔的DashPathEffect效果,三个参数分别为:实线、虚线长度、起始偏移量(通过变化的百分比乘以路径长度)
mEffect = new DashPathEffect(new float[]{mLength, mLength}, mLength * mAnimValue);
mPaint.setPathEffect(mEffect);
//刷新UI
invalidate();
}
});
animator.start();
}
public PathPaintView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
}
}
绘制出的路径效果如下,可见这就是实线在不断替代虚线的过程,即虚线到实线的一个变化效果,这也就对应了以上代码中对动画值的变化设置是[1,0]
,如果设置成[0,1]
,则是实线到虚线的变化效果。由此可见,借助Paint的Dash实虚线变化效果,再结合 PathMeasure的辅助方法获取路径长度计算偏移量,即可以新的思路完成路径轨迹的效果动画。
在介绍PathMeasure的基本方法中介绍过了getPosTan
重点方法,通过一个简单的切线绘制demo来深入了解学习。
这里先给出效果,如上,以绘制的圆形作为辅助更容易理解切线的概念,将以上效果实现分成两个部分:小圆圈沿着圆的轨迹移动,切线沿着圆的轨迹移动,这些实现都要依赖getPosTan
方法。首先来看第一个效果实现步骤:
getPosTan
方法中需要的Pos、T an数组,留以后用;onDraw
方法中调用PathMeasure的getPosTan
方法,注意回顾此方法要求的三个参数信息,分别是距离 Path 起点的长度(取值范围[0, getLength]
)、坐标值数组、切点数组,因此此处我们传入的参数分别是:动画偏移量百分比*length、两个新创建的数组。调用此方法后,后序绘制时可以利用Pos数组,即沿着圆轨迹移动的坐标值来绘制移动的小圆圈!drawPath
绘制出大圆,接着调用drawCircle
绘制沿着圆轨迹移动的小圆圈,而此方法传入的圆心坐标就是Pos数组!绘制效果如上,接下来就是重头戏,绘制移动小圆圈相对于大圆的切线,此处需要用到讲解该API时的公式:
//用于获取路径上某点的切线角度
(math.atan2(tan[1], tan[0])*180.0 / math.PI)
通过以上公式可以获取到沿着圆轨迹移动的小圆圈的切线角度,有此角度后便可绘制不断变化的切线,此处有个小技巧,不需要多次重复绘制变化的切线,既然已经知晓变化的角度,直接调用canvas的rotate
方法变化圆的形状即可,因为圆即使改变了角度也无任何变化,而其切线则会产生变化。
完整代码如下:
public class PathPosTanView extends View implements View.OnClickListener{
private Path mPath;
private float[] mPos;
private float[] mTan;
private Paint mPaint;
private PathMeasure mPathMeasure;
private ValueAnimator mAnimator;
private float mCurrentValue;
public PathPosTanView(Context context) {
super(context);
}
public PathPosTanView(Context context, AttributeSet attrs) {
super(context, attrs);
mPath = new Path();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPath.addCircle(0, 0, 200, Path.Direction.CW);
mPathMeasure = new PathMeasure();
mPathMeasure.setPath(mPath, false);
mPos = new float[2];
mTan = new float[2];
setOnClickListener(this);
mAnimator = ValueAnimator.ofFloat(0, 1);
mAnimator.setDuration(3000);
mAnimator.setInterpolator(new LinearInterpolator());
mAnimator.setRepeatCount(ValueAnimator.INFINITE);
mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mCurrentValue = (float) valueAnimator.getAnimatedValue();
invalidate();
}
});
}
public PathPosTanView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPathMeasure.getPosTan(mCurrentValue * mPathMeasure.getLength(), mPos, mTan);
float degree = (float) (Math.atan2(mTan[1], mTan[0]) * 180 / Math.PI);
canvas.save();
canvas.translate(400, 400);
canvas.drawPath(mPath, mPaint);
canvas.drawCircle(mPos[0], mPos[1], 10, mPaint);
canvas.rotate(degree);
//相对坐标
canvas.drawLine(0, -200, 300, -200, mPaint);
canvas.restore();
}
@Override
public void onClick(View view) {
mAnimator.start();
}
}
最后留一个常见的自定义View供读者自己奇思妙想去实现,除了用VectorDrawable实现,阅读过此篇文章可以轻松使用PathMeasure实现哟~
(此自定义控件本不打算贴源码,留给读者自行实现,但思量过后还是贴上,实现的具体步骤暂不分析,建议读者思索尝试过后再看源码)
public class SearchView extends View {
// 画笔
private Paint mPaint;
// View 宽高
private int mViewWidth;
private int mViewHeight;
// 这个视图拥有的状态
public static enum State {
NONE,
STARTING,
SEARCHING,
ENDING
}
// 当前的状态(非常重要)
private State mCurrentState = State.NONE;
// 放大镜与外部圆环
private Path path_srarch;
private Path path_circle;
// 测量Path 并截取部分的工具
private PathMeasure mMeasure;
// 默认的动效周期 2s
private int defaultDuration = 2000;
// 控制各个过程的动画
private ValueAnimator mStartingAnimator;
private ValueAnimator mSearchingAnimator;
private ValueAnimator mEndingAnimator;
// 动画数值(用于控制动画状态,因为同一时间内只允许有一种状态出现,具体数值处理取决于当前状态)
private float mAnimatorValue = 0;
// 动效过程监听器
private ValueAnimator.AnimatorUpdateListener mUpdateListener;
private Animator.AnimatorListener mAnimatorListener;
// 用于控制动画状态转换
private Handler mAnimatorHandler;
// 判断是否已经搜索结束
private boolean isOver = false;
private int count = 0;
public SearchView(Context context) {
super(context);
initPaint();
initPath();
initListener();
initHandler();
initAnimator();
// 进入开始动画
mCurrentState = State.STARTING;
mStartingAnimator.start();
}
private void initPaint() {
mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(Color.WHITE);
mPaint.setStrokeWidth(15);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setAntiAlias(true);
}
private void initPath() {
path_srarch = new Path();
path_circle = new Path();
mMeasure = new PathMeasure();
// 注意,不要到360度,否则内部会自动优化,测量不能取到需要的数值
RectF oval1 = new RectF(-50, -50, 50, 50); // 放大镜圆环
path_srarch.addArc(oval1, 45, 359.9f);
RectF oval2 = new RectF(-100, -100, 100, 100); // 外部圆环
path_circle.addArc(oval2, 45, -359.9f);
float[] pos = new float[2];
mMeasure.setPath(path_circle, false); // 放大镜把手的位置
mMeasure.getPosTan(0, pos, null);
path_srarch.lineTo(pos[0], pos[1]); // 放大镜把手
Log.i("TAG", "pos=" + pos[0] + ":" + pos[1]);
}
private void initListener() {
mUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mAnimatorValue = (float) animation.getAnimatedValue();
invalidate();
}
};
mAnimatorListener = new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {}
@Override
public void onAnimationEnd(Animator animation) {
// getHandle发消息通知动画状态更新
mAnimatorHandler.sendEmptyMessage(0);
}
@Override
public void onAnimationCancel(Animator animation) {}
@Override
public void onAnimationRepeat(Animator animation) {}
};
}
private void initHandler() {
mAnimatorHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
switch (mCurrentState) {
case STARTING:
// 从开始动画转换好搜索动画
isOver = false;
mCurrentState = State.SEARCHING;
mStartingAnimator.removeAllListeners();
mSearchingAnimator.start();
break;
case SEARCHING:
if (!isOver) { // 如果搜索未结束 则继续执行搜索动画
mSearchingAnimator.start();
Log.e("Update", "RESTART");
count++;
if (count>2){ // count大于2则进入结束状态
isOver = true;
}
} else { // 如果搜索已经结束 则进入结束动画
mCurrentState = State.ENDING;
mEndingAnimator.start();
}
break;
case ENDING:
// 从结束动画转变为无状态
mCurrentState = State.NONE;
break;
}
}
};
}
private void initAnimator() {
mStartingAnimator = ValueAnimator.ofFloat(0, 1).setDuration(defaultDuration);
mSearchingAnimator = ValueAnimator.ofFloat(0, 1).setDuration(defaultDuration);
mEndingAnimator = ValueAnimator.ofFloat(1, 0).setDuration(defaultDuration);
mStartingAnimator.addUpdateListener(mUpdateListener);
mSearchingAnimator.addUpdateListener(mUpdateListener);
mEndingAnimator.addUpdateListener(mUpdateListener);
mStartingAnimator.addListener(mAnimatorListener);
mSearchingAnimator.addListener(mAnimatorListener);
mEndingAnimator.addListener(mAnimatorListener);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mViewWidth = w;
mViewHeight = h;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawSearch(canvas);
}
private void drawSearch(Canvas canvas) {
mPaint.setColor(Color.WHITE);
canvas.translate(mViewWidth / 2, mViewHeight / 2);
canvas.drawColor(Color.parseColor("#0082D7"));
switch (mCurrentState) {
case NONE:
canvas.drawPath(path_srarch, mPaint);
break;
case STARTING:
mMeasure.setPath(path_srarch, false);
Path dst = new Path();
mMeasure.getSegment(mMeasure.getLength() * mAnimatorValue, mMeasure.getLength(), dst, true);
canvas.drawPath(dst, mPaint);
break;
case SEARCHING:
mMeasure.setPath(path_circle, false);
Path dst2 = new Path();
float stop = mMeasure.getLength() * mAnimatorValue;
float start = (float) (stop - ((0.5 - Math.abs(mAnimatorValue - 0.5)) * 200f));
// float start = stop-50;
mMeasure.getSegment(start, stop, dst2, true);
canvas.drawPath(dst2, mPaint);
break;
case ENDING:
mMeasure.setPath(path_srarch, false);
Path dst3 = new Path();
mMeasure.getSegment(mMeasure.getLength() * mAnimatorValue, mMeasure.getLength(), dst3, true);
canvas.drawPath(dst3, mPaint);
break;
}
}
}
若有错误,虚心指教~