今天开始另一个新技术项目,我们将专注于绘图。这可能是SwiftUI的一个领域,你可能会认为你不需要太多,但事实并非如此:SwiftUI使高性能绘图变得如此容易,每个人都可以访问,你将找到可以使用自己的技能的地方在你构建的几乎所有应用中。

绘画有益的另一件事-在项目的第二部分和第三部分中将更加明显-有助于创造一种嬉戏感。在接下来的几天中,你会发现仅用几行代码就可以创建漂亮的设计,而我在准备示例并享受乐趣的过程中浪费了无数的时间。

不要相信我-著名的荷兰印象派画家梵高(Vincent Van Gogh)说:“我有时认为没有什么比绘画更令人愉快了。”

使用SwiftUI创建自定义路径

SwiftUI为我们提供了Path一种用于绘制自定义形状的专用类型。这是一个非常低的级别,我的意思是你通常会希望将其包装在其他内容中以使其更有用,但是由于它是构建其他工作的基础,因此我们将从此处开始。

就像颜色,渐变和形状一样,路径本身就是视图。这意味着我们可以像使用文本视图和图像一样使用它们,尽管你会看到它有些笨拙。

让我们从一个简单的形状开始:绘制一个三角形。有几种创建路径的方法,包括一种接受关闭绘图指令的方法。此闭包必须接受单个参数,这是绘制的路径。我意识到一开始这可能有点费劲,因为我们正在创建路径,并且在该路径的初始值设定项内通过绘制的路径来传递,但是这样想:SwiftUI正在创建一个空的路径,然后让我们有机会根据需要添加更多内容。

路径有很多创建带有正方形,圆形,弧形和直线形的形状的方法。对于我们的三角形,我们需要移动到起始位置,然后添加如下三行:

1
2
3
4
5
6
7
8
var body: some View {
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
}
}

我们以前没有使用CGPoint过,但是我确实CGSize在项目6中快速回顾了一下。“ CG”是Core Graphics的缩写,它提供了一些基本类型供你选择,它们使我们可以引用X / Y坐标(CGPoint),宽度和高度(CGSize),矩形框(CGRect)和偶数(CGFloat)。

当我们的三角形代码运行时,你会看到一个大的黑色三角形。当你看到它相对于你的屏幕取决于什么模拟器使用的是,这是这些原始路径的问题的一部分:我们需要使用精确的坐标,所以如果你想通过自己使用的路径,你要么需要接受在所有设备上调整大小,或使用类似的方法GeometryReader来相对于其容器缩放它们。

我们将在短期内寻找更好的选择,但首先让我们着眼于着色。一种选择是使用fill()修饰符,如下所示:

1
2
3
4
5
6
7
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
}
.fill(Color.blue)

我们还可以使用stroke()修饰符在路径周围绘制,而不是将其填充:

.stroke(Color.blue, lineWidth: 10)
但是,这看起来不太正确–三角形的底角很好看而且很尖锐,但顶角已损坏。之所以会发生这种情况,是因为SwiftUI确保线与前后的线整齐地连接,而不仅仅是一系列单独的线,但是我们的最后一行之后没有任何内容,因此无法建立连接。

解决此问题的一种方法是再次绘制第一条线,这意味着最后一条线具有一条与之匹配的连接线:

1
2
3
4
5
6
7
8
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
}
.stroke(Color.blue, lineWidth: 10)

如你所见,这很棒。而且它甚至可以很好地与透明度配合使用:如果使用透明的笔触颜色(例如),Color.blue.opacity(0.25)你将看到整个笔触均匀变淡,而沿第一行则看不到任何双笔触。

一种替代方法是使用SwiftUI的专用ShapeStyle结构,该结构使我们可以控制每条线在其后应如何连接到该线(线连接),以及在其后没有连接的情况下应如何绘制每条线(线盖)。这是特别有用的,因为join和cap的选项之一是.round,它会创建柔和的圆形形状:

1
.stroke(Color.blue, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))

有了这个位置,你就可以从我们的路径中删除多余的行,因为不再需要它。

使用圆角可以解决粗糙边缘的问题,但不能解决固定坐标的问题。为此,我们需要继续前进,并研究更复杂的事物:shapes。

SwiftUI中的路径与形状

SwiftUI启用具有两种不同类型的自定义绘图:路径和形状。路径是一系列绘制指令,例如“从此处开始,在此处绘制线,然后在此处添加圆”,所有指令均使用绝对坐标。相反,形状不知道将在何处使用或将使用多大的形状,而是会要求其在给定的矩形内绘制自身。

有用的是,形状是使用路径构建的,因此一旦你了解了路径,形状就很容易了。同样,就像路径,颜色和渐变一样,形状也是视图,这意味着我们可以将它们与文本视图,图像等一起使用。

SwiftUI Shape使用一个必需的方法作为协议来实现:给定以下矩形,你想绘制什么路径?仍然会像直接使用原始路径一样创建并返回路径,但是由于我们已经掌握了尺寸,因此将使用形状,因为我们确切知道绘制路径的大小–我们不再需要依赖固定坐标。

例如,以前我们使用来创建了一个三角形Path,但是我们可以将其包装成一个形状以确保它自动占用所有可用空间,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()

path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))

return path
}
}

