今天,我们将通过查看特殊效果和动画来将你的绘画技巧发挥到极致。正当我们处于绘画的前沿时,可以公平地说,这些技能不太可能在日常编码中使用,但是正如Ralph Waldo Emerson曾经说过的那样,“我们的目标是超越目标以达到目标。”

当你处理今天的主题时,你将学习如何对形状进行动画处理,这是SwiftUI感觉像魔术一样的另一个实例。但是,正如你之前所看到的,这确实不是魔术– SwiftUI只是响应我们配置视图的方式。这有点像一台Rube Goldberg机器:我们将所有设备正确设置好,使整个机器运转,然后观察正确的输出。

控制动画没有什么不同:我们不想让body视图的属性每秒重新调用60或120次才能获得平滑的动画,因此我们只是提供有关动画进行时应更改内容的说明。它不是很难发现的-也就是说,你不能偶然发现该解决方案-但我希望你同意使用起来很简单。

SwiftUI中的特殊效果:模糊,融合等

SwiftUI使我们能够出色地控制视图的呈现方式,包括应用实时模糊,混合模式,饱和度调整等功能。

混合模式使我们可以控制一个视图在另一个视图上的渲染方式。默认模式是.normal,它只是将新视图中的像素绘制到后面的任何东西上,但是有很多选项可以控制颜色和不透明度。

举例来说,我们可以在中绘制图像ZStack,然后在顶部添加一个红色矩形,该矩形使用乘以混合模式绘制:

1
2
3
4
5
6
7
8
9
ZStack {
Image("PaulHudson")

Rectangle()
.fill(Color.red)
.blendMode(.multiply)
}
.frame(width: 400, height: 500)
.clipped()

之所以称为“乘”,是因为它将每个源像素颜色与目标像素颜色相乘-在我们的示例中,是图像的每个像素和矩形的每个像素在顶部。每个像素具有RGBA的颜色值,范围从0(没有该颜色)到1(所有颜色),因此所得的最高颜色为1x1,最低的颜色为0x0。

对纯色使用乘法会产生一种非常常见的色调效果:黑色保持黑色(因为它们的颜色值为0,所以无论你将顶部乘以0都将产生0),而较浅的颜色会变成各种阴影着色。

实际上,乘法是如此普遍,以至于有一个快捷键修饰符,这意味着我们可以避免使用ZStack:

1
2
3
4
var body: some View {
Image("PaulHudson")
.colorMultiply(.red)
}

还有很多其他混合模式可供选择,值得花一些时间尝试一下它们的工作方式。另一个流行的效果称为screen,它与乘法相反:将颜色反转,执行乘法,然后再次反转,从而得到更亮的图像而不是更暗的图像。

例如,我们可以在内的各个位置绘制三个圆Stack,然后使用滑块控制其大小和重叠:

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 ContentView: View {
@State private var amount: CGFloat = 0.0

var body: some View {
VStack {
ZStack {
Circle()
.fill(Color.red)
.frame(width: 200 * amount)
.offset(x: -50, y: -80)
.blendMode(.screen)

Circle()
.fill(Color.green)
.frame(width: 200 * amount)
.offset(x: 50, y: -80)
.blendMode(.screen)

Circle()
.fill(Color.blue)
.frame(width: 200 * amount)
.blendMode(.screen)
}
.frame(width: 300, height: 300)

Slider(value: $amount)
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.edgesIgnoringSafeArea(.all)
}
}

如果你特别观察,你可能会注意到中间完全混合的颜色不是很白,而是一种非常淡的淡紫色。这样做的原因是Color.red,Color.green和Color.blue不完全的颜色; 使用时没有看到纯红色Color.red。相反,你会看到SwiftUI的自适应颜色被设计为在黑暗模式和明亮模式下都看起来不错,因此它们是红色,绿色和蓝色的自定义混合色,而不是纯色。

如果你想看到混合红色,绿色和蓝色的全部效果,则应使用以下三种自定义颜色:

1
2
3
.fill(Color(red: 1, green: 0, blue: 0))
.fill(Color(red: 0, green: 1, blue: 0))
.fill(Color(red: 0, green: 0, blue: 1))

我们还可以应用许多其他实时效果,并且我们已经blur()在项目3中进行了回顾。因此,在继续进行操作之前,我们再来看一个:saturation(),它可以调整视图中使用的颜色数量。给它一个介于0(无颜色,只有灰度)和1(全色)之间的值。

我们可以写一些代码来证明两者blur()并saturation()在同一视图,就像这样:

1
2
3
4
5
6
Image("PaulHudson")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
.saturation(Double(amount))
.blur(radius: (1 - amount) * 20)

使用该代码,将滑块设为0意味着图像模糊无色,但是当你将滑块向右移动时,它将获得色彩并变得清晰-所有这些都以闪电般的速度呈现。

使用animatableData对简单形状进行动画处理

现在,我们已经涵盖了与绘图有关的各种任务,在项目6中,我们研究了动画,因此现在我想将这两件事放在一起。

首先,让我们构建一个可以用作示例的自定义形状-这是梯形形状的代码,梯形形状是具有直边且一对相对边平行的四边形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Trapezoid: Shape {
var insetAmount: CGFloat

func path(in rect: CGRect) -> Path {
var path = Path()

path.move(to: CGPoint(x: 0, y: rect.maxY))
path.addLine(to: CGPoint(x: insetAmount, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - insetAmount, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: 0, y: rect.maxY))

return path
}
}

现在,我们可以在视图内部使用该视图,并为其插入量传递一些本地状态,以便我们可以在运行时修改该值:

1
2
3
4
5
6
7
8
9
10
11
struct ContentView: View {
@State private var insetAmount: CGFloat = 50

var body: some View {
Trapezoid(insetAmount: insetAmount)
.frame(width: 200, height: 100)
.onTapGesture {
self.insetAmount = CGFloat.random(in: 10...90)
}
}
}

每次点击梯形时,insetAmount都会将其设置为新值,从而导致形状被重绘。

如果我们可以对插图的变化进行动画处理,那不是很好吗?当然可以–尝试将onTapGesture()闭包更改为此:

1
2
3
4
5
.onTapGesture {
withAnimation {
self.insetAmount = CGFloat.random(in: 10...90)
}
}

现在再次运行它,……没有任何改变。我们已经要求动画,但是我们没有动画–有什么用?

以前查看动画时,我要求你print()在body属性内添加一个调用,然后这样说:

”你应该看到的是它会打印出2.0、3.0、4.0等。同时,按钮可以顺畅地向上或向下缩放-不仅可以直接跳至缩放2、3和4。这里实际发生的是,SwiftUI在绑定更改之前正在检查视图的状态,绑定更改后,我们视图的目标状态发生了变化,然后应用动画从A点到达B点。”

因此,一旦将self.insetAmount其设置为新的随机值,它将立即跳至该值并将其直接传递给它Trapezoid-动画发生时它不会传递很多中间值。这就是为什么我们的梯形从插图跳到插图。它甚至不知道动画正在发生。

我们只能用四行代码来解决这个问题,其中只有一行是大括号。但是,即使这段代码很简单,它的工作方式也可能使你的大脑弯曲。

首先,代码– Trapezoid现在将此新的计算属性添加到结构中:

1
2
3
4
var animatableData: CGFloat {
get { insetAmount }
set { self.insetAmount = newValue }
}

现在,你可以再次运行该应用程序,并通过平滑的动画查看梯形形状的变化。

这里发生的事情非常复杂:当我们使用时withAnimation(),SwiftUI会立即将state属性更改为新值,但在幕后,随着动画的进行,它还会跟踪随时间变化的值。随着动画的进行,SwiftUI会将animatableData形状的属性设置为最新值,这取决于我们决定什么意思–在本例中,我们直接将其分配给insetAmount,因为这就是我们要进行动画处理的东西。

记住,SwiftUI在应用动画之前先评估视图状态,然后再应用动画。可以看到我们最初有计算结果为的代码Trapezoid(insetAmount: 50),但是在选择了一个随机数之后,我们最终得到了(例如)Trapezoid(insetAmount: 62)。因此,它将在动画的整个长度内插值50到62,每次将animatableData形状的属性设置为最新的插值值– 51、52、53,依此类推,直到达到62。

使用AnimatablePair对复杂形状进行动画处理

SwiftUI使用animatableData属性让我们对形状的变化进行动画处理,但是当我们希望对两个,三个,四个或更多个属性进行动画处理时会发生什么呢?animatableData是一个属性,这意味着它必须始终是一个值,但是我们要决定它是什么类型的值:它可以是单个值CGFloat,也可以是包含在称为的特殊包装器中的两个值AnimatablePair。

为了尝试这一点,让我们看一下一个名为的新形状Checkerboard,该形状必须以一定数量的行和列创建:

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
struct Checkerboard: Shape {
var rows: Int
var columns: Int

func path(in rect: CGRect) -> Path {
var path = Path()

// figure out how big each row/column needs to be
let rowSize = rect.height / CGFloat(rows)
let columnSize = rect.width / CGFloat(columns)

// loop over all rows and columns, making alternating squares colored
for row in 0..<rows {
for column in 0..<columns {
if (row + column).isMultiple(of: 2) {
// this square should be colored; add a rectangle here
let startX = columnSize * CGFloat(column)
let startY = rowSize * CGFloat(row)

let rect = CGRect(x: startX, y: startY, width: columnSize, height: rowSize)
path.addRect(rect)
}
}
}

return path
}
}

现在,我们可以使用一些状态属性在SwiftUI视图中创建一个4x4棋盘格,我们可以使用点击手势来更改这些属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ContentView: View {
@State private var rows = 4
@State private var columns = 4

var body: some View {
Checkerboard(rows: rows, columns: columns)
.onTapGesture {
withAnimation(.linear(duration: 3)) {
self.rows = 8
self.columns = 16
}
}
}
}

运行该命令后,你应该可以点击黑色正方形,以查看棋盘格从4x4跳到8x16的情况,即使更改位于一个withAnimation()块内,也没有动画。

与更简单的形状一样,此处的解决方案是实现一个animatableData属性,该属性将随着动画的进行而设置为中间值。不过,这里有两个问题:

我们有两个要设置动画的属性,而不是一个。
我们的row和column属性是整数,SwiftUI不能插入整数。
为了解决第一个问题,我们将使用一种名为的新类型AnimatablePair。顾名思义,它包含一对可动画设置的值,并且由于两个值都可以设置动画,因此AnimatablePair可以设置动画。我们可以使用.first和读取配对值.second。

要解决的第二个我们只是做一些类型转换的问题:我们可以转换Double到Int仅通过使用Int(someDouble),并通过走另一条路Double(someInt)。

因此,要使我们的棋盘动画化行数和列数的变化,请添加以下属性:

1
2
3
4
5
6
7
8
9
10
public var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(Double(rows), Double(columns))
}

