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, 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 ...
此命令會將直線(或線條)新增至路徑,並將新的目前位置設定為最後一行的結尾。 您可以使用多個 x 和 y 座標組來遵循 L
命令。
水準LineTo
H x ...
此命令會將水準線新增至路徑,並將新的目前位置設定為行尾。 您可以使用多個 x 座標來追蹤H
命令,但意義不大。
垂直線
V y ...
此命令會將垂直線新增至路徑,並將新的目前位置設定為行尾。
關閉
Z
命令會 C
藉由將直線從目前位置新增至輪廓的開頭,來關閉輪廓。
ArcTo
將橢圓形弧線新增至輪廓的命令,是整個SVG路徑數據規格中最複雜的命令。 這是唯一的命令,數位可以代表座標值以外的值:
A rx ry rotation-angle large-arc-flag sweep-flag x y ...
rx 和 ry 參數是橢圓形的水準和垂直弧度。 旋轉角度以度為單位順時針方向。
將 大型弧形旗標 設定為 1,或將小弧線設定為 0。
將掃掠旗標設定為 1,以順時針順時針設定為 0。
弧線繪製到點 (x, y),這會成為新的目前位置。
CubicTo
C x1 y1 x2 y2 x3 y3 ...
此命令會將立方貝塞爾曲線從目前位置新增至 (x3, y3),這會成為新的目前位置。 點 (x1, y1) 和 (x2, y2) 是控制點。
單一 C
命令可以指定多個 Bézier 曲線。 點數必須是 3 的倍數。
還有一個「平滑」貝塞爾曲線命令:
S x2 y2 x3 y3 ...
此命令應該遵循一般的 Bézier 命令(雖然這不是絕對必要)。 Smooth Bézier 命令會計算第一個控制點,使其反映前一個貝塞爾第二個控制點在其相互點周圍。 因此,這三個點是粗線的,兩個貝塞爾曲線之間的連接是平滑的。
QuadTo
Q x1 y1 x2 y2 ...
對於二次方貝塞爾曲線,點數必須是 2 的倍數。 控制點為 (x1, y1) 和終點 (以及新的目前位置) 為 (x2, y2)
也有平滑二次曲線命令:
T x2 y2 ...
控制點是根據上一個二次曲線的控制點來計算。
所有這些命令也適用於「相對」版本,其中座標點相對於目前的位置。 這些相對命令會以小寫字母開頭,例如 c
,而不是 C
立方貝塞爾命令的相對版本。
這是 SVG 路徑資料定義的範圍。 沒有重複命令群組或執行任何類型的計算的功能。 無法使用 或 ConicTo
其他類型的 Arc 規格命令。
靜態 SKPath.ParseSvgPathData
方法需要有效的 SVG 命令字串。 如果偵測到任何語法錯誤,方法會傳 null
回 。 這是唯一的錯誤指示。
此方法 ToSvgPathData
適用於從現有 SKPath
物件取得 SVG 路徑數據,以傳送至另一個程式,或儲存在文字型檔格式,例如 XML。 (本文ToSvgPathData
中的範例程式代碼未示範 方法。請勿預期ToSvgPathData
會傳回對應至建立路徑之方法呼叫的字串。 特別是,您會發現弧線會轉換成多個 QuadTo
命令,這就是它們出現在從 ToSvgPathData
傳回的路徑數據中的方式。
Path Data 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 路徑數據時,您已看到路徑可以在欄位定義中完全指定。
「旋轉轉換」文章中先前的Ugly類比時鐘範例會將時鐘的手顯示為簡單的線條。 以下的美式模擬時鐘程式會將這些行SKPath
取代為 類別中PrettyAnalogClockPage
欄位的物件以及 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
物件,以黑色外框和灰色填滿繪製。
在先前的 醜陋類比時鐘 範例中,標記小時和分鐘的小圓圈是在迴圈中繪製的。 在這個漂亮的類比時鐘範例中,會使用完全不同的方法:小時和分鐘標記是以 和 hourMarkPaint
對象繪製minuteMarkPaint
的虛線:
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)
};
...
}
Dots 和 Dashes 文章討論了如何使用 SKPathEffect.CreateDash
方法來建立虛線。 第一個自變數是一般有兩個元素的陣列:第一個專案 float
是虛線的長度,而第二個元素則是破折號之間的間距。 StrokeCap
當 屬性設定為 SKStrokeCap.Round
時,虛線的四捨五入結尾會透過虛線兩側的筆劃寬度有效延長虛線長度。 因此,將第一個陣列元素設定為0會建立虛線。
這些點之間的距離是由第二個陣列元素所控管。 如您所見,這兩個 SKPaint
對象用來繪製半徑為90單位的圓形。 因此,這個圓形的周長為180π,這表示60分鐘標記必須每3π個單位出現一次,這是陣列minuteMarkPaint
中的float
第二個值。 12 小時標記必須每 15π 個單位出現一次,也就是第二 float
個陣列中的值。
類別會將 PrettyAnalogClockPage
定時器設定為每隔 16 毫秒使介面失效,並以 PaintSurface
該速率呼叫處理程式。 與 SKPaint
物件的先前定義SKPath
允許非常乾淨的繪圖程式代碼:
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 毫秒更新一次, Millisecond
因此值的屬性 DateTime
可能會用來以動畫顯示掃掠第二手,而不是以離散跳躍從第二個到第二個移動的指標。 但此程式代碼不允許移動順暢。 相反地,它會針對不同類型的移動使用 Xamarin.FormsSpringIn
和 SpringOut
動畫 Easing 函式。 這些 Easing 函式會讓第二手以 jerkier 的方式移動 — 在移動之前先回拉一點,然後稍微過度拍攝其目的地,但不幸的是,這些靜態螢幕快照無法重現的效果: