绘制弧线的三个方法

了解如何使用 SkiaSharp 以三种不同的方式定义弧线

弧是椭圆形的周长上的曲线,例如此无穷大符号的圆弧部分:

无穷大符号

尽管该定义很简单,但无法定义一个满足所有需求的弧绘制函数,因此在图形系统中并没有关于最佳绘制弧线方式的共识。因此,SKPath 类不会仅限于一种方法。

SKPath 定义一种 AddArc 方法、五种不同的 ArcTo 方法和两个相对的 RArcTo 方法。 这些方法分为三类,表示三种指定弧线的截然不同的方法。你使用哪一个取决于可用于定义弧线的信息,以及此弧线如何与要绘制的其他图形相适应。

角弧

用角弧法绘制弧线时,需要指定一个矩形来包围椭圆形。 此椭圆形圆周上的弧线由椭圆形中心的角度表示,这些角度表示弧线的起点和长度。 有两种不同的方法来绘制角弧。 以下是 AddArc 方法和 ArcTo 方法:

public void AddArc (SKRect oval, Single startAngle, Single sweepAngle)

public void ArcTo (SKRect oval, Single startAngle, Single sweepAngle, Boolean forceMoveTo)

这些方法与 Android AddArc 和 [ArcTo]xref:Android.Graphics.Path.ArcTo*) 方法相同。 iOS AddArc 方法是相似的,但仅限于圆周上的弧线,而不是推广到椭圆形。

这两种方法都以定义椭圆形的位置和大小的 SKRect 值开头:

开始角弧的椭圆

弧是这个椭圆形的周长的一部分。

startAngle 参数是相对于从椭圆形中心向右绘制的水平线的顺时针角度,以度为单位。 sweepAngle 参数相对于 startAngle。 下面是分别为 60 度和 100 度的 startAnglesweepAngle 值:

定义角弧的角度

弧线从起始角度开始。 其长度由扫描角度控制。 弧线在此处以红色显示:

突出显示的角弧

使用 AddArcArcTo 方法添加到路径的曲线只是椭圆形周长的一部分:

角弧本身

startAnglesweepAngle 参数可以是负值:弧线在正值 sweepAngle 时为顺时针方向,在负值 时为反时针方向。

但是,AddArc不会定义封闭轮廓。 如果在 AddArc 后调用 LineTo,则在 LineTo 法中,从弧的末端到点画一条直线,在 ArcTo 法中也是如此。

AddArc 会自动启动新的轮廓线,在功能上等效于使用具有最终参数的 true 调用 ArcTo

path.ArcTo (oval, startAngle, sweepAngle, true);

最后一个参数称为 forceMoveTo,它实际上会导致在弧线开头的 MoveTo 调用。这会开始新的轮廓。 false 的最后一个参数并非如此:

path.ArcTo (oval, startAngle, sweepAngle, false);

此版本的 ArcTo 会从当前位置绘制到弧线的开头。这意味着弧线可以位于较大轮廓中间的某个位置。

角弧线”页允许使用两个滑块来指定起始角度和扫描角度。 XAML 文件会实例化两个 Slider 元素和一个 SKCanvasViewAngleArcPage.xaml.cs 文件中的 PaintCanvas 处理程序使用定义为字段的两个 SKPaint 对象绘制椭圆和弧线:

void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    SKRect rect = new SKRect(100, 100, info.Width - 100, info.Height - 100);
    float startAngle = (float)startAngleSlider.Value;
    float sweepAngle = (float)sweepAngleSlider.Value;

    canvas.DrawOval(rect, outlinePaint);

    using (SKPath path = new SKPath())
    {
        path.AddArc(rect, startAngle, sweepAngle);
        canvas.DrawPath(path, arcPaint);
    }
}

如你所见,起始角度和扫描角度都可以采用负值:

“角弧”页的三张屏幕截图

