今天,我们将通过增加一些创意来继续研究SwiftUI的绘图系统-我想你会惊讶地发现,仅通过将大多数已知知识与几种新技术结合起来,就可以使一件有趣的事情变得如此容易。

今天,你还将遇到drawingGroup()修饰符,该修饰符使我们可以将视图渲染结合到一起,并由Apple的高性能图形API Metal提供支持。很多人都问我的过去,我是否打算写一本关于金属,答案是明确的不 -不仅是有一个非常好的一个已经,但它也是非常难以得到什么好东西了的API 。

那不是因为Metal不好-相信我,这太不可思议了!–而是因为苹果最好的工程师在与Metal一起工作时尽了最大的努力使SwiftUI尽可能高效,而且直率地说,我不可能做得更好。

如你所见,切换Metal并不容易,即使很容易做到。著名软件开发人员肯特·贝克(Kent Beck)曾经说过,我们的流程应该是“使其正常运行,使其正确,快速”。但是,如果你发现绘图工作迅速而无需切换到Metal,通常最好保持原样。

无论如何,足够的聊天–我说过我们会做点有趣的事情,让我们开始吧!

今天,你有三个课题合作,通过在其中你将了解CGAffineTransform,ImagePaint,drawingGroup(),等等。

使用CGAffineTransform和奇数填充来变换形状

当你超越简单的形状和路径时,SwiftUI的两个有用功能会合在一起,以极少的工作量创建出漂亮的效果。第一个是CGAffineTransform,它描述了如何旋转,缩放或剪切路径或视图。第二个是奇数填充,它使我们可以控制应如何渲染重叠的形状。

为了演示这两种方法,我们将用几个旋转的椭圆形花瓣创建一个花朵形状,每个椭圆形围绕一个圆放置。这背后的数学方法相对简单,只有一个要点:CGAffineTransform以弧度而非角度来测量角度。如果自从上学以来已经有一段时间了,那么你至少需要知道的是:3.141弧度等于180度,因此3.141弧度乘以2等于360度。3.141并非巧合:实际值是数学常数pi。

因此,我们要做的如下:

创建一个新的空路径。
从0到pi乘以2(弧度为360度),然后每次计数为pi的八分之一,这将为我们提供16个花瓣。
创建一个等于当前数字的旋转变换。
加上此旋转,移动量等于绘制空间宽度和高度的一半,因此每个花瓣都以我们的形状为中心。
为花瓣创建一个新路径,该路径等于特定大小的椭圆。
将我们的变换应用到该椭圆,以便将其移到位置。
将花瓣的路径添加到我们的主路径中。
一旦你看到代码正在运行,这将更有意义,但是首先我想添加三件事:

旋转然后移动的东西不会产生与移动然后旋转的结果相同的结果,因为先旋转时,它的移动方向将与未旋转时的不同。
为了真正帮助你了解发生了什么,我们将使花瓣椭圆使用一些可以从外部传递的属性。
1…5如果你想一次计数一次,则这样的范围非常好,但是如果你想以2s为单位进行计数,或者在我们的案例中以“ pi / 8”为单位进行计数,则应该stride(from:to:by:)改用。
好了,足够多的讨论–现在将此形状添加到你的项目中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct Flower: Shape {
// How much to move this petal away from the center
var petalOffset: Double = -20

// How wide to make each petal
var petalWidth: Double = 100

func path(in rect: CGRect) -> Path {
// The path that will hold all petals
var path = Path()

// Count from 0 up to pi * 2, moving up pi / 8 each time
for number in stride(from: 0, to: CGFloat.pi * 2, by: CGFloat.pi / 8) {
// rotate the petal by the current value of our loop
let rotation = CGAffineTransform(rotationAngle: number)

// move the petal to be at the center of our view
let position = rotation.concatenating(CGAffineTransform(translationX: rect.width / 2, y: rect.height / 2))

// create a path for this petal using our properties plus a fixed Y and height
let originalPetal = Path(ellipseIn: CGRect(x: CGFloat(petalOffset), y: 0, width: CGFloat(petalWidth), height: rect.width / 2))

// apply our rotation/position transformation to the petal
let rotatedPetal = originalPetal.applying(position)

// add it to our main path
path.addPath(rotatedPetal)
}

// now send the main path back
return path
}
}

我意识到这是很多代码,但是希望当你尝试一下时,它会变得更加清晰。修改ContentView为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct ContentView: View {
@State private var petalOffset = -20.0
@State private var petalWidth = 100.0

var body: some View {
VStack {
Flower(petalOffset: petalOffset, petalWidth: petalWidth)
.stroke(Color.red, lineWidth: 1)

Text("Offset")
Slider(value: $petalOffset, in: -40...40)
.padding([.horizontal, .bottom])

Text("Width")
Slider(value: $petalWidth, in: 0...100)
.padding(.horizontal)
}
}
}

现在尝试一下。一旦开始拖动offset和width滑块,你应该就能确切地看到代码的工作原理–它只是一系列旋转的椭圆,呈圆形排列。

这本身是有趣的,但有一个小的变化,我们可以去从有趣到崇高。如果你查看绘制椭圆的方式,它们经常重叠-有时一个椭圆绘制在另一个椭圆上,有时绘制在其他多个椭圆上。

如果我们使用纯色填充路径,则会得到相当不令人印象深刻的结果。像这样尝试:

1
2
Flower(petalOffset: petalOffset, petalWidth: petalWidth)
.fill(Color.red)

但是,作为一种替代方法,我们可以使用奇偶规则填充形状,该规则决定路径的一部分是否应根据其包含的重叠进行着色。它是这样的:

如果路径没有重叠,它将被填充。
如果另一条路径重叠,则重叠的部分将不会被填充。
如果第三个路径与前两个路径重叠,则会被填充。
…等等。
仅实际重叠的部分受此规则影响,并且会产生一些非常漂亮的结果。更好的是,Swift UI使其使用起来很简单,因为每当我们调用fill()形状时,我们都可以传递一个FillStyle结构,该结构要求启用奇偶规则。

尝试一下:

1
2
Flower(petalOffset: petalOffset, petalWidth: petalWidth)
.fill(Color.red, style: FillStyle(eoFill: true))

现在运行程序并开始播放-老实说,鉴于我们所做的工作很少,结果非常诱人!

使用ImagePaint创作边框和填充(平铺图案)

SwiftUI严重依赖于协议,使用绘图时可能会有些混乱。例如,我们可以Color用作视图,但它也符合ShapeStyle–用于填充,笔触和边框的不同协议。

实际上,这意味着我们可以修改默认的文本视图,使其具有红色背景:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.background(Color.red)

或红色边框:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.border(Color.red, width: 30)

相反,我们可以将图像用作背景:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.background(Image("Example"))

但是使用相同的图像作为边框将不起作用:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.border(Image("Example"), width: 30)

如果你考虑一下,这是有道理的-除非图像的大小正确,否则你对外观的控制将非常有限。

为了解决这个问题,SwiftUI为我们提供了一种专用类型,该类型可以完全控制应如何渲染图像的方式包装图像,这又意味着我们可以将它们用于边框和填充而不会出现问题。

该类型称为ImagePaint,它是使用一到三个参数创建的。至少你需要给它一个Image作为其第一个参数,但是你也可以在该图像内提供一个矩形,用作在0到1(第二个参数)范围内指定的图形源,以及该图像的比例(第三个参数)。这些第二和第三个参数具有合理的默认值“整个图像”和“ 100%比例”,因此有时你可以忽略它们。

举例来说,我们可以使用0.2的比例尺绘制示例图像,这表示它的显示尺寸为正常尺寸的1/5:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.border(ImagePaint(image: Image("Example"), scale: 0.2), width: 30)

如果要尝试使用该sourceRect参数,请确保输入CGRect相对大小和位置:0表示“开始”,1表示“结束”。例如,这将显示示例图像的整个宽度,但仅显示中间一半:

1
2
3
Text("Hello World")
.frame(width: 300, height: 300)
.border(ImagePaint(image: Image("Example"), sourceRect: CGRect(x: 0, y: 0.25, width: 1, height: 0.5), scale: 0.1), width: 30)

值得补充的是,ImagePaint它可以用于查看背景以及笔触形状。例如,我们可以创建一个胶囊,将示例图像平铺为笔划:

1
2
3
Capsule()
.strokeBorder(ImagePaint(image: Image("Example"), scale: 0.1), lineWidth: 20)
.frame(width: 300, height: 200)

ImagePaint 会自动将其图像平铺,直到其填满为止-它可以与背景,笔触,边框和任何大小的填充一起使用。

使用drawingGroup()启用高性能金属渲染

SwiftUI默认使用Core Animation进行渲染,提供了出色的开箱即用性能。但是,对于复杂的渲染,你可能会发现代码开始变慢–每秒60帧(FPS)以下的任何问题都是一个问题,但实际上你应该将目标更高,因为许多iOS设备现在以120fps的速度渲染。

为了说明这一点,让我们看一些示例代码。我们将创建一个颜色循环视图,以各种颜色呈现同心圆。结果看起来像是一个径向渐变,但是我们将添加两个属性以使其更具可定制性:一个用于控制应该绘制多少个圆形,另一个用于控制颜色循环–它将能够移动渐变周围的开始和结束颜色。

我们可以通过使用Color(hue:saturation:brightness:)初始化程序来获得颜色循环效果:色相是从0到1的一个值,控制着我们看到的颜色的种类–红色是0到1,介于其间的所有其他色相。为了弄清楚特定圆圈的色相,我们可以取圆圈数(例如25),除以有多少圆圈(例如100),然后加上颜色循环量(例如0.5)。因此,如果我们是100的第25圈,循环量为0.5,则我们的色相将为0.75。

这里的一个小复杂性是,色相在达到1.0后不会自动换行,这意味着1.0的色相等于0.0的色相,但是1.2的色相不等于0.2的色相。结果,我们将手工包裹色相:如果色相超过1.0,我们将减去1.0,以确保它始终位于0.0到1.0的范围内。

这是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ColorCyclingCircle: View {
var amount = 0.0
var steps = 100

var body: some View {
ZStack {
ForEach(0..<steps) { value in
Circle()
.inset(by: CGFloat(value))
.strokeBorder(self.color(for: value, brightness: 1), lineWidth: 2)
}
}
}

func color(for value: Int, brightness: Double) -> Color {
var targetHue = Double(value) / Double(self.steps) + self.amount

if targetHue > 1 {
targetHue -= 1
}

return Color(hue: targetHue, saturation: 1, brightness: brightness)
}
}

现在,我们可以在布局中使用它,将其颜色循环绑定到由滑块控制的局部属性:

1
2
3
4
5
6
7
8
9
10
11
12
struct ContentView: View {
@State private var colorCycle = 0.0

var body: some View {
VStack {
ColorCyclingCircle(amount: self.colorCycle)
.frame(width: 300, height: 300)

Slider(value: $colorCycle)
}
}
}

如果你运行该应用程序,你会看到我们有一个整齐的色波效果,完全可以通过在滑块上拖动来控制,并且效果非常流畅。

你现在所看到的是由Core Animation驱动的,这意味着它将把我们的100个圆变成在屏幕上绘制的100个独立视图。这在计算上是昂贵的,但是如你所见,它运行良好-我们获得了平稳的性能。

但是,如果稍微增加复杂度,我们会发现事情并不是那么乐观。strokeBorder()用这个替换现有的修饰符:

1
2
3
4
.strokeBorder(LinearGradient(gradient: Gradient(colors: [
self.color(for: value, brightness: 1),
self.color(for: value, brightness: 0.5)
]), startPoint: .top, endPoint: .bottom), lineWidth: 2)

现在,这将呈现一个柔和的渐变,在圆的顶部显示明亮的颜色,在底部显示较暗的颜色。而现在,当你运行应用程序,你会发现它运行慢得多- SwiftUI正在努力使100个梯度为100点独立的观点的一部分。

我们可以通过应用一个称为的新修饰符来解决此问题drawingGroup()。这告诉SwiftUI,在将视图内容作为单个呈现的输出放回到屏幕上之前,应将视图的内容呈现到屏幕外的图像中,这要快得多。在幕后,此功能由Metal提供支持,Metal是Apple的框架,可直接与GPU协同工作以实现极快的图形。

因此,将ColorCyclingCircle主体修改为此:

1
2
3
4
5
6
var body: some View {
ZStack {
// existing code…
}
.drawingGroup()
}

现在,再次运行它-仅需添加一点点,就可以正确渲染所有内容,即使使用渐变色也可以全速返回。

重要提示:该drawingGroup()修改有助于了解并保持你的军火库,以此来解决性能问题,当你打他们,但你应该不会使用它,往往。添加屏幕外渲染过程可能会降低SwiftUI进行简单绘图的速度,因此,在尝试引入之前,应等待直到遇到实际的性能问题drawingGroup()。

参考资料

查看下一天的SwiftUI学习笔记

关于100days英文课程