Android自定义绘制小猪佩奇
作者 | 呱呱_ 地址 | https://www.jianshu.com/p/5e2d1d3cec7e
刚好有看到用Python画佩奇的,所以就寻思着用Android也画了一个。
佩奇完工已有些时日,一直想写篇文章记录下,奈何拖到现在。限于水平有限,不对的地方,还望斧正。
直接点,咱们先来看一下效果,然后再去想怎么画出来。
简单分析下佩奇,会发现构图基本由曲线构成的,还有部分使用了圆、椭圆、矩形等常规图形。
常规图形我们使用Canvas绘制,曲线部分我们使用Path绘制贝塞尔曲线。
所以这篇文章分为三个模块:分别介绍Canvas的使用、Path基础、Path绘制贝塞尔曲线。
一、Canvas回顾
Canvas的使用相对基础一点,我们来一起通过API回顾下:
类别 | API | 描述 |
---|---|---|
绘制图形 | drawPoint, drawPoints, drawLine, drawLines, drawRect, drawRoundRect, drawOval, drawCircle, drawArc | 依次为绘制点、直线、矩形、圆角矩形、椭圆、圆、扇形 |
绘制文本 | drawText, drawPosText, drawTextOnPath | 依次为绘制文字、指定每个字符位置绘制文字、根据路径绘制文字 |
画布变换 | translate, scale, rotate, skew | 依次为平移、缩放、旋转、倾斜(错切) |
画布裁剪 | clipPath, clipRect, clipRegion | 依次为按路径、按矩形、按区域对画布进行裁剪 |
画布状态 | save,restore | 保存当前画布状态,恢复之前保存的画布 |
具体到每个API就不展开说明了,如有需要可以查看末尾的参考文章,都有很详细的介绍,这里我们画个鼻子做示例:
图片有些卡顿,流程还是容易知晓的:
绘制一个倾斜的椭圆,进度变化,闭环时上色
绘制两个小圆环,进度变化,闭环时上色
为了绘制方便,椭圆和小圆是同时绘制的
分析完成那就开撸吧,具体数值都是yy得来的,我们主要看一下流程步骤和api的使用:
private RectF rect;
private Paint paintPink;
private Paint paintRed;
public void init() {
// 初始化矩形,各个部位的父容器,如鼻子是在矩形内部画椭圆
rect = new RectF();
// 创建画笔
paintPink = new Paint();
// 设置画笔的颜色
paintPink.setColor(Color.rgb(255, 155, 192));
// 设置画笔的填充方式:描边
paintPink.setStyle(Paint.Style.STROKE);
// 设置画笔的宽度
paintPink.setStrokeWidth(3f);
// 设置抗锯齿,可以圆润一些
paintPink.setAntiAlias(true);
...
// 其他颜色画笔类似操作...
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 鼻子:倾斜的椭圆
rect.set(dp2px(200), dp2px(101), dp2px(250), dp2px(160));
// 旋转画布,结束还需旋转回去(在这里实现倾斜)
canvas.rotate(-15, dp2px(getContext(), 225), dp2px(getContext(), 150));
if (progressNose < 100) {
// 如果进度不完整,只进行描边操作
paintPink.setStyle(Paint.Style.STROKE);
paintRed.setStyle(Paint.Style.STROKE);
} else {
// 如果进度完整,即环形绘制完成,设置画笔为填充模式,设置填充及描边(FILL_AND_STROKE)也行
paintPink.setStyle(Paint.Style.FILL);
paintRed.setStyle(Paint.Style.FILL);
}
// 画扇形:如果角度为360度,就是矩形的内切椭圆,如果矩形为正方形,则椭圆为正圆
canvas.drawArc(rect, 0, progressNose * 3.6f, true, paintPink);
canvas.rotate(15, dp2px(getContext(), 225), dp2px(getContext(), 130));
// 鼻孔
// 重新设置矩形的参数为正方形
rect.set(dp2px(213), dp2px(125), dp2px(223), dp2px(135));
// 根据进度画圆形鼻孔
canvas.drawArc(rect, 0, progressNose * 3.6f, false, paintRed);
rect.set(dp2px(230), dp2px(122), dp2px(240), dp2px(132));
canvas.drawArc(rect, 0, progressNose * 3.6f, false, paintRed);
}
细节注释都有说明,至于绘制进度百分比,我们这里使用的 ValueAnimator类。
private int progressNose = 0;
private ValueAnimator animNose;
private void initIntAnim() {
// 设置动画的起始值,也就是我们需要的进度变化区间
animNose = ValueAnimator.ofint(0, 100);
animNose.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 监听动画进度变化,并执行重绘操作
progressNose = (int) animation.getAnimatedValue();
invalidate();
}
});
// 设置动画时长
animNose.setDuration(3000);
}
当只要执行这一个动画的时候,直接调用 animNose.start() 就可以了。
二、Path初识
The Path class encapsulates compound (multiple contour) geometric paths consisting of straight line segments, quadratic curves, and cubic curves. It can be drawn with canvas.drawPath(path, paint), either filled or stroked (based on the paint's Style), or it can be used for clipping or to draw text on a path.
简单说就是:Path可以通过直线、二次、三次贝塞尔曲线,以及填充描边模式,做出各种炫酷效果。
我们还是来一起看一下API:
Path 点、线操作 | 描述 |
---|---|
lineTo、rLineTo | 绘制线(lineTo的坐标点是相对于原点的,rLineTo的坐标点是相对于上个坐标的偏移量。) |
moveTo、rMoveTo | 设置下一次操作的起点位置 |
setLastPoint | 改变上一次操作结束点的位置 |
close | 闭合Path(如果连接Path起点和终点能形成一个闭合图形,则会将起点和终点连接起来形成一个闭合图形。) |
Path 常规图像 | 描述 |
---|---|
addRect | 绘制矩形 |
addRoundRect | 绘制圆角矩形 |
addCircle | 绘制圆形 |
addOval | 绘制椭圆 |
Path 设置方法 | 描述 |
---|---|
set | 将新的path赋值到已有的path |
reset | 将path的所有操作都清空 |
offset | 将path进行平移 |
Path 其他属性 | 描述 |
---|---|
isConvex | 判断path是否为凸多边形(API >= 21) |
isEmpty | 判断path中是否包含内容 |
isRect | 判断path是否是矩形 |
这里依旧没有展开来说,看着比较干瘪无趣,但应该会对Path的能力有所了解。
三、Path和贝塞尔曲线
3.1 我们先来假装了解一下贝塞尔曲线,以下内容摘抄自维基百科:
在数学的数值分析领域中,贝塞尔曲线(英语:Bézier curve,亦作“贝塞尔”)是计算机图形学中相当重要的参数曲线。更高维度的广泛化贝塞尔曲线就称作贝兹曲面,其中贝兹三角是一种特殊的实例。
贝塞尔曲线于1962年,由法国工程师皮埃尔·贝兹(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由保尔·德·卡斯特里奥于1959年运用德卡斯特里奥算法开发,以稳定数值的方法求出贝塞尔曲线。
一阶贝塞尔曲线(线性曲线)
对于一阶贝赛尔曲线,我们可以理解为在起点和终点形成的这条直线上,匀速移动的点。
二阶贝塞尔曲线
为建构二次贝塞尔曲线,可以中介点Q0和Q1作为由0至1的t:
由P0至P1的连续点Q0,描述一条线性贝塞尔曲线。
由P1至P2的连续点Q1,描述一条线性贝塞尔曲线。
由Q0至Q1的连续点B(t),描述一条二次贝塞尔曲线。
也可以说是:P0Q0 : P0P1 = P1Q1 : P1P2 = Q0B : Q0Q1 = t
三阶贝塞尔曲线
对于三次曲线,可由线性贝塞尔曲线描述的中介点Q0、Q1、Q2,和由二次曲线描述的点R0、R1所建构。
高阶贝塞尔曲线
建构更高阶曲线,只是需要更多的中介点,通用公式如下:
3.2 Path绘制贝塞尔曲线
Android是支持贝塞尔曲线的,但是只支持到三阶,我们来一起了解一下:
类别 | API | 描述 |
---|---|---|
一阶 | lineTo、rLineTo | 就是绘制线 |
二阶 | quadTo、rQuadTo | quadTo(x1, y1, x2, y2) (x1,y1) 为控制点,(x2,y2)为结束点 |
三阶 | cubicTo、rCubicTo | cubicTo(x1, y1, x2, y2, x3, y3) (x1,y1) 为控制点,(x2,y2)为控制点,(x3,y3) 为结束点;即与二阶区别多一个控制点 |
这里我们来画类似于口哨这么个玩意:
这里我们是分为两部分进行绘制的,第一部分到拐点那里,是个三阶曲线;第二部分是个二阶曲线。第一部分的终点就是第二部分的起点。
起点、终点、拐点坐标都相对好确定,可是这些控制点的坐标怎么确定呐?
实际项目中我们可以请设计帮忙,通过ps给个大致估算,这里只是跟着感觉试出来的,所以也就是个大致轮廓,不要介意。
代码比较简单,就是坐标点的信息有点多:
private Path mPath = new Path();
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 设置起始点
mPath.moveTo(dp2px(220),dp2px(102));
// 三阶:头部轮廓(画到鼻子和嘴的连接处)
mPath.cubicTo(dp2px(-100), dp2px(80), dp2px(130), dp2px(330), dp2px(170), dp2px(170));
// 二阶:画鼻子的下面的那条线
mPath.quadTo(dp2px(210), dp2px(170), dp2px(240), dp2px(155));
canvas.drawPath(mPath, paintPink);
}
静态绘制这样就完工了,可是我们怎么才能实现动态绘制呐?
3.3 动态绘制贝塞尔曲线(TypeEvaluator估值器的使用)
为了获取实时的坐标,我们需要通过 TypeEvaluator 打造一个属于我们自己的估值器。
首先我们需要创建一个自己的类,用于记录各种点信息和具体操作信息:
public class ViewPoint {
float x, y;
float x1, y1;
float x2, y2;
int operation;
public ViewPoint() {}
public ViewPoint(float x, float y) {
this.x = x;
this.y = y;
}
public static ViewPoint moveTo(float x, float y, int operation) {
return new ViewPoint(x, y, operation);
}
public static ViewPoint lineTo(float x, float y, int operation) {
return new ViewPoint(x, y, operation);
}
public static ViewPoint curveTo(float x, float y, float x1, float y1, float x2, float y2, int operation) {
return new ViewPoint(x, y, x1, y1, x2, y2, operation);
}
public static ViewPoint quadTo(float x, float y, float x1, float y1, int operation) {
return new ViewPoint(x, y, x1, y1, operation);
}
private ViewPoint(float x, float y, int operation) {
this.x = x;
this.y = y;
this.operation = operation;
}
public ViewPoint(float x, float y, float x1, float y1, int operation) {
this.x = x;
this.y = y;
this.x1 = x1;
this.y1 = y1;
this.operation = operation;
}
public ViewPoint(float x, float y, float x1, float y1, float x2, float y2, int operation) {
this.x = x;
this.y = y;
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
this.operation = operation;
}
}
再写一个类用来记录Path的相关操作,并且记录到具体的ViewPoint中:
public class ViewPath {
public static final int MOVE = 0;
public static final int LINE = 1;
public static final int QUAD = 2;
public static final int CURVE = 3;
private ArrayList
mPoints;
public ViewPath() {
mPoints = new ArrayList<>();
}
public void moveTo(float x, float y) {
mPoints.add(ViewPoint.moveTo(x, y, MOVE));
}
public void lineTo(float x, float y) {
mPoints.add(ViewPoint.lineTo(x, y, LINE));
}
public void curveTo(float x, float y, float x1, float y1, float x2, float y2) {
mPoints.add(ViewPoint.curveTo(x, y, x1, y1, x2, y2, CURVE));
}
public void quadTo(float x, float y, float x1, float y1) {
mPoints.add(ViewPoint.quadTo(x, y, x1, y1, QUAD));
}
public Collection
getPoints() { return mPoints;
}
}
最后就是我们的关键点,通过实现TypeEvaluator接口,来打造我们自己的估值器,最终返回实时的坐标。
我们通过泛型传入ViewPoint类,并且复写evaluate()方法。
evaluate() 方法共有三个参数,分别是:当前进度、起始数据和终点数据。
具体计算过程则是分清每一种操作类别,然后套计算公式即可。
public class ViewPathEvaluator implements TypeEvaluator
{
public ViewPathEvaluator() {}
@Override
public ViewPoint evaluate(float t, ViewPoint startValue, ViewPoint endValue) {
float x, y;
float startX, startY;
// 判断结束点的类型,根据后一个点类型,来计算开始点和结束点的变化
if (endValue.operation == ViewPath.LINE) {
// line:画直线,当前值 = 起始点 + t * (结束点-起始点)
// 判断开始点的类型,找到它真正的起始点
// 如果上一步操作(startValue)是二阶,取二阶的结束点x1 为新起点
startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startValue.x;
// 如果上一步操作(startValue)是三阶,取三阶的结束点x2 为新起点
startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startX;
// 以上两步:如果既不是二阶,也不是三阶,直接取startValue.x(一阶)
// Y 取值方式与 X 类似
startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startValue.y;
startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startY;
x = startX + t * (endValue.x - startX);
y = startY + t * (endValue.y - startY);
} else if (endValue.operation == ViewPath.CURVE) {
// curve:三阶,同上:先求真实起始点,然后套公式求值
startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startValue.x;
startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startValue.y;
startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startX;
startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startY;
float oneMinusT = 1 - t;
//三阶贝塞尔函数(套公式)
x = oneMinusT * oneMinusT * oneMinusT * startX +
3 * oneMinusT * oneMinusT * t * endValue.x +
3 * oneMinusT * t * t * endValue.x1 +
t * t * t * endValue.x2;
y = oneMinusT * oneMinusT * oneMinusT * startY +
3 * oneMinusT * oneMinusT * t * endValue.y +
3 * oneMinusT * t * t * endValue.y1 +
t * t * t * endValue.y2;
} else if (endValue.operation == ViewPath.MOVE) {
// move:重新设置起点
x = endValue.x;
y = endValue.y;
} else if (endValue.operation == ViewPath.QUAD) {
startX = (startValue.operation == ViewPath.CURVE) ? startValue.x2 : startValue.x;
startY = (startValue.operation == ViewPath.CURVE) ? startValue.y2 : startValue.y;
startX = (startValue.operation == ViewPath.QUAD) ? startValue.x1 : startX;
startY = (startValue.operation == ViewPath.QUAD) ? startValue.y1 : startY;
//二阶贝塞尔函数
float oneMinusT = 1 - t;
x = oneMinusT * oneMinusT * startX +
2 * oneMinusT * t * endValue.x +
t * t * endValue.x1;
y = oneMinusT * oneMinusT * startY +
2 * oneMinusT * t * endValue.y +
t * t * endValue.y1;
} else {
x = endValue.x;
y = endValue.y;
}
return new ViewPoint(x, y);
}
}
具体使用如下:
private ValueAnimator animHead;
private ViewPoint pointHead = new ViewPoint();
private Path mPath = new Path();
public void initPath() {
// 千万不要觉得下面很复杂,就是找贝尔塞的控制点和结束点而已,很简单
// 我们的ViewPath,其实可以绘制任何直线路径和贝塞尔曲线路径了,自己在调用lineTo传入点等就行了
ViewPath viewPath = new ViewPath();
pointHead.x = dp2px(220);
pointHead.y = dp2px(102);
mPath.moveTo(pointHead.x, pointHead.y);
viewPath.moveTo(pointHead.x, pointHead.y);
viewPath.curveTo(dp2px(-100), dp2px(80), dp2px(130), dp2px(330), dp2px(170), dp2px(170));
viewPath.quadTo(dp2px(210), dp2px(170), dp2px(240), dp2px(155));
animHead = ValueAnimator.ofObject(new ViewPathEvaluator(), viewPath.getPoints().toArray());
animHead.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
// 获取最新的坐标点,并执行重绘操作
pointHead = (ViewPoint) valueAnimator.getAnimatedValue();
invalidate();
}
}
);
animHead.setDuration(5000);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPath.lineTo(pointHead.x, pointHead.y);
canvas.drawPath(mPath, paintPink);
}
执行下start()操作,就是一个动态绘制的猪头了:
至于让所有的动画连起来绘制,则使用AnimatorSet类,playSequentially()方法是将动画集合按顺序播放。
源码地址:
https://github.com/princekin-f/Page
到这里就结束啦。