SkiaSharp 中的 SVG 路径数据
使用可缩放矢量图形格式的文本字符串定义路径
SKPath
类支持从采用可缩放矢量图形 (SVG) 规范建立的格式的文本字符串定义整个路径对象。 本文稍后将介绍如何在文本字符串中表示整个路径,如下所示:
SVG 是适用于网页的基于 XML 的图形编程语言。 由于 SVG 必须允许在标记中定义路径而不是一系列函数调用,因此 SVG 标准包含一种极其简洁的方法,用于将整个图形路径指定为文本字符串。
在 SkiaSharp 中,此格式称为“SVG 路径数据”。基于 Windows XAML 的编程环境(包括 Windows Presentation Foundation 和通用 Windows 平台)也支持该格式,在其中称为路径标记语法或移动和绘制命令语法。 它还可以用作矢量图形图像的交换格式,特别是在基于文本的文件(例如 XML)中。
SKPath
类定义两个方法,这些方法的名称中包含单词 SvgPathData
:
public static SKPath ParseSvgPathData(string svgPath)
public string ToSvgPathData()
静态 ParseSvgPathData
方法将字符串转换为 SKPath
对象,而 ToSvgPathData
将 SKPath
对象转换为字符串。
下面是一个以点 (0, 0) 为中心、以 100 为半径的五角星的 SVG 字符串:
"M 0 -100 L 58.8 90.9, -95.1 -30.9, 95.1 -30.9, -58.8 80.9 Z"
这些字母是生成 SKPath
对象的命令:M
表示 MoveTo
调用,L
是 LineTo
,Z
是 Close
,用于闭合轮廓。 每个数字对提供某个点的 X 和 Y 坐标。 请注意,L
命令后接多个以逗号分隔的点。 在一系列坐标和点中,逗号和空格的处理方式相同。 某些程序员偏好在 X 和 Y 坐标之间放置逗号,而不是在点之间放置逗号,但逗号或空格只是为了避免歧义。 这是完全合法的:
"M0-100L58.8 90.9-95.1-30.9 95.1-30.9-58.8 80.9Z"
SVG 路径数据的语法正式记录在 SVG 规范的第 8.3 节中。 下面是摘要:
MoveTo
M x y
通过设置当前位置在路径中开始创建新轮廓。 路径数据应始终以 M
命令开头。
LineTo
L x y ...
此命令向路径添加一条或多条直线,并将新的当前位置设置为最后一条线的末尾。 可以在 L
命令后面添加多对 x 和 y 坐标。
Horizontal LineTo
H x ...
此命令向路径添加一条水平线,并将新的当前位置设置为该线的末尾。 可以在 H
命令后面添加多个 x 坐标,但这没有多大意义。
Vertical Line
V y ...
此命令向路径添加一条垂直线,并将新的当前位置设置为该线的末尾。
Close
Z
C
命令通过添加从当前位置到轮廓起点的直线来闭合轮廓。
ArcTo
向轮廓添加椭圆弧的命令是迄今为止整个 SVG 路径数据规范中最复杂的命令。 它是可以用数字表示除坐标值以外的值的唯一命令:
A rx ry rotation-angle large-arc-flag sweep-flag x y ...
rx 和 ry 参数是椭圆的水平和垂直半径。 rotation-angle 以顺时针角度为单位。
将大弧和小弧的 large-arc-flag 分别设置为 1 和 0。
将 sweep-flag 设置为 1(顺时针)和 0(逆时针)。
依照点 (x, y)(成为新的当前位置)画弧。
CubicTo
C x1 y1 x2 y2 x3 y3 ...
此命令添加从当前位置到 (x3, y3)(成为新的当前位置)的三次贝塞尔曲线。 点 (x1, y1) 和 (x2, y2) 是控制点。
可以通过单个 C
命令指定多条贝塞尔曲线。 点数必须是 3 的倍数。
还有一个“平滑”贝塞尔曲线命令:
S x2 y2 x3 y3 ...
此命令应遵循常规贝塞尔曲线命令(尽管不严格要求)。 平滑贝塞尔曲线命令计算第一个控制点,使其成为前一个贝塞尔曲线的第二个控制点围绕共同点的反射。 因此,这三个点是共线的,两条贝塞尔曲线之间的连接是平滑的。
QuadTo
Q x1 y1 x2 y2 ...
对于二次贝塞尔曲线,点数必须是 2 的倍数。 控制点为 (x1, y1),终点(和新的当前位置)为 (x2, y2)
还有一个平滑二次曲线命令:
T x2 y2 ...
控制点是根据之前二次曲线的控制点计算出的。
所有这些命令也可用于“相对”版本,其中坐标点相对于当前位置。 这些相对命令以小写字母开头,例如三次贝塞尔曲线命令的相对版本是 c
而不是 C
。
这是 SVG 路径数据定义的范围。 没有用于重复命令组或执行任何类型的计算的工具。 用于 ConicTo
或其他类型的弧规范的命令不可用。
静态 SKPath.ParseSvgPathData
方法需要有效的 SVG 命令字符串。 如果检测到任何语法错误,该方法将返回 null
。 这是唯一的错误指示。
ToSvgPathData
方法可以方便地从现有 SKPath
对象获取 SVG 路径数据以传输到另一个程序,或以基于文本的文件格式(例如 XML)存储。 (本文的示例代码未演示 ToSvgPathData
方法。)不要期望 ToSvgPathData
返回与创建路径的方法调用完全对应的字符串。 具体而言,你会发现,弧已转换为多个 QuadTo
命令,这就是它们在从 ToSvgPathData
返回的路径数据中的显示方式。
“路径数据 Hello”页使用 SVG 路径数据拼写出单词“HELLO”。 SKPath
和 SKPaint
对象都定义为 PathDataHelloPage
类中的字段:
public class PathDataHelloPage : ContentPage
{
SKPath helloPath = SKPath.ParseSvgPathData(
"M 0 0 L 0 100 M 0 50 L 50 50 M 50 0 L 50 100" + // H
"M 125 0 C 60 -10, 60 60, 125 50, 60 40, 60 110, 125 100" + // E
"M 150 0 L 150 100, 200 100" + // L
"M 225 0 L 225 100, 275 100" + // L
"M 300 50 A 25 50 0 1 0 300 49.9 Z"); // O
SKPaint paint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Blue,
StrokeWidth = 10,
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round
};
...
}
定义文本字符串的路径从左上角的点 (0, 0) 开始。 每个字母的宽度为 50 个单位,高度为 100 个单位,字母之间相隔 25 个单位,这意味着整个路径的宽度为 350 个单位。
“Hello”的“H”由三个单线轮廓组成,而“E”则是两条相连的三次贝塞尔曲线。 请注意,C
命令后跟六个点,其中两个控制点的 Y 坐标分别为 –10 和 110,这使得它们超出了其他字母的 Y 坐标范围。 “L”是两条相连的线,而“O”是使用 A
命令渲染的椭圆。
请注意,开始创建最后一个轮廓的 M
命令将位置设置为点 (350, 50),该点是“O”左侧的垂直中心。 如 A
命令后面的第一个数字所示,椭圆的水平半径为 25,垂直半径为 50。 终点由 A
命令中的最后一对数字表示,代表点 (300, 49.9)。 我们有意地将其设置为与起点略有不同。 如果将终点设置为等于起点,则不会渲染弧。 若要绘制完整的椭圆,必须将终点设置为接近(但不等于)起点,或者必须使用两个或更多个 A
命令,每个命令对应于完整椭圆的一部分。
可以将以下语句添加到页的构造函数中,然后设置断点来检查生成的字符串:
string str = helloPath.ToSvgPathData();
你会发现,弧已替换为一长串的 Q
命令,以便使用二次贝塞尔曲线计算弧的分段近似值。
PaintSurface
处理程序获取路径的严格边界,其中不包括“E”和“O”曲线的控制点。 这三个变换将路径的中心移动到点 (0, 0),将路径缩放为画布大小(但同时考虑了笔划宽度),然后将路径的中心移动到画布的中心:
public class PathDataHelloPage : ContentPage
{
...
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
SKRect bounds;
helloPath.GetTightBounds(out bounds);
canvas.Translate(info.Width / 2, info.Height / 2);
canvas.Scale(info.Width / (bounds.Width + paint.StrokeWidth),
info.Height / (bounds.Height + paint.StrokeWidth));
canvas.Translate(-bounds.MidX, -bounds.MidY);
canvas.DrawPath(helloPath, paint);
}
}
路径填充了画布,在横向模式下查看时更合理:
“猫路径数据”页与此类似。 路径和绘制对象均定义为 PathDataCatPage
类中的字段:
public class PathDataCatPage : ContentPage
{
SKPath catPath = SKPath.ParseSvgPathData(
"M 160 140 L 150 50 220 103" + // Left ear
"M 320 140 L 330 50 260 103" + // Right ear
"M 215 230 L 40 200" + // Left whiskers
"M 215 240 L 40 240" +
"M 215 250 L 40 280" +
"M 265 230 L 440 200" + // Right whiskers
"M 265 240 L 440 240" +
"M 265 250 L 440 280" +
"M 240 100" + // Head
"A 100 100 0 0 1 240 300" +
"A 100 100 0 0 1 240 100 Z" +
"M 180 170" + // Left eye
"A 40 40 0 0 1 220 170" +
"A 40 40 0 0 1 180 170 Z" +
"M 300 170" + // Right eye
"A 40 40 0 0 1 260 170" +
"A 40 40 0 0 1 300 170 Z");
SKPaint paint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Orange,
StrokeWidth = 5
};
...
}
猫头是一个圆,此处用两个 A
命令渲染,每个命令绘制一个半圆。 针对头部的两个 A
命令都将水平和垂直半径定义为 100。 第一条弧线从 (240, 100) 开始,在 (240, 300)(成为第二条弧线的起点)处结束,而第二条弧线又在 (240, 100) 处结束。
两只眼睛也使用两个 A
命令进行渲染,与猫头一样,第二个 A
命令在与第一个 A
命令的起点相同的点处结束。 但是,这些 A
命令对不会定义椭圆。 每条弧的长度为 40 个单位,半径也为 40 个单位,这意味着这些弧不是完整的半圆。
PaintSurface
处理程序执行与上一个示例类似的变换,但设置单个 Scale
因子来保持宽高比并提供少量的边距,以便猫的胡须不会接触屏幕的两侧:
public class PathDataCatPage : ContentPage
{
...
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.Black);
SKRect bounds;
catPath.GetBounds(out bounds);
canvas.Translate(info.Width / 2, info.Height / 2);
canvas.Scale(0.9f * Math.Min(info.Width / bounds.Width,
info.Height / bounds.Height));
canvas.Translate(-bounds.MidX, -bounds.MidY);
canvas.DrawPath(catPath, paint);
}
}
下面是正在运行的程序:
通常,将 SKPath
对象定义为字段时,必须在构造函数或其他方法中定义路径的轮廓。 但是,在使用 SVG 路径数据时,你会发现路径完全可以在字段定义中指定。
前面在旋转变换一文中介绍的“丑陋的模拟时钟”示例将时钟指针显示为简单线条。 以下“漂亮的模拟时钟”程序将这些线条替换为定义为 PrettyAnalogClockPage
类中的字段的 SKPath
对象以及 SKPaint
对象:
public class PrettyAnalogClockPage : ContentPage
{
...
// Clock hands pointing straight up
SKPath hourHandPath = SKPath.ParseSvgPathData(
"M 0 -60 C 0 -30 20 -30 5 -20 L 5 0" +
"C 5 7.5 -5 7.5 -5 0 L -5 -20" +
"C -20 -30 0 -30 0 -60 Z");
SKPath minuteHandPath = SKPath.ParseSvgPathData(
"M 0 -80 C 0 -75 0 -70 2.5 -60 L 2.5 0" +
"C 2.5 5 -2.5 5 -2.5 0 L -2.5 -60" +
"C 0 -70 0 -75 0 -80 Z");
SKPath secondHandPath = SKPath.ParseSvgPathData(
"M 0 10 L 0 -80");
// SKPaint objects
SKPaint handStrokePaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Black,
StrokeWidth = 2,
StrokeCap = SKStrokeCap.Round
};
SKPaint handFillPaint = new SKPaint
{
Style = SKPaintStyle.Fill,
Color = SKColors.Gray
};
...
}
时针和分针现在具有封闭区域。 为了使这些指针彼此不同,我们使用 handStrokePaint
和 handFillPaint
对象以黑色轮廓和灰色填充绘制了它们。
在前面的“丑陋的模拟时钟”示例中,标记了小时和分钟的小圆被绘制成一个圆圈。 在“漂亮的模拟时钟”示例中,使用了一种完全不同的方法:小时和分钟标记是用 minuteMarkPaint
和 hourMarkPaint
对象绘制的虚线:
public class PrettyAnalogClockPage : ContentPage
{
...
SKPaint minuteMarkPaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Black,
StrokeWidth = 3,
StrokeCap = SKStrokeCap.Round,
PathEffect = SKPathEffect.CreateDash(new float[] { 0, 3 * 3.14159f }, 0)
};
SKPaint hourMarkPaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Black,
StrokeWidth = 6,
StrokeCap = SKStrokeCap.Round,
PathEffect = SKPathEffect.CreateDash(new float[] { 0, 15 * 3.14159f }, 0)
};
...
}
点和虚线一文讨论了如何使用 SKPathEffect.CreateDash
方法创建虚线。 第一个参数是一个 float
数组,它通常有两个元素:第一个元素是虚线的长度,第二个元素是虚线之间的间隙。 当 StrokeCap
属性设置为 SKStrokeCap.Round
时,虚线的圆角末端会有效地将虚线长度延长为虚线两侧的笔画宽度。 因此,将第一个数组元素设置为 0 会创建一条虚线。
这些点之间的距离由第二个数组元素控制。 很快你就会看到,这两个 SKPaint
对象用于绘制半径为 90 个单位的圆。 因此,该圆的周长为 180π,这意味着 60 分钟标记必须每隔 3π 个单位(这是 minuteMarkPaint
中 float
数组中的第二个值)出现一次。 12 小时标记必须每隔 15π 个单位(这是第二个 float
数组中的值)出现一次。
PrettyAnalogClockPage
类设置一个计时器,每隔 16 毫秒使表面失效,并以该频率调用 PaintSurface
处理程序。 前面的 SKPath
和 SKPaint
对象定义允许使用非常干净的绘图代码:
public class PrettyAnalogClockPage : ContentPage
{
...
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
// Transform for 100-radius circle in center
canvas.Translate(info.Width / 2, info.Height / 2);
canvas.Scale(Math.Min(info.Width / 200, info.Height / 200));
// Draw circles for hour and minute marks
SKRect rect = new SKRect(-90, -90, 90, 90);
canvas.DrawOval(rect, minuteMarkPaint);
canvas.DrawOval(rect, hourMarkPaint);
// Get time
DateTime dateTime = DateTime.Now;
// Draw hour hand
canvas.Save();
canvas.RotateDegrees(30 * dateTime.Hour + dateTime.Minute / 2f);
canvas.DrawPath(hourHandPath, handStrokePaint);
canvas.DrawPath(hourHandPath, handFillPaint);
canvas.Restore();
// Draw minute hand
canvas.Save();
canvas.RotateDegrees(6 * dateTime.Minute + dateTime.Second / 10f);
canvas.DrawPath(minuteHandPath, handStrokePaint);
canvas.DrawPath(minuteHandPath, handFillPaint);
canvas.Restore();
// Draw second hand
double t = dateTime.Millisecond / 1000.0;
if (t < 0.5)
{
t = 0.5 * Easing.SpringIn.Ease(t / 0.5);
}
else
{
t = 0.5 * (1 + Easing.SpringOut.Ease((t - 0.5) / 0.5));
}
canvas.Save();
canvas.RotateDegrees(6 * (dateTime.Second + (float)t));
canvas.DrawPath(secondHandPath, handStrokePaint);
canvas.Restore();
}
}
但是,对于秒针则执行了一些特别的操作。 由于时钟每隔 16 毫秒更新一次,因此 DateTime
值的 Millisecond
属性可用于动画显示扫动式秒针,而不是每秒离散跳跃移动的秒针。 但此代码不能实现流畅运动。 相反,它使用 Xamarin.FormsSpringIn
和 SpringOut
动画缓动函数来实现不同类型的运动。 这些缓动函数会导致秒针以一种急躁的方式移动 – 在移动之前向后拉一点,然后稍微超出其目标,遗憾的是,这种效果无法在这些静态屏幕截图中重现: