按路径和区域进行剪裁

使用路径将图形剪辑到特定区域,并创建区域

有时有必要将图形的渲染限制在特定区域内。 这称为剪裁。 可以将剪裁用于特殊效果,例如这张通过钥匙孔看到猴子的图像:

通过钥匙孔看到的猴子

剪裁区域是渲染图形的屏幕区域。 在剪裁区域之外显示的任何内容都不会进行渲染。 剪裁区域通常由矩形或 SKPath 对象定义,但也可以使用 SKRegion 对象定义剪裁区域。 这两种类型的对象乍一看似乎相关,因为可以从路径创建区域。 但是,无法从区域创建路径,并且它们在内部大相径庭:路径包含一系列线条和曲线,而区域由一系列水平扫描线定义。

上图是由通过钥匙孔看到猴子页创建的。 MonkeyThroughKeyholePage 类使用 SVG 数据定义路径,并使用构造函数从程序资源加载位图:

public class MonkeyThroughKeyholePage : ContentPage
{
    SKBitmap bitmap;
    SKPath keyholePath = SKPath.ParseSvgPathData(
        "M 300 130 L 250 350 L 450 350 L 400 130 A 70 70 0 1 0 300 130 Z");

    public MonkeyThroughKeyholePage()
    {
        Title = "Monkey through Keyhole";

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

        string resourceID = "SkiaSharpFormsDemos.Media.SeatedMonkey.jpg";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        {
            bitmap = SKBitmap.Decode(stream);
        }
    }
    ...
}

尽管 keyholePath 对象描述了钥匙孔的轮廓,但坐标是完全任意的,反映的是设计路径数据时的便利性。 出于此原因,PaintSurface 处理程序会获取此路径的边界并调用 TranslateScale 以将路径移动到屏幕中心,使其几乎与屏幕一样高:

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

        canvas.Clear();

        // Set transform to center and enlarge clip path to window height
        SKRect bounds;
        keyholePath.GetTightBounds(out bounds);

        canvas.Translate(info.Width / 2, info.Height / 2);
        canvas.Scale(0.98f * info.Height / bounds.Height);
        canvas.Translate(-bounds.MidX, -bounds.MidY);

        // Set the clip path
        canvas.ClipPath(keyholePath);

        // Reset transforms
        canvas.ResetMatrix();

        // Display monkey to fill height of window but maintain aspect ratio
        canvas.DrawBitmap(bitmap,
            new SKRect((info.Width - info.Height) / 2, 0,
                       (info.Width + info.Height) / 2, info.Height));
    }
}

但路径未经渲染。 相反,在转换之后,该路径用于使用此语句设置剪裁区域:

canvas.ClipPath(keyholePath);

然后,PaintSurface 处理程序会通过调用 ResetMatrix 重置转换,并绘制位图以扩展到屏幕的完整高度。 此代码假定位图为正方形,此特定位图也正是正方形。 位图仅在剪切路径定义的区域内呈现:

“通过钥匙孔看到的猴子”页面的三重屏幕截图

在调用 ClipPath 方法时,剪切路径受转换的影响,而不是在显示图形对象(如位图)时生效的转换。 剪切路径是画布状态的一部分,该状态使用 Save 方法保存,并使用 Restore 方法还原。

组合剪切路径

严格地说,剪裁区域不是由 ClipPath 方法“设置”的。 相反,它与现有的剪切路径相结合,该路径以与画布大小相等的矩形开头。 可以使用 LocalClipBounds 属性或 DeviceClipBounds 属性获取剪裁区域的矩形边界。 LocalClipBounds 属性会返回一个 SKRect 值,该值反映可能生效的任何转换。 DeviceClipBounds 属性会返回一个 RectI 值。 这是一个具有整数尺寸的矩形,描述实际像素维度中的剪裁区域。

任何对 ClipPath 的调用都会通过将剪裁区域与新区域组合来减少剪裁区域。 将剪裁区域与矩形组合在一起的 ClipPath 方法的完整语法:

public Void ClipRect(SKRect rect, SKClipOperation operation = SKClipOperation.Intersect, Boolean antialias = false);

默认情况下,生成的剪裁区域是现有剪裁区域的交集,也是 ClipPathClipRect 方法中指定的 SKPathSKRect。 这在四个圆相交剪裁页中演示。 FourCircleInteresectClipPage 类中的 PaintSurface 处理程序重复使用相同的 SKPath 对象来创建四个重叠圆圈,每个对象通过连续调用 ClipPath 来减少剪裁区域:

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

    canvas.Clear();

    float size = Math.Min(info.Width, info.Height);
    float radius = 0.4f * size;
    float offset = size / 2 - radius;

    // Translate to center
    canvas.Translate(info.Width / 2, info.Height / 2);

    using (SKPath path = new SKPath())
    {
        path.AddCircle(-offset, -offset, radius);
        canvas.ClipPath(path, SKClipOperation.Intersect);

        path.Reset();
        path.AddCircle(-offset, offset, radius);
        canvas.ClipPath(path, SKClipOperation.Intersect);

        path.Reset();
        path.AddCircle(offset, -offset, radius);
        canvas.ClipPath(path, SKClipOperation.Intersect);

        path.Reset();
        path.AddCircle(offset, offset, radius);
        canvas.ClipPath(path, SKClipOperation.Intersect);

        using (SKPaint paint = new SKPaint())
        {
            paint.Style = SKPaintStyle.Fill;
            paint.Color = SKColors.Blue;
            canvas.DrawPaint(paint);
        }
    }
}

剩下的是这四个圆的交集:

“四圆相交剪裁”页面的三重屏幕截图

SKClipOperation 枚举只有两个成员:

  • Difference 从现有剪辑区域中移除指定的路径或矩形

  • Intersect 与现有剪裁区域相交指定的路径或矩形

如果将 FourCircleIntersectClipPage 类中的四个 SKClipOperation.Intersect 参数替换为 SKClipOperation.Difference,则将看到以下内容:

具有差异操作的“四圆相交剪裁”页面的三重屏幕截图

从剪裁区域中移除了四个重叠圆。

剪裁操作页说明了这两个操作与一对圆的区别。 左侧的第一个圆将添加到剪裁区域,默认剪裁操作为 Intersect,右侧的第二个圆将添加到剪裁区域,剪裁操作由文本标签指示:

“剪辑操作”页面的三重屏幕截图

ClipOperationsPage 类将两个 SKPaint 对象定义为字段,然后将屏幕划分为两个矩形区域。 这些区域会因手机处于纵向模式还是横向模式而异。 然后,DisplayClipOp 类会显示文本并调用 ClipPath,其中包含两个圆路径来说明每个剪裁操作:

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

    canvas.Clear();

    float x = 0;
    float y = 0;

    foreach (SKClipOperation clipOp in Enum.GetValues(typeof(SKClipOperation)))
    {
        // Portrait mode
        if (info.Height > info.Width)
        {
            DisplayClipOp(canvas, new SKRect(x, y, x + info.Width, y + info.Height / 2), clipOp);
            y += info.Height / 2;
        }
        // Landscape mode
        else
        {
            DisplayClipOp(canvas, new SKRect(x, y, x + info.Width / 2, y + info.Height), clipOp);
            x += info.Width / 2;
        }
    }
}

void DisplayClipOp(SKCanvas canvas, SKRect rect, SKClipOperation clipOp)
{
    float textSize = textPaint.TextSize;
    canvas.DrawText(clipOp.ToString(), rect.MidX, rect.Top + textSize, textPaint);
    rect.Top += textSize;

    float radius = 0.9f * Math.Min(rect.Width / 3, rect.Height / 2);
    float xCenter = rect.MidX;
    float yCenter = rect.MidY;

    canvas.Save();

    using (SKPath path1 = new SKPath())
    {
        path1.AddCircle(xCenter - radius / 2, yCenter, radius);
        canvas.ClipPath(path1);

        using (SKPath path2 = new SKPath())
        {
            path2.AddCircle(xCenter + radius / 2, yCenter, radius);
            canvas.ClipPath(path2, clipOp);

            canvas.DrawPaint(fillPaint);
        }
    }

    canvas.Restore();
}

调用 DrawPaint 通常会导致整个画布填充该 SKPaint 对象,但在这种情况下,该方法只会在剪裁区域中进行绘制。

浏览区域

还可以根据 SKRegion 对象定义剪裁区域。

新创建的 SKRegion 对象描述一个空区域。 通常,SetRect 是对对象的第一次调用,以便该区域能够描述矩形区域。 指向 SetRect 的参数是一个具有整数坐标的矩形 SKRectI 值,因为它以像素为单位指定矩形。 然后,可以使用 SKPath 对象调用 SetPath。 这会创建与路径内部相同的区域,但会剪裁到初始矩形区域。

也可以通过调用 Op 方法重载之一来修改区域,例如:

public Boolean Op(SKRegion region, SKRegionOperation op)

