在制作高级控件的时候往往会用到很多的高级数学公式,例如本文将要讲到的贝塞尔曲线,结合Path使用,可以实现很多复杂的动画效果。
作用 | 相关方法 | 备注 |
---|---|---|
移动起点 | moveTo | 移动下一次操作的起点位置 |
设置终点 | setLastPoint | 重置当前path中最后一个点位置,如果在绘制之前调用,效果和moveTo相同 |
连接直线 | lineTo | 添加上一个点到当前点之间的直线到Path |
闭合路径 | close | 连接第一个点连接到最后一个点,形成一个闭合区域 |
添加内容 | addRect, addRoundRect, addOval, addCircle, addPath, addArc, arcTo | 添加(矩形, 圆角矩形, 椭圆, 圆, 路径, 圆弧) 到当前Path (注意addArc和arcTo的区别) |
是否为空 | isEmpty | 判断Path是否为空 |
是否为矩形 | isRect | 判断path是否是一个矩形 |
替换路径 | set | 用新的路径替换到当前路径所有内容 |
偏移路径 | offset | 对当前路径之前的操作进行偏移(不会影响之后的操作) |
贝塞尔曲线 | quadTo, cubicTo | 分别为二次和三次贝塞尔曲线的方法 |
rXxx方法 | rMoveTo, rLineTo, rQuadTo, rCubicTo | 不带r的方法是基于原点的坐标系(偏移量), rXxx方法是基于当前点坐标系(偏移量) |
填充模式 | setFillType, getFillType, isInverseFillType, toggleInverseFillType | 设置,获取,判断和切换填充模式 |
提示方法 | incReserve | 提示Path还有多少个点等待加入**(这个方法貌似会让Path优化存储结构)** |
布尔操作(API19) | op | 对两个Path进行布尔运算(即取交集、并集等操作) |
计算边界 | computeBounds | 计算Path的边界 |
重置路径 | reset, rewind | 清除Path中的内容 reset不保留内部数据结构,但会保留FillType. rewind会保留内部的数据结构,但不保留FillType |
矩阵操作 | transform | 矩阵变换 |
在讲解本部分之前,请关闭硬件加速,以免引起不必要的问题!
在AndroidMenifest文件中application节点下添上 android:hardwareAccelerated="false"以关闭整个应用的硬件加速。
更多请参考这里:Android的硬件加速及可能导致的问题
本次特地开了一篇详细讲解Path,为什么要单独摘出来呢,这是因为Path在2D绘图中是一个很重要的东西。
在前面我们讲解的所有绘制都是简单图形(如 矩形 圆 圆弧等),而对于那些复杂一点的图形则没法去绘制(如绘制一个心形 正多边形 五角星等),而使用Path不仅能够绘制简单图形,也可以绘制这些比较复杂的图形。另外,根据路径绘制文本和剪裁画布都会用到Path。
关于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封装了由直线和曲线(二次,三次贝塞尔曲线)构成的几何路径。你能用Canvas中的drawPath来把这条路径画出来(同样支持Paint的不同绘制模式),也可以用于剪裁画布和根据路径绘制文字。我们有时会用Path来描述一个图像的轮廓,所以也会称为轮廓线(轮廓线仅是Path的一种使用方法,两者并不等价)
另外路径有开放和封闭的区别。
图像 | 名称 | 备注 |
---|---|---|
封闭路径 | 首尾相接形成了一个封闭区域 | |
开放路径 | 没有首位相接形成封闭区域 |
与Path相关的还有一些比较神奇的概念,不过暂且不说,等接下来需要用到的时候再详细说明。
由于Path的有些知识点无法单独来讲,所以本次采取了一次讲一组方法。
按照惯例,先创建画笔:
Paint mPaint = new Paint(); // 创建画笔
mPaint.setColor(Color.BLACK); // 画笔颜色 - 黑色
mPaint.setStyle(Paint.Style.STROKE); // 填充模式 - 描边
mPaint.setStrokeWidth(10); // 边框宽度 - 10
方法预览:
public void lineTo (float x, float y)
首先讲解的的LineTo,为啥先讲解这个呢?
是因为moveTo、 setLastPoint、 close都无法直接看到效果,借助有具现化效果的lineTo才能让这些方法现出原形。
lineTo很简单,只有一个方法,作用也很容易理解,line嘛,顾名思义就是一条线。
俗话(数学书上)说,两点确定一条直线,但是看参数明显只给了一个点的坐标吧(这不按常理出牌啊)。
再仔细一看,这个lineTo除了line外还有一个to呢,to翻译过来就是“至”,到某个地方的意思,lineTo难道是指从某个点到参数坐标点之间连一条线?
没错,你猜对了,但是这某个点又是哪里呢?
前面我们提到过Path可以用来描述一个图像的轮廓,图像的轮廓通常都是用一条线构成的,所以这里的某个点就是上次操作结束的点,如果没有进行过操作则默认点为坐标原点。
那么我们就来试一下:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心(宽高数据在onSizeChanged中获取)
Path path = new Path(); // 创建Path
path.lineTo(200, 200); // lineTo
path.lineTo(200,0);
canvas.drawPath(path, mPaint); // 绘制Path
在示例中我们调用了两次lineTo,第一次由于之前没有过操作,所以默认点就是坐标原点O,结果就是坐标原点O到A(200,200)之间连直线(用蓝色圈1标注)。
第二次lineTo的时候,由于上次的结束位置是A(200,200),所以就是A(200,200)到B(200,0)之间的连线(用蓝色圈2标注)。
方法预览:
// moveTo
public void moveTo (float x, float y)
// setLastPoint
public void setLastPoint (float dx, float dy)
这两个方法虽然在作用上有相似之处,但实际上却是完全不同的两个东东,具体参照下表:
方法名 | 简介 | 是否影响之前的操作 | 是否影响之后操作 |
---|---|---|---|
moveTo | 移动下一次操作的起点位置 | 否 | 是 |
setLastPoint | 设置之前操作的最后一个点位置 | 是 | 是 |
废话不多说,直接上代码:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path(); // 创建Path
path.lineTo(200, 200); // lineTo
path.moveTo(200,100); // moveTo
path.lineTo(200,0); // lineTo
canvas.drawPath(path, mPaint); // 绘制Path
这个和上面演示lineTo的方法类似,只不过在两个lineTo之间添加了一个moveTo。
moveTo只改变下次操作的起点,在执行完第一次LineTo的时候,本来的默认点位置是A(200,200),但是moveTo将其改变成为了C(200,100),所以在第二次调用lineTo的时候就是连接C(200,100) 到 B(200,0) 之间的直线(用蓝色圈2标注)。
下面是setLastPoint的示例:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path(); // 创建Path
path.lineTo(200, 200); // lineTo
path.setLastPoint(200,100); // setLastPoint
path.lineTo(200,0); // lineTo
canvas.drawPath(path, mPaint); // 绘制Path
setLastPoint是重置上一次操作的最后一个点,在执行完第一次的lineTo的时候,最后一个点是A(200,200),而setLastPoint更改最后一个点为C(200,100),所以在实际执行的时候,第一次的lineTo就不是从原点O到A(200,200)的连线了,而变成了从原点O到C(200,100)之间的连线了。
在执行完第一次lineTo和setLastPoint后,最后一个点的位置是C(200,100),所以在第二次调用lineTo的时候就是C(200,100) 到 B(200,0) 之间的连线(用蓝色圈2标注)。
方法预览:
public void close ()
close方法用于连接当前最后一个点和最初的一个点(如果两个点不重合的话),最终形成一个封闭的图形。
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path(); // 创建Path
path.lineTo(200, 200); // lineTo
path.lineTo(200,0); // lineTo
path.close(); // close
canvas.drawPath(path, mPaint); // 绘制Path
很明显,两个lineTo分别代表第1和第2条线,而close在此处的作用就算连接了B(200,0)点和原点O之间的第3条线,使之形成一个封闭的图形。
注意:close的作用是封闭路径,与连接当前最后一个点和第一个点并不等价。如果连接了最后一个点和第一个点仍然无法形成封闭图形,则close什么 也不做。
这次内容主要是在Path中添加基本图形,重点区分addArc与arcTo。
方法预览:
// 第一类(基本形状)
// 圆形
public void addCircle (float x, float y, float radius, Path.Direction dir)
// 椭圆
public void addOval (RectF oval, Path.Direction dir)
// 矩形
public void addRect (float left, float top, float right, float bottom, Path.Direction dir)
public void addRect (RectF rect, Path.Direction dir)
// 圆角矩形
public void addRoundRect (RectF rect, float[] radii, Path.Direction dir)
public void addRoundRect (RectF rect, float rx, float ry, Path.Direction dir)
这一类就是在path中添加一个基本形状,基本形状部分和前面所讲的绘制基本形状并无太大差别,详情参考Canvas(1)颜色与基本形状, 本次只将其中不同的部分摘出来详细讲解一下。
仔细观察一下第一类的方法,无一例外,在最后都有一个_Path.Direction_,这是一个什么神奇的东东?
Direction的意思是 方向,趋势。 点进去看一下会发现Direction是一个枚举(Enum)类型,里面只有两个枚举常量,如下:
类型 | 解释 | 翻译 |
---|---|---|
CW | clockwise | 顺时针 |
CCW | counter-clockwise | 逆时针 |
下面我们看一下顺时针和逆时针的作用。
序号 | 作用 |
---|---|
1 | 在添加图形时确定闭合顺序(各个点的记录顺序) |
2 | 对图形的渲染结果有影响(是判断图形渲染的重要条件) |
咱们先研究确定闭合顺序的问题,添加一个矩形试试看:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path();
path.addRect(-200,-200,200,200, Path.Direction.CW);
canvas.drawPath(path,mPaint);
将上面代码的CW改为CCW再运行一次。接下来就是见证奇迹的时刻,两次运行结果一模一样,有木有很神奇!
其实啊,这个东东是自带隐身技能的,想要让它现出原形,就要用到咱们刚刚学到的setLastPoint(重置当前最后一个点的位置)。
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path();
path.addRect(-200,-200,200,200, Path.Direction.CW);
path.setLastPoint(-300,300); // <-- 重置最后一个点的位置
canvas.drawPath(path,mPaint);
可以明显看到,图形发生了奇怪的变化。为何会如此呢?
我们先分析一下,绘制一个矩形(仅绘制边线),实际上只需要进行四次lineTo操作就行了,也就是说,只需要知道4个点的坐标,然后使用moveTo到第一个点,之后依次lineTo就行了(从上面的测试可以看出,在实际绘制中也确实是这么干的)。
可是为什么要这么做呢?确定一个矩形最少需要两个点(对角线的两个点),根据这两个点的坐标直接算出四条边然后画出来不就行了,干嘛还要先计算出四个点坐标,之后再连直线呢?
这个就要涉及一些path的存储问题了,前面在path中的定义中说过,Path是封装了由直线和曲线(二次,三次贝塞尔曲线)构成的几何路径。其中曲线部分用的是贝塞尔曲线,稍后再讲。 然而除了曲线部分就只剩下直线了,对于直线的存储最简单的就是记录坐标点,然后直接连接各个点就行了。虽然记录矩形只需要两个点,但是如果只用两个点来记录一个矩形的话,就要额外增加一个标志位来记录这是一个矩形,显然对于存储和解析都是很不划算的事情,将矩形转换为直线,为的就是存储记录方便。
扯了这么多,该回归正题了,就是我们的顺时针和逆时针在这里是干啥的?
图形在实际记录中就是记录各个的点,对于一个图形来说肯定有多个点,既然有这么多的点,肯定就需要一个先后顺序,这里顺时针和逆时针就是用来确定记录这些点的顺序的。
对于上面这个矩形来说,我们采用的是顺时针(CW),所以记录的点的顺序就是 A -> B -> C -> D. 最后一个点就是D,我们这里使用setLastPoint改变最后一个点的位置实际上是改变了D的位置。
理解了上面的原理之后,设想如果我们将顺时针改为逆时针(CCW),则记录点的顺序应该就是 A -> D -> C -> B, 再使用setLastPoint则改变的是B的位置,我们试试看结果和我们的猜想是否一致:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
Path path = new Path();
path.addRect(-200,-200,200,200, Path.Direction.CCW);
path.setLastPoint(-300,300); // <-- 重置最后一个点的位置
canvas.drawPath(path,mPaint);
通过验证发现,发现结果和我们猜想的一样,但是还有一个潜藏的问题不晓得大家可否注意到。我们用两个点的坐标确定了一个矩形,矩形起始点(A)就是我们指定的第一个点的坐标。
需要注意的是,交换坐标点的顺序可能就会影响到某些绘制内容哦,例如上面的例子,你可以尝试交换两个坐标点,或者指定另外两个点来作为参数,虽然指定的是同一个矩形,但实际绘制出来是不同的哦。
参数中点的顺序很重要!
参数中点的顺序很重要!
参数中点的顺序很重要!
重要的话说三遍,本次是用矩形作为例子的,其他的几个图形基本上都包含了曲线,详情参见后续的贝塞尔曲线部分。
关于顺时针和逆时针对图形填充结果的影响请等待后续文章,虽然只讲了一个Path,但也是内容颇多,放进一篇中就太长了,请见谅。
方法预览:
// 第二类(Path)
// path
public void addPath (Path src)
public void addPath (Path src, float dx, float dy)
public void addPath (Path src, Matrix matrix)
这个相对比较简单,也很容易理解,就是将两个Path合并成为一个。
第三个方法是将src添加到当前path之前先使用Matrix进行变换。
第二个方法比第一个方法多出来的两个参数是将src进行了位移之后再添加进当前path中。
示例:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
canvas.scale(1,-1); // <-- 注意 翻转y坐标轴
Path path = new Path();
Path src = new Path();
path.addRect(-200,-200,200,200, Path.Direction.CW);
src.addCircle(0,0,100, Path.Direction.CW);
path.addPath(src,0,200);
mPaint.setColor(Color.BLACK); // 绘制合并后的路径
canvas.drawPath(path,mPaint);
首先我们新建的两个Path(矩形和圆形)中心都是坐标原点,我们在将包含圆形的path添加到包含矩形的path之前将其进行移动了一段距离,最终绘制出来的效果就如上面所示。
方法预览:
// 第三类(addArc与arcTo)
// addArc
public void addArc (RectF oval, float startAngle, float sweepAngle)
// arcTo
public void arcTo (RectF oval, float startAngle, float sweepAngle)
public void arcTo (RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo)
从名字就可以看出,这两个方法都是与圆弧相关的,作用都是添加一个圆弧到path中,但既然存在两个方法,两者之间肯定是有区别的:
名称 | 作用 | 区别 |
---|---|---|
addArc | 添加一个圆弧到path | 直接添加一个圆弧到path中 |
arcTo | 添加一个圆弧到path | 添加一个圆弧到path,如果圆弧的起点和上次最后一个坐标点不相同,就连接两个点 |
可以看到addArc有1个方法(实际上是两个的,但另一个重载方法是API21添加的), 而arcTo有2个方法,其中一个最后多了一个布尔类型的变量forceMoveTo。
forceMoveTo是什么作用呢?
这个变量意思为“是否强制使用moveTo”,也就是说,是否使用moveTo将变量移动到圆弧的起点位移,也就意味着:
forceMoveTo | 含义 | 等价方法 |
---|---|---|
true | 将最后一个点移动到圆弧起点,即不连接最后一个点与圆弧起点 | public void addArc (RectF oval, float startAngle, float sweepAngle) |
false | 不移动,而是连接最后一个点与圆弧起点 | public void arcTo (RectF oval, float startAngle, float sweepAngle) |
示例(addArc):
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
canvas.scale(1,-1); // <-- 注意 翻转y坐标轴
Path path = new Path();
path.lineTo(100,100);
RectF oval = new RectF(0,0,300,300);
path.addArc(oval,0,270);
// path.arcTo(oval,0,270,true); // <-- 和上面一句作用等价
canvas.drawPath(path,mPaint);
示例(arcTo):
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
canvas.scale(1,-1); // <-- 注意 翻转y坐标轴
Path path = new Path();
path.lineTo(100,100);
RectF oval = new RectF(0,0,300,300);
path.arcTo(oval,0,270);
// path.arcTo(oval,0,270,false); // <-- 和上面一句作用等价
canvas.drawPath(path,mPaint);
从上面两张运行效果图可以清晰的看出来两者的区别,我就不再废话了。
这一组比较简单,稍微说一下就可以了。
方法预览:
public boolean isEmpty ()
判断path中是否包含内容。
Path path = new Path();
Log.e("1",path.isEmpty()+"");
path.lineTo(100,100);
Log.e("2",path.isEmpty()+"");
log输出结果:
03-02 14:22:54.770 12379-12379/com.sloop.canvas E/1: true
03-02 14:22:54.770 12379-12379/com.sloop.canvas E/2: false
方法预览:
public boolean isRect (RectF rect)
判断path是否是一个矩形,如果是一个矩形的话,会将矩形的信息存放进参数rect中。
path.lineTo(0,400);
path.lineTo(400,400);
path.lineTo(400,0);
path.lineTo(0,0);
RectF rect = new RectF();
boolean b = path.isRect(rect);
Log.e("Rect","isRect:"+b+"| left:"+rect.left+"| top:"+rect.top+"| right:"+rect.right+"| bottom:"+rect.bottom);
log 输出结果:
03-02 16:48:39.669 24179-24179/com.sloop.canvas E/Rect: isRect:true| left:0.0| top:0.0| right:400.0| bottom:400.0
方法预览:
public void set (Path src)
将新的path赋值到现有path。
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
canvas.scale(1,-1); // <-- 注意 翻转y坐标轴
Path path = new Path(); // path添加一个矩形
path.addRect(-200,-200,200,200, Path.Direction.CW);
Path src = new Path(); // src添加一个圆
src.addCircle(0,0,100, Path.Direction.CW);
path.set(src); // 大致相当于 path = src;
canvas.drawPath(path,mPaint);
方法预览:
public void offset (float dx, float dy)
public void offset (float dx, float dy, Path dst)
这个的作用也很简单,就是对path进行一段平移,它和Canvas中的translate作用很像,但Canvas作用于整个画布,而path的offset只作用于当前path。
但是第二个方法最后怎么会有一个path作为参数?
其实第二个方法中最后的参数dst是存储平移后的path的。
dst状态 | 效果 |
---|---|
dst不为空 | 将当前path平移后的状态存入dst中,不会影响当前path |
dst为空(null) | 平移将作用于当前path,相当于第一种方法 |
示例:
canvas.translate(mWidth / 2, mHeight / 2); // 移动坐标系到屏幕中心
canvas.scale(1,-1); // <-- 注意 翻转y坐标轴
Path path = new Path(); // path中添加一个圆形(圆心在坐标原点)
path.addCircle(0,0,100, Path.Direction.CW);
Path dst = new Path(); // dst中添加一个矩形
dst.addRect(-200,-200,200,200, Path.Direction.CW);
path.offset(300,0,dst); // 平移
canvas.drawPath(path,mPaint); // 绘制path
mPaint.setColor(Color.BLUE); // 更改画笔颜色
canvas.drawPath(dst,mPaint); // 绘制dst
从运行效果图可以看出,虽然我们在dst中添加了一个矩形,但是并没有表现出来,所以,当dst中存在内容时,dst中原有的内容会被清空,而存放平移后的path。
关于Path的详细例子,可以查看Android雷达图(蜘蛛网图)绘制。
贝塞尔曲线的运用是十分广泛的,可以说贝塞尔曲线奠定了计算机绘图的基础(因为它可以将任何复杂的图形用精确的数学语言进行描述),在你不经意间就已经使用过它了。
你会使用Photoshop的话,你可能会注意到里面有一个钢笔工具,这个钢笔工具核心就是贝塞尔曲线。
你说你不会PS? 没关系,你如果看过前面的文章或者用过2D绘图,肯定绘制过圆,圆弧,圆角矩形等这些东西。这里面的圆弧部分全部都是贝塞尔曲线的运用。
- QQ小红点拖拽效果
虽然贝塞尔曲线用途非常广泛,然而目前貌似并没有适合的中文教程,能够搜索出来Android关于贝塞尔曲线的中文文章基本可以分为以下几种:
以上几种类型中比较有用的就是基础型和实战型,但两者各有不足,本文会综合两者内容,从零开始学习贝塞尔曲线。
此处理解贝塞尔曲线并非是学会公式的推导过程(不是推倒(ノ*・ω・)ノ),而是要了解贝塞尔曲线是如何生成的。
贝塞尔曲线是用一系列点来控制曲线状态的,我将这些点简单分为两类:
类型 | 作用 |
---|---|
数据点 | 确定曲线的起始和结束位置 |
控制点 | 确定曲线的弯曲程度 |
此处暂时仅作了解概念,接下来就会讲解其中详细的含义。
一阶曲线原理:
一阶曲线是没有控制点的,仅有两个数据点(A 和 B),最终效果一个线段。
上图表示的是一阶曲线生成过程中的某一个阶段,动态过程可以参照下图(本文中贝塞尔曲线相关的动态演示图片来自维基百科)。
PS:一阶曲线其实就是前面讲解过的lineTo。
二阶曲线原理:
二阶曲线由两个数据点(A 和 C),一个控制点(B)来描述曲线状态,大致如下:
上图中红色曲线部分就是传说中的二阶贝塞尔曲线,那么这条红色曲线是如何生成的呢?接下来我们就以其中的一个状态分析一下:
连接AB BC,并在AB上取点D,BC上取点E,使其满足条件:
连接DE,取点F,使得:
这样获取到的点F就是贝塞尔曲线上的一个点,动态过程如下:
PS: 二阶曲线对应的方法是quadTo
三阶曲线原理:
三阶曲线由两个数据点(A 和 D),两个控制点(B 和 C)来描述曲线状态,如下:
三阶曲线计算过程与二阶类似,具体可以见下图动态效果:
PS: 三阶曲线对应的方法是cubicTo
一阶曲线是一条线段,非常简单,可以参见上一篇文章Path之基本操作,此处就不详细讲解了。
通过上面对二阶曲线的简单了解,我们知道二阶曲线是由两个数据点,一个控制点构成,接下来我们就用一个实例来演示二阶曲线是如何运用的。
首先,两个数据点是控制贝塞尔曲线开始和结束的位置,比较容易理解,而控制点则是控制贝塞尔的弯曲状态,相对来说比较难以理解,所以本示例重点在于理解贝塞尔曲线弯曲状态与控制点的关系,废话不多说,先上效果图:
为了更加容易看出控制点与曲线弯曲程度的关系,上图中绘制出了辅助点和辅助线,从上面的动态图可以看出,贝塞尔曲线在动态变化过程中有类似于橡皮筋一样的弹性效果,因此在制作一些弹性效果的时候很常用。
主要代码如下:
public class Bezier extends View {
private Paint mPaint;
private int centerX, centerY;
private PointF start, end, control;
public Bessel1(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStrokeWidth(8);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setTextSize(60);
start = new PointF(0,0);
end = new PointF(0,0);
control = new PointF(0,0);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
centerX = w/2;
centerY = h/2;
// 初始化数据点和控制点的位置
start.x = centerX-200;
start.y = centerY;
end.x = centerX+200;
end.y = centerY;
control.x = centerX;
control.y = centerY-100;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 根据触摸位置更新控制点,并提示重绘
control.x = event.getX();
control.y = event.getY();
invalidate();
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制数据点和控制点
mPaint.setColor(Color.GRAY);
mPaint.setStrokeWidth(20);
canvas.drawPoint(start.x,start.y,mPaint);
canvas.drawPoint(end.x,end.y,mPaint);
canvas.drawPoint(control.x,control.y,mPaint);
// 绘制辅助线
mPaint.setStrokeWidth(4);
canvas.drawLine(start.x,start.y,control.x,control.y,mPaint);
canvas.drawLine(end.x,end.y,control.x,control.y,mPaint);
// 绘制贝塞尔曲线
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth(8);
Path path = new Path();
path.moveTo(start.x,start.y);
path.quadTo(control.x,control.y,end.x,end.y);
canvas.drawPath(path, mPaint);
}
}
三阶曲线由两个数据点和两个控制点来控制曲线状态。
代码:
public class Bezier2 extends View {
private Paint mPaint;
private int centerX, centerY;
private PointF start, end, control1, control2;
private boolean mode = true;
public Bezier2(Context context) {
this(context, null);
}
public Bezier2(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStrokeWidth(8);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setTextSize(60);
start = new PointF(0, 0);
end = new PointF(0, 0);
control1 = new PointF(0, 0);
control2 = new PointF(0, 0);
}
public void setMode(boolean mode) {
this.mode = mode;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
centerX = w / 2;
centerY = h / 2;
// 初始化数据点和控制点的位置
start.x = centerX - 200;
start.y = centerY;
end.x = centerX + 200;
end.y = centerY;
control1.x = centerX;
control1.y = centerY - 100;
control2.x = centerX;
control2.y = centerY - 100;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 根据触摸位置更新控制点,并提示重绘
if (mode) {
control1.x = event.getX();
control1.y = event.getY();
} else {
control2.x = event.getX();
control2.y = event.getY();
}
invalidate();
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//drawCoordinateSystem(canvas);
// 绘制数据点和控制点
mPaint.setColor(Color.GRAY);
mPaint.setStrokeWidth(20);
canvas.drawPoint(start.x, start.y, mPaint);
canvas.drawPoint(end.x, end.y, mPaint);
canvas.drawPoint(control1.x, control1.y, mPaint);
canvas.drawPoint(control2.x, control2.y, mPaint);
// 绘制辅助线
mPaint.setStrokeWidth(4);
canvas.drawLine(start.x, start.y, control1.x, control1.y, mPaint);
canvas.drawLine(control1.x, control1.y,control2.x, control2.y, mPaint);
canvas.drawLine(control2.x, control2.y,end.x, end.y, mPaint);
// 绘制贝塞尔曲线
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth(8);
Path path = new Path();
path.moveTo(start.x, start.y);
path.cubicTo(control1.x, control1.y, control2.x,control2.y, end.x, end.y);
canvas.drawPath(path, mPaint);
}
}
三阶曲线相比于二阶曲线可以制作更加复杂的形状,但是对于高阶的曲线,用低阶的曲线组合也可达到相同的效果,就是传说中的降阶。因此我们对贝塞尔曲线的封装方法一般最高只到三阶曲线。
类型 | 释义 | 变化 |
---|---|---|
降阶 | 在保持曲线形状与方向不变的情况下,减少控制点数量,即降低曲线阶数 | 方法变得简单,数据点变多,控制点可能减少,灵活性变弱 |
升阶 | 在保持曲线形状与方向不变的情况下,增加控制点数量,即升高曲线阶数 | 方法更加复杂,数据点不变,控制点增加,灵活性变强 |
在制作这个实例之前,首先要明确一个内容,就是在什么情况下需要使用贝塞尔曲线?
需要绘制不规则图形时? 当然不是!目前来说,我觉得使用贝塞尔曲线主要有以下几个方面(仅个人拙见,可能存在错误,欢迎指正)
序号 | 内容 | 用例 |
---|---|---|
1 | 事先不知道曲线状态,需要实时计算时 | 天气预报气温变化的平滑折线图 |
2 | 显示状态会根据用户操作改变时 | QQ小红点,仿真翻书效果 |
3 | 一些比较复杂的运动状态(配合PathMeasure使用) | 复杂运动状态的动画效果 |
至于只需要一个静态的曲线图形的情况,用图片岂不是更好,大量的计算会很不划算。
如果是显示SVG矢量图的话,已经有相关的解析工具了(内部依旧运用的有贝塞尔曲线),不需要手动计算。
贝塞尔曲线的主要优点是可以实时控制曲线状态,并可以通过改变控制点的状态实时让曲线进行平滑的状态变化。
我们最终的需要的效果是将一个圆转变成一个心形,通过分析可知,圆可以由四段三阶贝塞尔曲线组合而成,如下:
心形也可以由四段的三阶的贝塞尔曲线组成,如下:
两者的差别仅仅在于数据点和控制点位置不同,因此只需要调整数据点和控制点的位置,就能将圆形变为心形。
关于使用绘制圆形的数据点与控制点早就已经有人详细的计算好了,可以参考stackoverflow的一个回答How to create circle with Bézier curves?其中的数据只需要拿来用即可。
而对于心形的数据点和控制点,可以由圆形的部分数据点和控制点平移后得到,具体参数可以自己慢慢调整到一个满意的效果。
渐变其实就是每次对数据点和控制点稍微移动一点,然后重绘界面,在短时间多次的调整数据点与控制点,使其逐渐接近目标值,通过不断的重绘界面达到一种渐变的效果。过程可以参照下图动态效果:
public class Bezier3 extends View {
private static final float C = 0.551915024494f; // 一个常量,用来计算绘制圆形贝塞尔曲线控制点的位置
private Paint mPaint;
private int mCenterX, mCenterY;
private PointF mCenter = new PointF(0,0);
private float mCircleRadius = 200; // 圆的半径
private float mDifference = mCircleRadius*C; // 圆形的控制点与数据点的差值
private float[] mData = new float[8]; // 顺时针记录绘制圆形的四个数据点
private float[] mCtrl = new float[16]; // 顺时针记录绘制圆形的八个控制点
private float mDuration = 1000; // 变化总时长
private float mCurrent = 0; // 当前已进行时长
private float mCount = 100; // 将时长总共划分多少份
private float mPiece = mDuration/mCount; // 每一份的时长
public Bezier3(Context context) {
this(context, null);
}
public Bezier3(Context context, AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStrokeWidth(8);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setTextSize(60);
// 初始化数据点
mData[0] = 0;
mData[1] = mCircleRadius;
mData[2] = mCircleRadius;
mData[3] = 0;
mData[4] = 0;
mData[5] = -mCircleRadius;
mData[6] = -mCircleRadius;
mData[7] = 0;
// 初始化控制点
mCtrl[0] = mData[0]+mDifference;
mCtrl[1] = mData[1];
mCtrl[2] = mData[2];
mCtrl[3] = mData[3]+mDifference;
mCtrl[4] = mData[2];
mCtrl[5] = mData[3]-mDifference;
mCtrl[6] = mData[4]+mDifference;
mCtrl[7] = mData[5];
mCtrl[8] = mData[4]-mDifference;
mCtrl[9] = mData[5];
mCtrl[10] = mData[6];
mCtrl[11] = mData[7]-mDifference;
mCtrl[12] = mData[6];
mCtrl[13] = mData[7]+mDifference;
mCtrl[14] = mData[0]-mDifference;
mCtrl[15] = mData[1];
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCenterX = w / 2;
mCenterY = h / 2;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawCoordinateSystem(canvas); // 绘制坐标系
canvas.translate(mCenterX, mCenterY); // 将坐标系移动到画布中央
canvas.scale(1,-1); // 翻转Y轴
drawAuxiliaryLine(canvas);
// 绘制贝塞尔曲线
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth(8);
Path path = new Path();
path.moveTo(mData[0],mData[1]);
path.cubicTo(mCtrl[0], mCtrl[1], mCtrl[2], mCtrl[3], mData[2], mData[3]);
path.cubicTo(mCtrl[4], mCtrl[5], mCtrl[6], mCtrl[7], mData[4], mData[5]);
path.cubicTo(mCtrl[8], mCtrl[9], mCtrl[10], mCtrl[11], mData[6], mData[7]);
path.cubicTo(mCtrl[12], mCtrl[13], mCtrl[14], mCtrl[15], mData[0], mData[1]);
canvas.drawPath(path, mPaint);
mCurrent += mPiece;
if (mCurrent < mDuration){
mData[1] -= 120/mCount;
mCtrl[7] += 80/mCount;
mCtrl[9] += 80/mCount;
mCtrl[4] -= 20/mCount;
mCtrl[10] += 20/mCount;
postInvalidateDelayed((long) mPiece);
}
}
// 绘制辅助线
private void drawAuxiliaryLine(Canvas canvas) {
// 绘制数据点和控制点
mPaint.setColor(Color.GRAY);
mPaint.setStrokeWidth(20);
for (int i=0; i<8; i+=2){
canvas.drawPoint(mData[i],mData[i+1], mPaint);
}
for (int i=0; i<16; i+=2){
canvas.drawPoint(mCtrl[i], mCtrl[i+1], mPaint);
}
// 绘制辅助线
mPaint.setStrokeWidth(4);
for (int i=2, j=2; i<8; i+=2, j+=4){
canvas.drawLine(mData[i],mData[i+1],mCtrl[j],mCtrl[j+1],mPaint);
canvas.drawLine(mData[i],mData[i+1],mCtrl[j+2],mCtrl[j+3],mPaint);
}
canvas.drawLine(mData[0],mData[1],mCtrl[0],mCtrl[1],mPaint);
canvas.drawLine(mData[0],mData[1],mCtrl[14],mCtrl[15],mPaint);
}
// 绘制坐标系
private void drawCoordinateSystem(Canvas canvas) {
canvas.save(); // 绘制做坐标系
canvas.translate(mCenterX, mCenterY); // 将坐标系移动到画布中央
canvas.scale(1,-1); // 翻转Y轴
Paint fuzhuPaint = new Paint();
fuzhuPaint.setColor(Color.RED);
fuzhuPaint.setStrokeWidth(5);
fuzhuPaint.setStyle(Paint.Style.STROKE);
canvas.drawLine(0, -2000, 0, 2000, fuzhuPaint);
canvas.drawLine(-2000, 0, 2000, 0, fuzhuPaint);
canvas.restore();
}
}
其实关于贝塞尔曲线最重要的是核心理解贝塞尔曲线的生成方式,只有理解了贝塞尔曲线的生成方式,才能更好的运用贝塞尔曲线。
Path
Canvas
贝塞尔曲线扫盲
贝塞尔曲线-维基百科
How to create circle with Bézier curves?
三次贝塞尔曲线练习之弹性的圆