路径信息和枚举

获取有关路径的信息并枚举内容

SKPath 类定义多个属性和方法,可让你获取有关路径的信息。 BoundsTightBounds 属性(及相关方法)获取路径的指标维度。 Contains 方法可让你确定特定点是否在路径内。

有时,确定构成路径的所有直线和曲线的总长度很有用。 计算此长度在算法上并不是一个简单的任务,因此,为此专门设计了一个名为 PathMeasure 的整个类。

有时,获取构成路径的所有绘图操作和点也很有用。 乍看之下,此工具似乎没有必要:如果程序已创建路径,则程序已经知道其内容。 不过,你已经看到,路径也可以通过路径效果以及通过将文本字符串转换为路径来创建。 还可以获取构成这些路径的所有绘图操作和点。 一种可能性是对所有点应用算法变换,例如,将文本环绕半球:

文本包裹在半球上

获取路径长度

路径和文本一文中,你已了解如何使用 DrawTextOnPath 方法绘制基线沿着路径延伸的文本字符串。 但是,如果你要调整文本大小以使其精确适合路径,该怎么办? 围绕圆绘制文本很容易,因为圆的周长很容易计算。 但计算椭圆的周长或贝塞尔曲线的长度就不那么简单了。

SKPathMeasure 类可以提供帮助。 构造函数接受 SKPath 参数,而 Length 属性显示其长度。

此类在“路径长度”示例中进行了演示,该示例基于“贝塞尔曲线”页PathLengthPage.xaml 文件源自 InteractivePage,并包含触摸接口:

<local:InteractivePage xmlns="http://xamarin.com/schemas/2014/forms"
                       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                       xmlns:local="clr-namespace:SkiaSharpFormsDemos"
                       xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
                       xmlns:tt="clr-namespace:TouchTracking"
                       x:Class="SkiaSharpFormsDemos.Curves.PathLengthPage"
                       Title="Path Length">
    <Grid BackgroundColor="White">
        <skia:SKCanvasView x:Name="canvasView"
                           PaintSurface="OnCanvasViewPaintSurface" />
        <Grid.Effects>
            <tt:TouchEffect Capture="True"
                            TouchAction="OnTouchEffectAction" />
        </Grid.Effects>
    </Grid>
</local:InteractivePage>

PathLengthPage.xaml.cs 代码隐藏文件允许移动四个触摸点来定义三次贝塞尔曲线的终点和控制点。 三个字段定义一个文本字符串、一个 SKPaint 对象和计算出的文本宽度:

public partial class PathLengthPage : InteractivePage
{
    const string text = "Compute length of path";

    static SKPaint textPaint = new SKPaint
    {
        Style = SKPaintStyle.Fill,
        Color = SKColors.Black,
        TextSize = 10,
    };

    static readonly float baseTextWidth = textPaint.MeasureText(text);
    ...
}

baseTextWidth 字段是基于 TextSize 设置 10 的文本宽度。

PaintSurface 处理程序绘制贝塞尔曲线,然后调整文本大小以适合其整个长度:

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

    canvas.Clear();

    // Draw path with cubic Bezier curve
    using (SKPath path = new SKPath())
    {
        path.MoveTo(touchPoints[0].Center);
        path.CubicTo(touchPoints[1].Center,
                     touchPoints[2].Center,
                     touchPoints[3].Center);

        canvas.DrawPath(path, strokePaint);

        // Get path length
        SKPathMeasure pathMeasure = new SKPathMeasure(path, false, 1);

        // Find new text size
        textPaint.TextSize = pathMeasure.Length / baseTextWidth * 10;

        // Draw text on path
        canvas.DrawTextOnPath(text, path, 0, 0, textPaint);
    }
    ...
}

新建的 SKPathMeasure 对象的 Length 属性获取路径的长度。 路径长度除以 baseTextWidth 值(基于文本大小 10 的文本宽度),然后乘以基本文本大小 10。 结果是沿该路径显示文本的新文本大小:

“路径长度”页的三屏幕截图

随着贝塞尔曲线变长或变短,可以看到文本大小发生变化。