SKRegionOperation 枚举类似于 SKClipOperation,但它具有更多成员:

  • Difference

  • Intersect

  • Union

  • XOR

  • ReverseDifference

  • Replace

要进行 Op 调用的区域会与根据 SKRegionOperation 成员指定为参数的区域组合在一起。 最终获得适合的剪裁区域时,可以使用 SKCanvasClipRegion 方法将该区域设置为画布的剪裁区域:

public void ClipRegion(SKRegion region, SKClipOperation operation = SKClipOperation.Intersect)

以下屏幕截图显示了基于六个区域操作的剪裁区域。 左圆是调用 Op 方法的区域,右圆是传递给 Op 方法的区域:

“区域操作”页面的三重屏幕截图

这些就是这两个圆圈组合的所有可能性吗? 将生成的图像视为三个组件的组合,这些组件本身在 DifferenceIntersectReverseDifference 操作中可见。 组合总数为 2 的三次方,即 8。 缺少的两个区域是原始区域(这是完全不调用 Op 的结果)和一个完全空的区域。

更难使用区域进行剪裁,因为需要首先创建路径,然后从该路径创建区域,然后合并多个区域。 区域操作页面的整体结构与剪裁操作非常相似,但 RegionOperationsPage 类会将屏幕划分为六个区域,并显示使用此作业的区域所需的额外工作:

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

    canvas.Clear();

    float x = 0;
    float y = 0;
    float width = info.Height > info.Width ? info.Width / 2 : info.Width / 3;
    float height = info.Height > info.Width ? info.Height / 3 : info.Height / 2;

    foreach (SKRegionOperation regionOp in Enum.GetValues(typeof(SKRegionOperation)))
    {
        DisplayClipOp(canvas, new SKRect(x, y, x + width, y + height), regionOp);

        if ((x += width) >= info.Width)
        {
            x = 0;
            y += height;
        }
    }
}

void DisplayClipOp(SKCanvas canvas, SKRect rect, SKRegionOperation regionOp)
{
    float textSize = textPaint.TextSize;
    canvas.DrawText(regionOp.ToString(), rect.MidX, rect.Top + textSize, textPaint);
    rect.Top += textSize;

    float radius = 0.9f * Math.Min(rect.Width / 3, rect.Height / 2);
    float xCenter = rect.MidX;
    float yCenter = rect.MidY;

    SKRectI recti = new SKRectI((int)rect.Left, (int)rect.Top,
                                (int)rect.Right, (int)rect.Bottom);

    using (SKRegion wholeRectRegion = new SKRegion())
    {
        wholeRectRegion.SetRect(recti);

        using (SKRegion region1 = new SKRegion(wholeRectRegion))
        using (SKRegion region2 = new SKRegion(wholeRectRegion))
        {
            using (SKPath path1 = new SKPath())
            {
                path1.AddCircle(xCenter - radius / 2, yCenter, radius);
                region1.SetPath(path1);
            }

            using (SKPath path2 = new SKPath())
            {
                path2.AddCircle(xCenter + radius / 2, yCenter, radius);
                region2.SetPath(path2);
            }

            region1.Op(region2, regionOp);

            canvas.Save();
            canvas.ClipRegion(region1);
            canvas.DrawPaint(fillPaint);
            canvas.Restore();
        }
    }
}

下面是 ClipPath 方法和 ClipRegion 方法之间的一个很大区别:

重要

ClipPath 方法不同,ClipRegion 方法不受转换的影响。

若要了解这种差异的原因,首先要了解什么是区域。 如果你想过如何在内部实现剪裁操作或区域操作,可能会觉得非常复杂。 几条可能非常复杂的路径被组合在一起,由此产生的路径轮廓很可能是一场算法噩梦。

如果将每条路径简化为一系列水平扫描线,就像旧式真空管电视中的扫描线一样,这项工作会大大简化。 每个扫描线只是一条具有起点和终点的水平线。 例如,一个半径为 10 像素的圆可以分解为 20 条水平扫描线,每个扫描线从圆的左侧部分开始,并在右侧部分结束。 用任何区域操作组合两个圆都变得非常简单,因为只需检查每对相应扫描线的起点和终点坐标即可。

这是一个区域的定义:一系列水平扫描线,用于确定一个区域。

但是,当区域减少到一系列扫描线时,这些扫描线是基于特定的像素尺寸的。 严格地说,该区域不是矢量图形对象。 与路径相比,它的性质更接近于压缩单色位图。 因此,区域无法缩放或旋转而不失去保真度,由此,它们不会在用于剪裁区域时受到转换。

