Path

141 阅读10分钟

使用 Path 类可以实现复合(多轮廓)的几何路径,这些路径可以由直线段(lineTo)、二维的贝塞尔曲线(quadTo)、三维的贝塞尔曲线(cubicTo)组成。你可以通过 canvas.drawPath(path, paint) 在画布上绘制 path,也可以将其用于裁剪或在 path 上写文字。

简单使用

绘制直线段

使用 Path 画线段,代码如下:

class PathView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {

    private val path: Path = Path() // 使用 Path 前首先要创建对象,Path 的起点默认为 (0,0)

    private val linePaint = Paint().apply {
        color = context.getColor(android.R.color.black)
        isAntiAlias = true
        style = Paint.Style.STROKE
    }

    private val textPaint = Paint().apply {
        color = context.getColor(android.R.color.holo_red_light)
        isAntiAlias = true
        style = Paint.Style.STROKE
        textSize = 30f
        strokeWidth = 2f
    }

    init {
        // 用于添加从上一个点到该点的线段,如果该轮廓没有调用 moveTo() 函数,则第一个点为 (0,0)
        path.lineTo(400f, 400f) 
        path.moveTo(800f, 400f) // 设置下一个轮廓的起点坐标为 (800f, 400f)
        path.lineTo(800f, 800f)
        path.lineTo(400f, 800f)
        path.close() // 闭合轮廓
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawPath(path, linePaint)

        canvas.drawText("(400, 400)", 400f, 400f, textPaint)
        canvas.drawText("(800, 400)", 800f, 400f, textPaint)
        canvas.drawText("(800, 800)", 800f, 800f, textPaint)
        canvas.drawText("(400, 800)", 400f, 800f, textPaint)
    }
}

运行后显示如下:

image.png

Path 中的 close() 方法的作用是:闭合当前轮廓(contour)。它会从路径的当前终点画一条直线,连接回该轮廓的起点,从而形成一个封闭的图形(如三角形、矩形、多边形等)。

什么是 contour(轮廓)?在矢量图形中,一个 Path 可以包含多个独立的子路径(称为 contours)。
例如:你可以先画一个圆,再移动到别处画一个矩形——这就是两个 contour。

每个 contour 通常以 moveTo(x, y) 开始,然后通过 lineTo()、quadTo()、cubicTo() 等添加点。

Path 中的 setLastPoint() 是用于修改该 Path 的最后一个点的坐标,而 moveTo() 用于设置下一个轮廓的起点坐标。如果把上面代码中的 path.moveTo(800f, 400f) 改成 path.setLastPoint(800f, 400f),代码如下:

init {
    path.lineTo(400f, 400f) 
    path.setLastPoint(800f, 400f) 
    path.lineTo(800f, 800f)
    path.lineTo(400f, 800f)
    path.close() 
}

运行后如下所示:

image.png

可以看到调用 setLastPoint(800f, 400f) 后,绘制的路径的起点还是 (0f, 0f),但是终点变成了 (800f, 400f)。

重置 Path

可以重置 Path 的函数有两个:reset() 和 rewind():

  • reset(),该函数移除路径中所有的点、线段、曲线等,让 Path 回到 empty 的状态(相当于新建一个空白 Path),但是不会改变 FillType(FillType 决定了路径在填充时如何处理自相交区域,后面会详细介绍)。
  • rewind(),rewind 翻译过来是倒带的意思,该函数也会清除 Path 中的点、线段、曲线,但会保留内部的内存分配,仅重置读写指针。无需重新分配内存,性能更高,方便快速重用。

rewind() 适用于需要频繁重用同一个 Path 对象的场景,比如在 onDraw() 方法中:

// 假设 path 需要在 onDraw() 中反复使用
Path path = new Path();

@Override
protected void onDraw(Canvas canvas) {
    path.rewind(); // 快速清空,保留内存

    path.moveTo(...);
    path.lineTo(...);
    // 构建新路径...

    canvas.drawPath(path, paint);
}

需要注意的是,执行 reset() 或 rewind() 后,Path 的 isEmpty() 都返回 true。

绘制图形

代码如下:

class PathView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {

    private val path: Path = Path()

    private val rectF = RectF(0f, 0f, 0f, 0f)

    private val paint = Paint().apply {
        color = context.getColor(android.R.color.black)
        isAntiAlias = true
        style = Paint.Style.STROKE
    }

    init {
        // 设置矩形的四个顶点坐标
        rectF.set(300f, 300f, 600f, 600f)
        // 给 Path 添加闭合的矩形轮廓
        path.addRect(rectF, Path.Direction.CW)
        
        rectF.set(300f, 700f, 600f, 900f)
        // 给 Path 添加闭合的椭圆轮廓
        path.addOval(rectF, Path.Direction.CW)
        
        // 给 Path 添加闭合的圆形轮廓
        path.addCircle(450f, 1150f, 150f, Path.Direction.CW)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawPath(path, paint)
    }
}

上面的代码绘制了一个矩形、一个椭圆和一个圆,其中 addRect(RectF rect, Direction dir) 需要传入两个参数,第二个参数表示绘制的方向,有两种类型:

public enum Direction {
    /** clockwise */
    CW  (0),    
    /** counter-clockwise */
    CCW (1);    
}
  • CW:clockwise,顺时针。
  • CCW:counter-clockwise,逆时针。

这里是用实线绘制的矩形,看不出来这个参数的作用。如果你绘制的是一个随着手指触摸的起点和终点逐渐变大的虚线矩形,你很容易就会发现这个参数会影响线的绘制方向。

需要注意的是,给 Path 添加闭合的图形轮廓后会影响下一个轮廓的起点,看下面的例子,没有加入图形轮廓前的代码如下:

class PathView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {

    private val path: Path = Path()

    private val paint = Paint().apply {
        color = context.getColor(android.R.color.black)
        isAntiAlias = true
        style = Paint.Style.STROKE
    }

    private val textPaint = Paint().apply {
        color = context.getColor(android.R.color.holo_red_light)
        isAntiAlias = true
        style = Paint.Style.STROKE
        textSize = 30f
        strokeWidth = 2f
    }


    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // canvas 往右平移 400,往下平移 500
        // 会影响后面在 canvas 上绘制的 path 的坐标
        canvas.translate(400f,500f)

        // 轮廓起点默认是(0,0)
        path.lineTo(-200f,0f)
        // 标记 1
        // path.addCircle(0f,0f,200f, Path.Direction.CCW)

        path.lineTo(-200f,200f)
        path.lineTo(200f,200f)
        path.close()
        canvas.drawPath(path,paint)

        canvas.drawText("(0, 0)", 0f, 0f, textPaint)
        canvas.drawText("(-200, 0)", -200f, 0f, textPaint)
        canvas.drawText("(-200, 200)", -200f, 200f, textPaint)
        canvas.drawText("(200, 200)", 200f, 200f, textPaint)
    }
}

运行后显示如下:

image.png

把标记 1 处注释的代码打开,运行后显示如下所示:

image.png

可以看到下一个轮廓的起点变成了(200f, 0f)。

这是因为在 Android 的 Path 类中,有一个 隐式的“当前位置”(current point) 的概念,它决定了下一次绘图操作(如 lineTo, quadTo, arcTo 等)的起点。第一次调用 moveTo(x, y) 会设置当前位置为 (x, y)。调用 lineTo(x, y) 会从当前位置画一条线到 (x, y),然后把当前位置更新为 (x, y)。调用 addCircle()、addRect()、addOval() 等方法时,也会修改当前位置。addCircle() 会隐式地执行一次 moveTo() 到圆的起点(起点具体位置取决于绘制的方向 CW 或 CCW),然后画圆,最后把当前位置作为新的起点。

其他常用的给 Path 添加图形的方法还有:

// 给 Path 添加闭合的圆角矩形轮廓
addRoundRect(RectF rect, float rx, float ry, Direction dir)

// 给 Path 添加圆弧轮廓
addArc(RectF oval, float startAngle, float sweepAngle)

// 与上面的方法不同的地方是:如果圆弧的起点跟 Path 当前的最后一个点不同,自动会
// 使用 lineTo() 来连接这两个点
public void arcTo(RectF oval, float startAngle, float sweepAngle)

// 多了一个参数:forceMoveTo,true 表示开始一个新轮廓,那就跟 AddArc() 方法一样了
public void arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo) 

添加路径

可以通过 addPath() 方法把别的路径添加到当前路径中:

// 把 src 的 copy 添加到当前 Path 中
addPath(Path src)

// 先将 src 的 copy 进行(x,y)位移之后再添加到当前 Path 中
addPath(Path src, float dx, float dy)

// 先将 src 的 copy 进行 matrix 变换之后再添加到当前 Path 中
addPath(@NonNull Path src, @NonNull Matrix matrix)

看下面的代码:

canvas.translate(350f, 500f)
val pathRect = Path()
val pathCircle = Path()
pathRect.addRect(-200f, -200f, 200f, 200f, Path.Direction.CW)
pathCircle.addCircle(0f, 0f, 100f, Path.Direction.CW)

// 将圆形的 path 移动 (0f, 200f), 然后添加到矩形的 path 中
pathRect.addPath(pathCircle, 0f, 200f)
// 绘制
canvas.drawPath(pathRect, paint)

其他常用方法

  • isEmpty(),用于判断 path 是否包含直线和曲线;
  • isRect(RectF rect),判断 path 是否是一个矩形,如果是,并且 rect 不为 null,会将该矩形的坐标(left, top, right, bottom)写入参数 rect 中;
  • set(Path src),替换 path 的内容为 src 的内容;
  • offset(float dx, float dy),平移 path;

FillType

可以通过 setFillType(FillType ft) 设置 path 的填充规则,有以下几种类型:

public enum FillType {
    WINDING         (0),  // 默认值,非零环绕规则
    EVEN_ODD        (1),  // 奇偶规则
    INVERSE_WINDING (2),  // 反向非零环绕规则
    INVERSE_EVEN_ODD(3);  // 反向奇偶规则
}

从名字可以看出来,这些类型分成了两对:WINDING 与 INVERSE_WINDING,EVEN_ODD 与 INVERSE_EVEN_ODD。

    1. WINDING,非零环绕规则,从待判断点(待填充颜色的点)向任意方向发射一条射线,统计路径穿过射线的方向,顺时针方向穿过+1,逆时针方向穿过-1,最终总和不等于0,则该点需要填充颜色。
    1. EVEN_ODD,奇偶规则,从待判断点向任意方向发射一条射线,数这条射线穿过了多少条路径线,如果为奇数,则点在内部,填充;偶数则在外部,不填充。

看下面的代码:

// 创建两个交叉的圆形路径
val path = Path()
path.addCircle(500f, 400f, 250f, Path.Direction.CW) // 顺时针圆
path.addCircle(500f, 600f, 250f, Path.Direction.CW)

// 绘制不同填充模式的效果
val paint = Paint()
paint.style = Paint.Style.FILL

// 绘制 WINDING 模式效果
path.fillType = Path.FillType.WINDING
paint.color = Color.RED
canvas.drawPath(path, paint)

// 绘制 EVEN_ODD 模式效果
path.fillType = Path.FillType.EVEN_ODD
paint.color = Color.BLUE
canvas.translate(0f, 800f) // 向下移动位置
canvas.drawPath(path, paint)

运行后显示如下:

image.png

fillType 为 WINDING 时,由于两个圆形路径交叉的区域有两个同为顺时针方向的路径,计数值为2,所以该区域需要填充颜色;fillType 为 EVEN_ODD 时,交叉区域向外发射射线,射线会穿过2次路径,则该区域不填充颜色。

两个 Path 之间的运算

可以通过下面的方法对两个 Path 进行运算。

boolean op(Path path, Op op) 
// path1.op(path2, Path.Op.DIFFERENCE),path1 和 path2 执行运算,
// 运算结果存入到 path1 中。

boolean op(Path path1, Path path2, Op op)
// path3.op(path1, path2, Path.Op.DIFFERENCE),path1 和 path2 
// 执行运算,运算结果存入到 path3 中。

其中 OP 是枚举类型:

public enum Op {
    DIFFERENCE,
    INTERSECT,
    UNION,
    XOR,
    REVERSE_DIFFERENCE
}

传入不同的 OP 类型会生成不同的结果: image.png

贝塞尔曲线

贝塞尔曲线可以由数据点和若干个控制点描述。

  • 数据点:实际经过的点,起点和终点。
  • 控制点:不经过的点,像“磁铁”一样吸引曲线,控制曲线的切线方向和弯曲程度。

Path 类中绘制贝塞尔曲线的方法如下:

// 添加二阶贝塞尔曲线,从当前路径的最后一个点(即“当前点”)出发,
// 接近控制点(x1, y1),最终到达终点(x2, y2)。
// 如果没有对此轮廓调用 moveTo(),会自动将起始点设为 (0, 0)。
quadTo(float x1, float y1, float x2, float y2)

使用示例:

Path path = new Path();
path.moveTo(10, 10);               // 设置起点为 (10,10)
path.quadTo(50, 0, 100, 10);       // 添加一条曲线:控制点 (50,0),终点 (100,10)

这段代码从 (10,10) 开始,向 (50,0) 方向弯曲,最终到达 (100,10),形成一个平滑的弧线。

// 与 quadTo() 一样,但是(dx1, dy1)和(dx2, dy2)是相对于上一个点的偏移量,
// 如果没有上一个点,会自动先调用 moveTo(0, 0)。
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)

// 添加三阶贝塞尔曲线,从当前路径的最后一个点(即“当前点”)出发,
// 接近控制点(x1, y1)和(x2, y2),最终到达终点(x3, y3)。
// 如果没有对此轮廓调用 moveTo(),会自动将起始点设为 (0, 0)。
cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
               
// 与 cubicTo() 一样,但是参数是相对于起点的偏移量,
// 如果没有上一个点,会自动先调用 moveTo(0, 0)。
rCubicTo(float x1, float y1, float x2, float y2, float x3, float y3)