生成弧线的方法在算法上是最简单的方法,并且很容易派生描述弧线的参数公式。知道椭圆形的大小和位置,以及起始角和扫描角,就可以用简单的三角法计算出弧线的起点和终点:

x = oval.MidX + (oval.Width / 2) * cos(angle)

y = oval.MidY + (oval.Height / 2) * sin(angle)

angle 值为或 startAnglestartAngle + sweepAngle

使用两个角度来定义弧线最适合知道要绘制的弧线的角长度的情况,例如,要绘制饼图。 “分解饼图”页对此进行了演示。 ExplodedPieChartPage 类使用内部类来定义一些捏造的数据和颜色:

class ChartData
{
    public ChartData(int value, SKColor color)
    {
        Value = value;
        Color = color;
    }

    public int Value { private set; get; }

    public SKColor Color { private set; get; }
}

ChartData[] chartData =
{
    new ChartData(45, SKColors.Red),
    new ChartData(13, SKColors.Green),
    new ChartData(27, SKColors.Blue),
    new ChartData(19, SKColors.Magenta),
    new ChartData(40, SKColors.Cyan),
    new ChartData(22, SKColors.Brown),
    new ChartData(29, SKColors.Gray)
};

PaintSurface 处理程序首先会循环访问项以计算 totalValues 数字。 从中可以确定每个项的大小作为总计的分数,并将其转换为角度:

void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    int totalValues = 0;

    foreach (ChartData item in chartData)
    {
        totalValues += item.Value;
    }

    SKPoint center = new SKPoint(info.Width / 2, info.Height / 2);
    float explodeOffset = 50;
    float radius = Math.Min(info.Width / 2, info.Height / 2) - 2 * explodeOffset;
    SKRect rect = new SKRect(center.X - radius, center.Y - radius,
                             center.X + radius, center.Y + radius);

    float startAngle = 0;

    foreach (ChartData item in chartData)
    {
        float sweepAngle = 360f * item.Value / totalValues;

        using (SKPath path = new SKPath())
        using (SKPaint fillPaint = new SKPaint())
        using (SKPaint outlinePaint = new SKPaint())
        {
            path.MoveTo(center);
            path.ArcTo(rect, startAngle, sweepAngle, false);
            path.Close();

            fillPaint.Style = SKPaintStyle.Fill;
            fillPaint.Color = item.Color;

            outlinePaint.Style = SKPaintStyle.Stroke;
            outlinePaint.StrokeWidth = 5;
            outlinePaint.Color = SKColors.Black;

            // Calculate "explode" transform
            float angle = startAngle + 0.5f * sweepAngle;
            float x = explodeOffset * (float)Math.Cos(Math.PI * angle / 180);
            float y = explodeOffset * (float)Math.Sin(Math.PI * angle / 180);

            canvas.Save();
            canvas.Translate(x, y);

            // Fill and stroke the path
            canvas.DrawPath(path, fillPaint);
            canvas.DrawPath(path, outlinePaint);
            canvas.Restore();
        }

        startAngle += sweepAngle;
    }
}

为每个饼图切片以创建新的 SKPath 对象。 该路径由中间的一条线组成,然后是绘制弧线的 ArcTo,另一条线时从 Close 调用返回的中心结果。 此程序通过将“分解”饼片从中心移出 50 像素来显示“分解”饼片。 该项任务需要每个切片的扫描角度中点方向上的一个矢量:

“分解饼图”页的三张屏幕截图

若要查看没有“分解”的外观,只需注释禁止 Translate 调用:

未进行分解的“分解饼图”页面的三张屏幕截图

切线弧

SKPath 支持的第二种类型的弧是 切线弧,之所以称为相切弧,是因为弧是与两条相连直线相切的圆的周长。

将正切弧添加到具有两个 SKPoint 参数的 ArcTo 方法的路径,或具有点的单独 Single 参数的 ArcTo 重载:

public void ArcTo (SKPoint point1, SKPoint point2, Single radius)