穿越路径

SKPathMeasure 不仅仅可以度量路径的长度。 对于零和路径长度之间的任何值,SKPathMeasure 对象可以获取路径上的位置以及该点处路径曲线的切线。 切线可用作 SKPoint 对象形式的矢量,或用作封装在 SKMatrix 对象中的旋转。 下面是 SKPathMeasure 的方法,它们以多种灵活方式获取此信息:

Boolean GetPosition (Single distance, out SKPoint position)

Boolean GetTangent (Single distance, out SKPoint tangent)

Boolean GetPositionAndTangent (Single distance, out SKPoint position, out SKPoint tangent)

Boolean GetMatrix (Single distance, out SKMatrix matrix, SKPathMeasureMatrixFlags flag)

SKPathMeasureMatrixFlags 枚举的成员是:

  • GetPosition
  • GetTangent
  • GetPositionAndTangent

“独轮车半管”页为独轮车上的简笔画人物制作了动画,该人物似乎沿着三次贝塞尔曲线来回骑行

“独轮车半管”页的三屏幕截图

用于抚摸半管和独轮车的 SKPaint 对象定义为 UnicycleHalfPipePage 类中的字段。 此外,还定义了独轮车的 SKPath 对象:

public class UnicycleHalfPipePage : ContentPage
{
    ...
    SKPaint strokePaint = new SKPaint
    {
        Style = SKPaintStyle.Stroke,
        StrokeWidth = 3,
        Color = SKColors.Black
    };

    SKPath unicyclePath = SKPath.ParseSvgPathData(
        "M 0 0" +
        "A 25 25 0 0 0 0 -50" +
        "A 25 25 0 0 0 0 0 Z" +
        "M 0 -25 L 0 -100" +
        "A 15 15 0 0 0 0 -130" +
        "A 15 15 0 0 0 0 -100 Z" +
        "M -25 -85 L 25 -85");
    ...
}

该类包含动画的 OnAppearingOnDisappearing 方法的标准重写。 PaintSurface 处理程序创建半管路径,然后绘制它。 然后根据此路径创建一个 SKPathMeasure 对象:

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

        canvas.Clear();

        using (SKPath pipePath = new SKPath())
        {
            pipePath.MoveTo(50, 50);
            pipePath.CubicTo(0, 1.25f * info.Height,
                             info.Width - 0, 1.25f * info.Height,
                             info.Width - 50, 50);

            canvas.DrawPath(pipePath, strokePaint);

            using (SKPathMeasure pathMeasure = new SKPathMeasure(pipePath))
            {
                float length = pathMeasure.Length;

                // Animate t from 0 to 1 every three seconds
                TimeSpan timeSpan = new TimeSpan(DateTime.Now.Ticks);
                float t = (float)(timeSpan.TotalSeconds % 5 / 5);

                // t from 0 to 1 to 0 but slower at beginning and end
                t = (float)((1 - Math.Cos(t * 2 * Math.PI)) / 2);

                SKMatrix matrix;
                pathMeasure.GetMatrix(t * length, out matrix,
                                      SKPathMeasureMatrixFlags.GetPositionAndTangent);

                canvas.SetMatrix(matrix);
                canvas.DrawPath(unicyclePath, strokePaint);
            }
        }
    }
}

PaintSurface 处理程序每五秒计算一次从 0 到 1 的 t 值。 然后,它使用 Math.Cos 函数将其转换为范围 0 到 1 再返回到 0 的 t 值,其中 0 对应于左上角开头的独轮车,而 1 对应于位于右上角的独轮车。 余弦函数导致管道顶部速度最慢,底部速度最快。

请注意,t 的值必须乘以 GetMatrix 的第一个参数的路径长度。 然后将该矩阵应用于 SKCanvas 对象以绘制独轮车路径。

枚举路径

使用 SKPath 的两个嵌入类可以枚举路径的内容。 这些类是 SKPath.IteratorSKPath.RawIterator。 这两个类非常相似,但 SKPath.Iterator 可以消除路径中长度为零或接近零长度的元素。 以下示例中使用了 RawIterator