set {
self.rows = Int(newValue.first)
self.columns = Int(newValue.second)
}
}

现在,当你运行该应用程序时,你应该发现更改进行得很顺利–或由于我们将数字四舍五入为整数而达到了你期望的平滑。

当然,下一个问题是:我们如何为三个属性设置动画?还是四个?

为了回答这个问题,让我向你展示animatableDataSwiftUI EdgeInsets类型的属性:

AnimatablePair<CGFloat, AnimatablePair<CGFloat, AnimatablePair<CGFloat, CGFloat>>>
是的,它们使用三个独立的可动画对,然后使用诸如的代码对它们进行挖掘newValue.second.second.first。

我不会断言这是最优雅的解决方案,但我希望你能理解它存在的原因:因为SwiftUI可以读取和写入形状的可动画显示的数据,而无论该数据是什么或意味着什么,它都不会body在动画制作过程中,无需每秒60甚至120次重新调用视图的属性–只需更改实际更改的部分即可。

使用SwiftUI创建呼吸描记器

为了完成一些真正涉及绘图的工作,我将引导你完成使用SwiftUI创建一个简单的描记器的过程。“旋转描记器”是玩具的商标名称,你可以将铅笔放在一个圆圈内,然后将其绕另一个圆圈旋转,从而创建各种被称为轮盘赌的几何图案,例如赌场游戏。

该代码包含一个非常具体的公式。我将对其进行解释,但是如果你不感兴趣,则可以跳过本章,这完全是可以的-这只是为了好玩,这里没有介绍新的Swift或SwiftUI。

我们的算法有四个输入:

内圆的半径。
外圆的半径。
虚拟笔距外圆中心的距离。
要画多少轮盘赌。这是可选的,但我认为它确实有助于显示算法工作时发生的情况。
因此,让我们开始:

1
2
3
4
5
6
struct Spirograph: Shape {
let innerRadius: Int
let outerRadius: Int
let distance: Int
let amount: CGFloat
}

然后,我们从该数据中准备三个值,从内半径和外半径的最大公约数(GCD)开始。通常使用Euclid算法来计算两个数字的GCD,该算法以稍微简化的形式看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
func gcd(_ a: Int, _ b: Int) -> Int {
var a = a
var b = b

while b != 0 {
let temp = b
b = a % b
a = temp
}

return a
}

请将该方法添加到Spirograph结构中。

其他两个值是内半径和外半径之间的差,以及我们需要执行多少步来绘制轮盘赌-这是360度乘以外半径除以最大公因数再乘以我们输入的金额。当以整数形式提供时,我们所有的输入效果最佳,但是在绘制轮盘时,我们需要使用CGFloat,因此我们还将创建CGFloat输入的副本。

现在将此path(in:)方法添加到Spirographstruct中:

1
2
3
4
5
6
7
8
9
10
func path(in rect: CGRect) -> Path {
let divisor = gcd(innerRadius, outerRadius)
let outerRadius = CGFloat(self.outerRadius)
let innerRadius = CGFloat(self.innerRadius)
let distance = CGFloat(self.distance)
let difference = innerRadius - outerRadius
let endPoint = ceil(2 * CGFloat.pi * outerRadius / CGFloat(divisor)) * amount

// more code to come
}

最后,我们可以通过从0循环到终点,然后将点放置在精确的X / Y坐标上来绘制轮盘赌本身。计算该循环中给定点(称为“θ”)的X / Y坐标是真正的数学知识的来源,但是老实说,我只是将标准方程式从Wikipedia转换为Swift,这不是我梦dream以求的事情!

X等于半径差乘以theta的余弦,X等于距离乘以半径差的余弦除以外半径再乘以theta。
Y等于半径差乘以theta的正弦,即距离减去半径乘以半径差的正弦除以外半径再乘以theta所得的值。
这是核心算法,但是我们将做两个小更改:我们将分别将绘图矩形的宽度或高度的一半加到X和Y上,以使其在绘图空间中居中,并且theta为0 –即,如果这是轮盘中绘制的第一点–我们将打电话move(to:)而不是addLine(to:)路径。

这是该path(in:)方法的最终代码–将此// more code to come注释替换为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var path = Path()

for theta in stride(from: 0, through: endPoint, by: 0.01) {
var x = difference * cos(theta) + distance * cos(difference / outerRadius * theta)
var y = difference * sin(theta) - distance * sin(difference / outerRadius * theta)

x += rect.width / 2
y += rect.height / 2

if theta == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}

return path

我意识到这是一门繁重的数学工作,但即将实现的结果是:我们现在可以在视图中使用该形状,添加各种滑块以控制内半径,外半径,距离,数量甚至颜色:

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
34
35
36
37
38
39
40
41
struct ContentView: View {
@State private var innerRadius = 125.0
@State private var outerRadius = 75.0
@State private var distance = 25.0
@State private var amount: CGFloat = 1.0
@State private var hue = 0.6

var body: some View {
VStack(spacing: 0) {
Spacer()

Spirograph(innerRadius: Int(innerRadius), outerRadius: Int(outerRadius), distance: Int(distance), amount: amount)
.stroke(Color(hue: hue, saturation: 1, brightness: 1), lineWidth: 1)
.frame(width: 300, height: 300)

Spacer()

Group {
Text("Inner radius: \(Int(innerRadius))")
Slider(value: $innerRadius, in: 10...150, step: 1)
.padding([.horizontal, .bottom])

Text("Outer radius: \(Int(outerRadius))")
Slider(value: $outerRadius, in: 10...150, step: 1)
.padding([.horizontal, .bottom])

Text("Distance: \(Int(distance))")
Slider(value: $distance, in: 1...150, step: 1)
.padding([.horizontal, .bottom])

Text("Amount: \(amount, specifier: "%.2f")")
Slider(value: $amount)
.padding([.horizontal, .bottom])

Text("Color")
Slider(value: $hue)
.padding(.horizontal)
}
}
}
}

那是很多代码,但是我希望你花些时间运行该应用程序,并欣赏轮盘的精美程度。你所看到的实际上只是轮盘赌的一种形式,被称为下摆线–对算法的细微调整,你可以生成上摆线,甚至更多,以不同的方式精美。

在结束之前,我想提醒你,这里使用的参数方程式是数学标准,而不是我刚发明的东西–我从字面上转到维基百科有关下胚轴的页面(https://en.wikipedia.org/wiki/Hypotrochoid)并将其转换为Swift。

参考资料

查看下一天的SwiftUI学习笔记

关于100days英文课程