public void ArcTo (Single x1, Single y1, Single x2, Single y2, Single radius)

ArcTo 方法类似于 PostScript arct(第 532 页)函数和 iOS AddArcToPoint 方法。

ArcTo 方法涉及三点:

  • 轮廓的当前点;如果未调用 MoveTo,则为点 (0, 0)
  • ArcTo 方法的第一个点参数,称为角点
  • ArcTo 方法的第二个点参数,称为目标点

开始切弧的三个点

这三个点定义两条连接线:

连接切弧的三点的线条

如果这三个点共线,即它们位于同一条直线上,将不会绘制任何弧。

ArcTo 方法还包括 radius 参数。 这将定义圆的半径:

切弧的圆

切弧并不能概括为椭圆形。

如果两条线以任意角度相交,那么可以在这些线之间插入一个圆,使其与两条线都相切:

两行之间的切弧圆

在轮廓上添加的曲线不会触碰到 ArcTo 方法中指定的任何一个点。 它包括一条从当前点到第一个切点的直线,以及一条以第二个切点为终点的弧线,此处以红色显示:

图上显示用红线批注了上一幅图,其中显示了两行之间突出显示的切弧。

这是添加到轮廓的最终直线和弧线:

两行之间突出显示的切弧

轮廓可以从第二个切点继续。

切线弧页面允许尝试使用切线弧。这是派生自 InteractivePage 的多个页面中的第一个页面,它定义了一些方便的 SKPaint 对象并会执行 TouchPoint 处理:

public class InteractivePage : ContentPage
{
    protected SKCanvasView baseCanvasView;
    protected TouchPoint[] touchPoints;

    protected SKPaint strokePaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        StrokeWidth = 3
    };

    protected SKPaint redStrokePaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Red,
        StrokeWidth = 15
    };

    protected SKPaint dottedStrokePaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        Color = SKColors.Black,
        StrokeWidth = 3,
        PathEffect = SKPathEffect.CreateDash(new float[] { 7, 7 }, 0)
    };

    protected void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        bool touchPointMoved = false;

        foreach (TouchPoint touchPoint in touchPoints)
        {
            float scale = baseCanvasView.CanvasSize.Width / (float)baseCanvasView.Width;
            SKPoint point = new SKPoint(scale * (float)args.Location.X,
                                        scale * (float)args.Location.Y);
            touchPointMoved |= touchPoint.ProcessTouchEvent(args.Id, args.Type, point);
        }

        if (touchPointMoved)
        {
            baseCanvasView.InvalidateSurface();
        }
    }
}

TangentArcPage 类从 InteractivePage 派生。 TangentArcPage.xaml.cs 文件中的构造函数负责实例化和初始化 touchPoints 数组,并将 baseCanvasView(在 InteractivePage 中)设置为 TangentArcPage.xaml 文件中实例化的 SKCanvasView 对象:

public partial class TangentArcPage : InteractivePage
{
    public TangentArcPage()
    {
        touchPoints = new TouchPoint[3];

        for (int i = 0; i < 3; i++)
        {
            TouchPoint touchPoint = new TouchPoint
            {
                Center = new SKPoint(i == 0 ? 100 : 500,
                                     i != 2 ? 100 : 500)
            };
            touchPoints[i] = touchPoint;
        }

        InitializeComponent();

        baseCanvasView = canvasView;
        radiusSlider.Value = 100;
    }

    void sliderValueChanged(object sender, ValueChangedEventArgs args)
    {
        if (canvasView != null)
        {
            canvasView.InvalidateSurface();
        }
    }
    ...
}

PaintSurface 处理程序使用 ArcTo 方法基于触摸点和 Slider 绘制弧线,同时也以算法方式计算角度所基于的圆:

public partial class TangentArcPage : InteractivePage
{
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        // Draw the two lines that meet at an angle
        using (SKPath path = new SKPath())
        {
            path.MoveTo(touchPoints[0].Center);
            path.LineTo(touchPoints[1].Center);
            path.LineTo(touchPoints[2].Center);
            canvas.DrawPath(path, dottedStrokePaint);
        }