可以通过调用 SKPathCreateRawIterator 方法来获取类型为 SKPath.RawIterator 的对象。 枚举路径是通过重复调用 Next 方法来完成的。 向其传递一个包含四个 SKPoint 值的数组:

SKPoint[] points = new SKPoint[4];
...
SKPathVerb pathVerb = rawIterator.Next(points);

Next 方法返回 SKPathVerb 枚举类型的成员。 这些值指示路径中的特定绘图命令。 插入数组中的有效点的数量取决于该谓词:

  • 包含一个点的 Move
  • 包含两个点的 Line
  • 包含四个点的 Cubic
  • 包含三个点的 Quad
  • 包含三个点的 Conic(并且还调用 ConicWeight 方法以获取权重)
  • 包含一个点的 Close
  • Done

Done 谓词指示路径枚举已完成。

请注意没有 Arc 谓词。 这表示所有圆弧在添加到路径时都会转换为贝塞尔曲线。

SKPoint 数组中的某些信息是多余的。 例如,如果 Move 谓词后接 Line 谓词,则伴随 Line 的两个点中的第一个点与 Move 点相同。 实际上,这种冗余非常有帮助。 当你获得 Cubic 谓词时,它会伴随着定义三次贝塞尔曲线的所有四个点。 不需要保留前一个谓词建立的当前位置。

但是,有问题的谓词是 Close。 此命令绘制一条从当前位置连接先前通过 Move 命令建立的轮廓的起点的直线。 理想情况下,Close 谓词应提供这两个点,而不仅仅是一个点。 更糟糕的是,Close 谓词伴随的点始终是 (0, 0)。 当枚举路径时,可能需要保留 Move 点和当前位置。

枚举、平展和变形

有时需要对路径应用算法变换,以某种方式对其进行变形:

文本包裹在半球上

这些字母大多由直线组成,但这些直线显然已被扭曲成曲线。 那么如何做好这一点呢?

关键在于,原始直线被分解成一系列更小的直线。 然后能够以不同的方式操控这些单独的较小直线以形成曲线。

为了便于完成这一过程,示例包含一个静态 PathExtensions 类,该类有一个 Interpolate 方法,该方法将一条直线分解为许多短线,这些短线仅有一个单元的长度。 此外,该类还包含多种方法,可将三种类型的贝塞尔曲线转换为一系列近似该曲线的微小直线。 (参数公式在文章三种类型的贝塞尔曲线中做了介绍。)此过程称为平展曲线

static class PathExtensions
{
    ...
    static SKPoint[] Interpolate(SKPoint pt0, SKPoint pt1)
    {
        int count = (int)Math.Max(1, Length(pt0, pt1));
        SKPoint[] points = new SKPoint[count];

        for (int i = 0; i < count; i++)
        {
            float t = (i + 1f) / count;
            float x = (1 - t) * pt0.X + t * pt1.X;
            float y = (1 - t) * pt0.Y + t * pt1.Y;
            points[i] = new SKPoint(x, y);
        }

        return points;
    }

    static SKPoint[] FlattenCubic(SKPoint pt0, SKPoint pt1, SKPoint pt2, SKPoint pt3)
    {
        int count = (int)Math.Max(1, Length(pt0, pt1) + Length(pt1, pt2) + Length(pt2, pt3));
        SKPoint[] points = new SKPoint[count];

        for (int i = 0; i < count; i++)
        {
            float t = (i + 1f) / count;
            float x = (1 - t) * (1 - t) * (1 - t) * pt0.X +
                        3 * t * (1 - t) * (1 - t) * pt1.X +
                        3 * t * t * (1 - t) * pt2.X +
                        t * t * t * pt3.X;
            float y = (1 - t) * (1 - t) * (1 - t) * pt0.Y +
                        3 * t * (1 - t) * (1 - t) * pt1.Y +
                        3 * t * t * (1 - t) * pt2.Y +
                        t * t * t * pt3.Y;
            points[i] = new SKPoint(x, y);
        }

        return points;
    }

    static SKPoint[] FlattenQuadratic(SKPoint pt0, SKPoint pt1, SKPoint pt2)
    {
        int count = (int)Math.Max(1, Length(pt0, pt1) + Length(pt1, pt2));
        SKPoint[] points = new SKPoint[count];

        for (int i = 0; i < count; i++)
        {
            float t = (i + 1f) / count;
            float x = (1 - t) * (1 - t) * pt0.X + 2 * t * (1 - t) * pt1.X + t * t * pt2.X;
            float y = (1 - t) * (1 - t) * pt0.Y + 2 * t * (1 - t) * pt1.Y + t * t * pt2.Y;
            points[i] = new SKPoint(x, y);
        }

        return points;
    }

    static SKPoint[] FlattenConic(SKPoint pt0, SKPoint pt1, SKPoint pt2, float weight)
    {
        int count = (int)Math.Max(1, Length(pt0, pt1) + Length(pt1, pt2));
        SKPoint[] points = new SKPoint[count];

        for (int i = 0; i < count; i++)
        {
            float t = (i + 1f) / count;
            float denominator = (1 - t) * (1 - t) + 2 * weight * t * (1 - t) + t * t;
            float x = (1 - t) * (1 - t) * pt0.X + 2 * weight * t * (1 - t) * pt1.X + t * t * pt2.X;
            float y = (1 - t) * (1 - t) * pt0.Y + 2 * weight * t * (1 - t) * pt1.Y + t * t * pt2.Y;
            x /= denominator;
            y /= denominator;
            points[i] = new SKPoint(x, y);
        }

        return points;
    }

    static double Length(SKPoint pt0, SKPoint pt1)
    {
        return Math.Sqrt(Math.Pow(pt1.X - pt0.X, 2) + Math.Pow(pt1.Y - pt0.Y, 2));
    }
}

所有这些方法均从扩展方法 CloneWithTransform 引用,该扩展方法也包含在此类中,如下所示。 该方法通过枚举路径命令并根据数据构造新路径来克隆路径。 但是,新路径仅包含 MoveToLineTo 调用。 所有曲线和直线都被简化为一系列微小线条。

调用 CloneWithTransform 时,请向该方法传递一个 Func<SKPoint, SKPoint>,它是一个带有 SKPaint 参数的函数,可返回 SKPoint 值。 每个点都会调用此函数以应用自定义算法变换:

static class PathExtensions
{
    public static SKPath CloneWithTransform(this SKPath pathIn, Func<SKPoint, SKPoint> transform)
    {
        SKPath pathOut = new SKPath();

        using (SKPath.RawIterator iterator = pathIn.CreateRawIterator())
        {
            SKPoint[] points = new SKPoint[4];
            SKPathVerb pathVerb = SKPathVerb.Move;
            SKPoint firstPoint = new SKPoint();
            SKPoint lastPoint = new SKPoint();

            while ((pathVerb = iterator.Next(points)) != SKPathVerb.Done)
            {
                switch (pathVerb)
                {
                    case SKPathVerb.Move:
                        pathOut.MoveTo(transform(points[0]));
                        firstPoint = lastPoint = points[0];
                        break;

                    case SKPathVerb.Line:
                        SKPoint[] linePoints = Interpolate(points[0], points[1]);

                        foreach (SKPoint pt in linePoints)
                        {
                            pathOut.LineTo(transform(pt));
                        }

                        lastPoint = points[1];
                        break;

                    case SKPathVerb.Cubic:
                        SKPoint[] cubicPoints = FlattenCubic(points[0], points[1], points[2], points[3]);

                        foreach (SKPoint pt in cubicPoints)
                        {
                            pathOut.LineTo(transform(pt));
                        }

                        lastPoint = points[3];
                        break;

                    case SKPathVerb.Quad:
                        SKPoint[] quadPoints = FlattenQuadratic(points[0], points[1], points[2]);

                        foreach (SKPoint pt in quadPoints)
                        {
                            pathOut.LineTo(transform(pt));
                        }

                        lastPoint = points[2];
                        break;

                    case SKPathVerb.Conic:
                        SKPoint[] conicPoints = FlattenConic(points[0], points[1], points[2], iterator.ConicWeight());

                        foreach (SKPoint pt in conicPoints)
                        {
                            pathOut.LineTo(transform(pt));
                        }

                        lastPoint = points[2];
                        break;

                    case SKPathVerb.Close:
                        SKPoint[] closePoints = Interpolate(lastPoint, firstPoint);

                        foreach (SKPoint pt in closePoints)
                        {
                            pathOut.LineTo(transform(pt));
                        }

                        firstPoint = lastPoint = new SKPoint(0, 0);
                        pathOut.Close();
                        break;
                }
            }
        }
        return pathOut;
    }
    ...
}

由于克隆的路径被简化为微小直线,因此变换函数具有将直线转换为曲线的功能。

请注意,该方法将每个轮廓的第一个点保留在名为 firstPoint 的变量中,并将每个绘图命令之后的当前位置保留在变量 lastPoint 中。 遇到 Close 谓词时,这些变量是生成最终结束线条所必需的。

GlobularText 示例使用此扩展方法以 3D 效果大致地将文本环绕在半球周围

“球形文本”页的三屏幕截图

GlobularTextPage 类构造函数执行此变换。 它为文本创建一个 SKPaint 对象,然后从 GetTextPath 方法获取一个 SKPath 对象。 这是与变换函数一起传递给 CloneWithTransform 扩展方法的路径:

public class GlobularTextPage : ContentPage
{
    SKPath globePath;

    public GlobularTextPage()
    {
        Title = "Globular Text";

        SKCanvasView canvasView = new SKCanvasView();
        canvasView.PaintSurface += OnCanvasViewPaintSurface;
        Content = canvasView;

        using (SKPaint textPaint = new SKPaint())
        {
            textPaint.Typeface = SKTypeface.FromFamilyName("Times New Roman");
            textPaint.TextSize = 100;

            using (SKPath textPath = textPaint.GetTextPath("HELLO", 0, 0))
            {
                SKRect textPathBounds;
                textPath.GetBounds(out textPathBounds);

                globePath = textPath.CloneWithTransform((SKPoint pt) =>
                {
                    double longitude = (Math.PI / textPathBounds.Width) *
                                            (pt.X - textPathBounds.Left) - Math.PI / 2;
                    double latitude = (Math.PI / textPathBounds.Height) *
                                            (pt.Y - textPathBounds.Top) - Math.PI / 2;

                    longitude *= 0.75;
                    latitude *= 0.75;

                    float x = (float)(Math.Cos(latitude) * Math.Sin(longitude));
                    float y = (float)Math.Sin(latitude);

                    return new SKPoint(x, y);
                });
            }
        }
    }
    ...
}

变换函数首先计算名为 longitudelatitude 的两个值,其范围为从文本顶部和左侧的 –π/2 到文本右侧和底部的 π/2。 这些值的范围在视觉上不令人满意,因此通过乘以 0.75 来减小它们。 (请尝试使用不进行这些调整的代码。文本在北极和南极变得太模糊,而在两侧变得太细。)这些三维球体坐标通过标准公式转换为二维 xy 坐标。

新路径存储为字段。 然后,PaintSurface 处理程序只需将路径居中并缩放即可将其显示在屏幕上:

public class GlobularTextPage : ContentPage
{
    SKPath globePath;
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        using (SKPaint pathPaint = new SKPaint())
        {
            pathPaint.Style = SKPaintStyle.Fill;
            pathPaint.Color = SKColors.Blue;
            pathPaint.StrokeWidth = 3;
            pathPaint.IsAntialias = true;

            canvas.Translate(info.Width / 2, info.Height / 2);
            canvas.Scale(0.45f * Math.Min(info.Width, info.Height));     // radius
            canvas.DrawPath(globePath, pathPaint);
        }
    }
}

这是一种非常通用的技术。 如果路径效果一文中所述的一系列路径效果没有完全包含你认为应该包含的内容,则这是一种填补空白的方法。