但是,可以将转换应用于用于绘制的区域。 区域绘画程序生动地展示了区域的内在本质。 RegionPaintPage 类基于一个半径为 10 单位圆的 SKPath 创建一个 SKRegion 对象。 然后,转换将展开该圆以填充页面:

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

    canvas.Clear();

    int radius = 10;

    // Create circular path
    using (SKPath circlePath = new SKPath())
    {
        circlePath.AddCircle(0, 0, radius);

        // Create circular region
        using (SKRegion circleRegion = new SKRegion())
        {
            circleRegion.SetRect(new SKRectI(-radius, -radius, radius, radius));
            circleRegion.SetPath(circlePath);

            // Set transform to move it to center and scale up
            canvas.Translate(info.Width / 2, info.Height / 2);
            canvas.Scale(Math.Min(info.Width / 2, info.Height / 2) / radius);

            // Fill region
            using (SKPaint fillPaint = new SKPaint())
            {
                fillPaint.Style = SKPaintStyle.Fill;
                fillPaint.Color = SKColors.Orange;

                canvas.DrawRegion(circleRegion, fillPaint);
            }

            // Stroke path for comparison
            using (SKPaint strokePaint = new SKPaint())
            {
                strokePaint.Style = SKPaintStyle.Stroke;
                strokePaint.Color = SKColors.Blue;
                strokePaint.StrokeWidth = 0.1f;

                canvas.DrawPath(circlePath, strokePaint);
            }
        }
    }
}

DrawRegion 调用会以橙色填充区域,而 DrawPath 调用会将原始路径划入蓝色以进行比较:

“区域画图”页面的三重屏幕截图

该区域显然是一系列离散坐标。

如果不需要在剪裁区域中使用转换,则可以使用区域进行剪裁,如四叶草页面所示。 FourLeafCloverPage 类从四个圆形区域构造复合区域,将该复合区域设置为剪裁区域,然后从页面中心绘制一系列 360 直线:

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

    canvas.Clear();

    float xCenter = info.Width / 2;
    float yCenter = info.Height / 2;
    float radius = 0.24f * Math.Min(info.Width, info.Height);

    using (SKRegion wholeScreenRegion = new SKRegion())
    {
        wholeScreenRegion.SetRect(new SKRectI(0, 0, info.Width, info.Height));

        using (SKRegion leftRegion = new SKRegion(wholeScreenRegion))
        using (SKRegion rightRegion = new SKRegion(wholeScreenRegion))
        using (SKRegion topRegion = new SKRegion(wholeScreenRegion))
        using (SKRegion bottomRegion = new SKRegion(wholeScreenRegion))
        {
            using (SKPath circlePath = new SKPath())
            {
                // Make basic circle path
                circlePath.AddCircle(xCenter, yCenter, radius);

                // Left leaf
                circlePath.Transform(SKMatrix.MakeTranslation(-radius, 0));
                leftRegion.SetPath(circlePath);

                // Right leaf
                circlePath.Transform(SKMatrix.MakeTranslation(2 * radius, 0));
                rightRegion.SetPath(circlePath);

                // Make union of right with left
                leftRegion.Op(rightRegion, SKRegionOperation.Union);

                // Top leaf
                circlePath.Transform(SKMatrix.MakeTranslation(-radius, -radius));
                topRegion.SetPath(circlePath);

                // Combine with bottom leaf
                circlePath.Transform(SKMatrix.MakeTranslation(0, 2 * radius));
                bottomRegion.SetPath(circlePath);

                // Make union of top with bottom
                bottomRegion.Op(topRegion, SKRegionOperation.Union);

                // Exclusive-OR left and right with top and bottom
                leftRegion.Op(bottomRegion, SKRegionOperation.XOR);

                // Set that as clip region
                canvas.ClipRegion(leftRegion);

                // Set transform for drawing lines from center
                canvas.Translate(xCenter, yCenter);

                // Draw 360 lines
                for (double angle = 0; angle < 360; angle++)
                {
                    float x = 2 * radius * (float)Math.Cos(Math.PI * angle / 180);
                    float y = 2 * radius * (float)Math.Sin(Math.PI * angle / 180);

                    using (SKPaint strokePaint = new SKPaint())
                    {
                        strokePaint.Color = SKColors.Green;
                        strokePaint.StrokeWidth = 2;

                        canvas.DrawLine(0, 0, x, y, strokePaint);
                    }
                }
            }
        }
    }
}

它看起来并不像四叶草,但这是一幅可能很难在没有剪裁的情况下呈现出来的图像:

“四叶草”页面的三重屏幕截图