        // Draw the circle that the arc wraps around
        float radius = (float)radiusSlider.Value;

        SKPoint v1 = Normalize(touchPoints[0].Center - touchPoints[1].Center);
        SKPoint v2 = Normalize(touchPoints[2].Center - touchPoints[1].Center);

        double dotProduct = v1.X * v2.X + v1.Y * v2.Y;
        double angleBetween = Math.Acos(dotProduct);
        float hypotenuse = radius / (float)Math.Sin(angleBetween / 2);
        SKPoint vMid = Normalize(new SKPoint((v1.X + v2.X) / 2, (v1.Y + v2.Y) / 2));
        SKPoint center = new SKPoint(touchPoints[1].Center.X + vMid.X * hypotenuse,
                                     touchPoints[1].Center.Y + vMid.Y * hypotenuse);

        canvas.DrawCircle(center.X, center.Y, radius, this.strokePaint);

        // Draw the tangent arc
        using (SKPath path = new SKPath())
        {
            path.MoveTo(touchPoints[0].Center);
            path.ArcTo(touchPoints[1].Center, touchPoints[2].Center, radius);
            canvas.DrawPath(path, redStrokePaint);
        }

        foreach (TouchPoint touchPoint in touchPoints)
        {
            touchPoint.Paint(canvas);
        }
    }

    // Vector methods
    SKPoint Normalize(SKPoint v)
    {
        float magnitude = Magnitude(v);
        return new SKPoint(v.X / magnitude, v.Y / magnitude);
    }

    float Magnitude(SKPoint v)
    {
        return (float)Math.Sqrt(v.X * v.X + v.Y * v.Y);
    }
}

下面是正在运行的切线弧页面:

“切弧”页的三张屏幕截图

切线弧是创建圆角的理想选择,比如圆角矩形。 由于 SKPath 已包含 AddRoundedRect 方法,所以圆角七边形页面展示了如何使用 ArcTo 来使七边形的角变圆。 (对于任何常规多边形,代码已通用化。)

RoundedHeptagonPage 类的 PaintSurface 处理程序包含一个 for 循环,用于计算七个顶点的坐标,并通过这些顶点计算出七条边的中点。 然后,这些中点用于构造路径:

void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    float cornerRadius = 100;
    int numVertices = 7;
    float radius = 0.45f * Math.Min(info.Width, info.Height);

    SKPoint[] vertices = new SKPoint[numVertices];
    SKPoint[] midPoints = new SKPoint[numVertices];

    double vertexAngle = -0.5f * Math.PI;       // straight up

    // Coordinates of the vertices of the polygon
    for (int vertex = 0; vertex < numVertices; vertex++)
    {
        vertices[vertex] = new SKPoint(radius * (float)Math.Cos(vertexAngle),
                                       radius * (float)Math.Sin(vertexAngle));
        vertexAngle += 2 * Math.PI / numVertices;
    }

    // Coordinates of the midpoints of the sides connecting the vertices
    for (int vertex = 0; vertex < numVertices; vertex++)
    {
        int prevVertex = (vertex + numVertices - 1) % numVertices;
        midPoints[vertex] = new SKPoint((vertices[prevVertex].X + vertices[vertex].X) / 2,
                                        (vertices[prevVertex].Y + vertices[vertex].Y) / 2);
    }

    // Create the path
    using (SKPath path = new SKPath())
    {
        // Begin at the first midpoint
        path.MoveTo(midPoints[0]);

        for (int vertex = 0; vertex < numVertices; vertex++)
        {
            SKPoint nextMidPoint = midPoints[(vertex + 1) % numVertices];

            // Draws a line from the current point, and then the arc
            path.ArcTo(vertices[vertex], nextMidPoint, cornerRadius);

            // Connect the arc with the next midpoint
            path.LineTo(nextMidPoint);
        }
        path.Close();

        // Render the path in the center of the screen
        using (SKPaint paint = new SKPaint())
        {
            paint.Style = SKPaintStyle.Stroke;
            paint.Color = SKColors.Blue;
            paint.StrokeWidth = 10;

            canvas.Translate(info.Width / 2, info.Height / 2);
            canvas.DrawPath(path, paint);
        }
    }
}

下面是正在运行的程序:

“圆角七边形”页面的三张屏幕截图

椭圆弧

椭圆弧被添加到路径中,该路径调用具有两个 SKPoint 参数的 ArcTo 方法,或具有单独的 X 和 Y 坐标的 ArcTo 重载:

public void ArcTo (SKPoint r, Single xAxisRotate, SKPathArcSize largeArc, SKPathDirection sweep, SKPoint xy)

public void ArcTo (Single rx, Single ry, Single xAxisRotate, SKPathArcSize largeArc, SKPathDirection sweep, Single x, Single y)

椭圆弧与可缩放矢量图形 (SVG) 和通用 Windows 平台 ArcSegment 类中包含的椭圆弧 一致。

这些 ArcTo 方法在两个点之间,即轮廓的当前点和 ArcTo 方法的最后一个参数(xy 参数或单独的 xy 参数)之间绘制弧线:

定义椭圆弧的两个点

ArcTo 方法(r,或 rxry)的第一个点参数根本不是点,而是指定椭圆形的水平和垂直弧度;

定义椭圆弧的省略号

xAxisRotate 参数是旋转此椭圆形的顺时针度数:

定义椭圆弧的倾斜椭圆

如果将此倾斜椭圆形定位,使其与该两点相交,那么这两个点就会被两条不同的弧线连接起来:

第一组椭圆弧

这两个弧可以通过两种方式区分开来:顶部的弧比底部的弧大,并且当从左到右绘制弧时,顶部的弧是顺时针方向绘制的,而底部的弧是逆时针方向绘制的。

还可以以另一种方式将两个点之间的椭圆形拟合:

第二组椭圆弧

现在,上面有一个顺时针绘制的较小弧线,下面有一个逆时针绘制的较大弧线。

因此,这两个点可以由倾斜椭圆形定义的弧线连接,共有四种方式:

所有四个椭圆弧

这四条弧线由 ArcTo 方法的 SKPathArcSizeSKPathDirection 枚举类型参数的四种组合区分开来:

  • 红色:SKPathArcSize.Large 和 SKPathDirection.Clockwise
  • 绿色:SKPathArcSize.Small 和 SKPathDirection.Clockwise
  • 蓝色: SKPathArcSize.Small 和 SKPathDirection.CounterClockwise
  • 品红色:SKPathArcSize.Large 和 SKPathDirection.CounterClockwise

如果倾斜椭圆不够大,不适合两个点之间的大小,则会将其均匀缩放,直到足够大。 在这种情况下,只有两条唯一的弧连接这两个点。 可以使用 SKPathDirection 参数来进行区分。

虽然这种定义弧线的方法乍听之下很复杂,但它是唯一一种可以用旋转椭圆形定义弧线的方法,而且在需要将弧线与轮廓的其他部分整合时,它往往是最简单的方法。

椭圆形弧页允许以交互方式设置两个点,以及椭圆形的大小和旋转角度。 EllipticalArcPage 类派生自 InteractivePageEllipticalArcPage.xaml.cs 代码隐藏文件中的 PaintSurface 处理程序会绘制四个弧:

void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    using (SKPath path = new SKPath())
    {
        int colorIndex = 0;
        SKPoint ellipseSize = new SKPoint((float)xRadiusSlider.Value,
                                          (float)yRadiusSlider.Value);
        float rotation = (float)rotationSlider.Value;

        foreach (SKPathArcSize arcSize in Enum.GetValues(typeof(SKPathArcSize)))
            foreach (SKPathDirection direction in Enum.GetValues(typeof(SKPathDirection)))
            {
                path.MoveTo(touchPoints[0].Center);
                path.ArcTo(ellipseSize, rotation,
                           arcSize, direction,
                           touchPoints[1].Center);

                strokePaint.Color = colors[colorIndex++];
                canvas.DrawPath(path, strokePaint);
                path.Reset();
            }
    }

    foreach (TouchPoint touchPoint in touchPoints)
    {
        touchPoint.Paint(canvas);
    }
}

此处它正在运行:

“椭圆弧”页面的三张屏幕截图

弧无穷大页面使用椭圆弧绘制无穷大符号。 无穷大符号的基础是两个半径为 100 个单位的圆,中间相隔 100 个单位:

两个圆

相交的两条直线分别与两个圆相切:

具有切线的两个圆

无限符号由这些圆的部分和两条线组合而成。 要使用椭圆弧绘制无穷大符号,必须确定两条直线与圆相切的坐标。

在其中一个圆中构造一个直角矩形:

具有切线和嵌入圆的两个圆

圆的半径是 100 个单位,三角形的斜边是 150 个单位,因此角度 α 是 100 除以 150 的余弦(反正弦),即 41.8 度。 三角形的另一边长是 41.8 度的余弦值乘以 150,即 112,也可以通过勾股定理计算得出。

然后,可以使用以下信息计算切点的坐标:

x = 112·cos(41.8) = 83

y = 112·sin(41.8) = 75

只需四个切点,就能绘制出以点 (0, 0) 为中心、圆半径为 100 的无穷大符号:

具有切线和坐标的两个圆

ArcInfinityPage 类中的 PaintSurface 处理程序将无穷大符号定位在页面中心,使 (0, 0) 点位于页面中心,并根据屏幕尺寸缩放路径:

void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
    SKImageInfo info = args.Info;
    SKSurface surface = args.Surface;
    SKCanvas canvas = surface.Canvas;

    canvas.Clear();

    using (SKPath path = new SKPath())
    {
        path.LineTo(83, 75);
        path.ArcTo(100, 100, 0, SKPathArcSize.Large, SKPathDirection.CounterClockwise, 83, -75);
        path.LineTo(-83, 75);
        path.ArcTo(100, 100, 0, SKPathArcSize.Large, SKPathDirection.Clockwise, -83, -75);
        path.Close();

        // Use path.TightBounds for coordinates without control points
        SKRect pathBounds = path.Bounds;

        canvas.Translate(info.Width / 2, info.Height / 2);
        canvas.Scale(Math.Min(info.Width / pathBounds.Width,
                              info.Height / pathBounds.Height));

        using (SKPaint paint = new SKPaint())
        {
            paint.Style = SKPaintStyle.Stroke;
            paint.Color = SKColors.Blue;
            paint.StrokeWidth = 5;

            canvas.DrawPath(path, paint);
        }
    }
}

该代码使用 SKPathBounds 属性来确定无穷大正弦的尺寸,以将其缩放为画布的大小:

Arc Infinity 页面的屏幕截图

结果似乎有点小,这表明 SKPathBounds 属性报告的大小大于路径。

在内部,Skia 使用多条二次贝塞尔曲线逼近弧线。 这些曲线(在下一节中将会看到)包含控制点,这些控制点控制曲线的绘制方式,但并不是渲染曲线的一部分。 该 Bounds 属性包括这些控制点。

若要获得更紧密的拟合度,请使用排除控制点的 TightBounds 属性。 下面是在横向模式下运行的程序,并使用 TightBounds 属性获取路径边界:

带紧密边界的 Arc Infinity 页的三张屏幕截图

虽然弧线和直线之间的连接在数学上是平滑的,但从弧线到直线的改变似乎有点突然。 下一篇文章“贝塞尔曲线的三种类型”将介绍更好的无穷大符号。