通过CGRect,该工作变得更加容易,它提供了有用的属性,例如minX(矩形中的最小X值),maxX(矩形中的最大X值)和midX(minX和之间的中点maxX)。

然后,我们可以创建一个精确尺寸的红色三角形,如下所示:

1
2
3
Triangle()
.fill(Color.red)
.frame(width: 300, height: 300)

形状还支持相同的StrokeStyle参数以创建更高级的笔触:

1
2
3
Triangle()
.stroke(Color.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 300)

理解之间的区别的关键Path,并Shape为可重用性:路径的设计做一个具体的东西,而形状具有绘画空间的灵活性,还可以接受的参数让我们进一步对其进行自定义。

为了说明这一点,我们可以创建一个Arc接受三个参数的形状:起始角度,终止角度以及是否顺时针绘制圆弧。这似乎很简单,特别是因为它Path有一个addArc()方法,但是如你所见,它有几个有趣的怪癖。

让我们从最简单的弧形开始:

1
2
3
4
5
6
7
8
9
10
11
12
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool

func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise)

return path
}
}

我们现在可以像这样创建一个弧:

1
2
3
Arc(startAngle: .degrees(0), endAngle: .degrees(110), clockwise: true)
.stroke(Color.blue, lineWidth: 10)
.frame(width: 300, height: 300)

如果你查看我们弧线的预览,很可能它看起来不像你期望的那样。我们要求顺时针旋转的弧度是从0度到110度,但是逆时针旋转的弧度似乎是从90度到200度。

这里发生了两件事:

在SwiftUI看来,0度不是笔直向上,而是直接向右。
形状是从左下角而不是左上角开始测量坐标的,这意味着SwiftUI从一个角度到另一个角度都以相反的方式进行测量。在我看来,这是非常陌生的。
我们可以使用一种新path(in:)方法来解决这两个问题,该新方法将从起点和终点角度减去90度,并且还改变方向,以便SwiftUI遵循自然预期的方式:

1
2
3
4
5
6
7
8
9
10
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment

var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)

return path
}

运行该代码,看看你的想法–对我来说,它产生了一种更为自然的工作方式,并巧妙地隔离了SwiftUI的古怪绘画行为。

使用InsettableShape添加strokeBorder()支持

如果创建的形状没有特定大小,它将自动扩展以占据所有可用空间。例如,这将创建一个填充我们视图的圆,并为其提供40点蓝色边框:

1
2
3
4
5
6
struct ContentView: View {
var body: some View {
Circle()
.stroke(Color.blue, lineWidth: 40)
}
}

仔细观察边框的左右边缘–你注意到边框是怎么切掉的吗?

你在这里看到的是SwiftUI在形状周围绘制边框的方式的副作用。如果你递给某人一个圆的铅笔轮廓,并要求他们用粗笔在该圆上画线,他们将绘制出该圆的精确线-大约一半的笔在该线的内部,一半在该线的外部。这就是SwiftUI为我们所做的,但是当形状到达屏幕边缘时,则意味着边框的外部最终超出了屏幕边缘。

现在尝试改用这个圆圈:

1
2
Circle()
.strokeBorder(Color.blue, lineWidth: 40)

更改stroke()为strokeBorder(),现在我们得到了更好的结果:我们的所有边框都是可见的,因为Swift描画了圆的内部而不是居中于直线。

以前我们建立了一个Arc像这样的形状:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool

func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment

var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)

return path
}
}

就像一样Circle,它会自动占用所有可用空间。但是,这种代码将不起作用:

Arc(startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: true)
.strokeBorder(Color.blue, lineWidth: 40)
如果你打开Xcode的错误消息,你会看到它说“类型为’Arc’的值没有成员’strokeBorder’” –也就是说,该strokeBorder()修饰符就不存在Arc。

SwiftUI Circle和我们的之间有一个小但重要的区别Arc:既符合Shape协议,Circle 又符合称为的第二协议InsettableShape。该形状可以嵌入-向内减小-一定量以产生另一个形状。它产生的插图形状可以是任何其他类型的插图形状,但实际上,它应该是一个较小的矩形中的相同形状。

为了Arc符合要求,InsettableShape我们需要在其中添加一种额外的方法:inset(by:)。这将获得插入量(笔画的线宽的一半),并应返回一种新的可插入形状-在我们的实例中,这意味着我们应该创建一个插入弧。问题是,我们还不知道弧的实际大小,因为path(in:)尚未调用。

事实证明,解决方案非常简单:如果我们给Arc形状一个insetAmount默认值为0 的新属性,则只要inset(by:)调用它就可以添加该属性。添加到插图中后,inset(by:)如果需要,我们可以调用多次,例如,如果我们想手动调用一次,然后使用strokeBorder()。

首先,将此新属性添加到Arc:

1
var insetAmount: CGFloat = 0

现在给它这个inset(by:)方法:

1
2
3
4
5
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}

amount传入的参数应应用于所有边缘,这在圆弧的情况下意味着我们应该使用它来减小绘制半径。因此,将addArc()内部呼叫更改为path(in:):

1
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2 - insetAmount, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)

有了这一更改,我们现在可以使Arc符合InsettableShape如下所示:

1
struct Arc: InsettableShape {

注意: InsettableShape实际上是基于构建的Shape,因此无需在两者上都添加。

参考资料

查看下一天的SwiftUI学习笔记

关于100days英文课程