Sycamore

Phoenix reborns from the ashe

0%

关于SwiftUI简单笔记

此博客记录在TwT时开发WeiPeiyang 4.0的SwiftUI笔记📒

基础模块

关于照片变成纯蓝色

如果您发现图像已被某种颜色填充,例如显示为纯蓝色而不是实际图片,则可能是SwiftUI为它们着色以显示它们是可轻敲的。要解决此问题,请使用renderingMode(.original)修饰符强制SwiftUI显示原始图像,而不是重新着色的版本。

自定义修饰符

SwiftUI为我们提供了内置的改性剂,如一系列的font()background()clipShape()。但是,也可以创建执行特定操作的自定义修饰符。

例如,我们可能会说应用程序中的所有标题都应具有特定的样式,因此首先我们需要创建一个自定义ViewModifier结构来实现我们想要的功能:

1
2
3
4
5
6
7
8
9
10
struct Title: ViewModifier {
func body(content: Content) -> some View {
content
.font(.largeTitle)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

现在,我们可以将其与modifier()修饰符一起使用-是的,它是一个称为“修饰符”的修饰符,但是它允许我们将任何种类的修饰符应用于视图,如下所示:

1
2
Text("Hello World")
.modifier(Title())

使用自定义修饰符时,通常在其上创建扩展View使其易于使用的明智之举。例如,我们可以将Title修饰符包装在如下扩展中:

1
2
3
4
5
extension View {
func titleStyle() -> some View {
self.modifier(Title())
}
}

我们现在可以像这样使用修饰符:

1
2
Text("Hello World")
.titleStyle()

自定义修改器不仅可以应用其他现有修改器,还可以做更多的工作-它们还可以根据需要创建新的视图结构。记住,修饰符会返回新对象,而不是修改现有对象,因此我们可以创建一个将视图嵌入堆栈并添加另一个视图的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Watermark: ViewModifier {
var text: String

func body(content: Content) -> some View {
ZStack(alignment: .bottomTrailing) {
content
Text(text)
.font(.caption)
.foregroundColor(.white)
.padding(5)
.background(Color.black)
}
}
}

extension View {
func watermarked(with text: String) -> some View {
self.modifier(Watermark(text: text))
}
}

有了它,我们现在可以为任何视图添加水印,如下所示:

1
2
3
Color.blue
.frame(width: 300, height: 200)
.watermarked(with: "Hacking with Swift")

自定义容器

尽管您不太可能经常这样做,但我至少想向您展示,在SwiftUI应用程序中完全有可能创建自定义容器。这需要更高级的Swift知识,因为它利用了Swift的一些强大功能,因此,如果发现太多,可以跳过。

为了进行试验,我们将创建一种称为a的新型堆栈GridStack,这将使我们能够在网格内创建任意数量的视图。我们要说的是,有一个名为struct的新结构GridStack,它符合View协议并且具有一定数量的行和列,并且在网格内部将有很多内容单元格,它们本身必须符合View协议。

在Swift中,我们可以这样写:

1
2
3
4
5
6
7
8
9
struct GridStack<Content: View>: View {
let rows: Int
let columns: Int
let content: (Int, Int) -> Content

var body: some View {
// more to come
}
}

第一行– struct GridStack: View使用Swift的一个更高级的功能,称为通用(generics),在这种情况下,这意味着“您可以提供所需的任何种类的内容,但是无论其内容必须符合View协议。” 在冒号之后,我们View再次重复说,GridStack它本身也符合View协议。

请特别注意这一let content行–定义了一个闭包,该闭包必须能够接受两个整数并返回我们可以显示的某种内容。

我们需要通过body组合多个垂直和水平堆栈以创建所需数量的单元格来完成该属性。我们不需要说什么是每个单元中,因为我们可以得到通过拨打我们content用适当的行和列关闭。

因此,我们可以这样填写:

1
2
3
4
5
6
7
8
9
10
11
var body: some View {
VStack {
ForEach(0..<rows, id: \.self) { row in
HStack {
ForEach(0..<self.columns, id: \.self) { column in
self.content(row, column)
}
}
}
}
}

提示:在范围上循环时,只有在我们确定范围内的值不会随时间变化时,SwiftUI才能直接使用范围。在这里,我们使用ForEachwith 0..和0..,这两个值都是可以随时间变化的值-例如,我们可以添加更多行。在这种情况下,我们需要添加第二个参数ForEachid: \.self以告诉SwiftUI它如何能够识别环路中的每个视图。我们将在项目5中对此进行更详细的介绍。

现在我们有了一个自定义容器,我们可以使用它来编写一个视图,如下所示:

1
2
3
4
5
6
7
struct ContentView: View {
var body: some View {
GridStack(rows: 4, columns: 4) { row, col in
Text("R\(row) C\(col)")
}
}
}

GridStack只要符合View协议,我们就能接受任何种类的细胞内容。因此,如果需要,我们可以给单元格一个堆栈:

1
2
3
4
5
6
GridStack(rows: 4, columns: 4) { row, col in
HStack {
Image(systemName: "\(row * 4 + col).circle")
Text("R\(row) C\(col)")
}
}

想走得更远吗?

为了获得更大的灵活性,我们可以利用SwiftUI的一种称为视图构建器的功能,该功能允许我们发送多个视图并将其形成隐式堆栈。

要使用此功能,我们需要为我们的GridStack结构创建一个自定义初始化程序,因此我们可以将content关闭标记为使用SwiftUI的视图构建器系统:

1
2
3
4
5
init(rows: Int, columns: Int, @ViewBuilder content: @escaping (Int, Int) -> Content) {
self.rows = rows
self.columns = columns
self.content = content
}

多数情况下,只是将参数直接复制到结构的属性中,但请注意该@ViewBuilder属性在那里。您还将看到该@escaping属性,该属性使我们可以存储闭包,以备后用。

有了适当的设置,SwiftUI现在将在我们的单元格封闭内部自动创建一个隐式水平堆栈:

1
2
3
4
GridStack(rows: 4, columns: 4) { row, col in
Image(systemName: "\(row * 4 + col).circle")
Text("R\(row) C\(col)")
}

这两个选项均有效,所以无论您喜欢哪个都可以。

用步进器输入数字

SwiftUI有两种让用户输入数字的方式,我们将在这里使用的一种方式是Stepper:一个简单的-和+按钮,可以点击以选择一个精确的数字。另一个选项是Slider,我们稍后将使用它-它也使我们可以从一系列值中进行选择,但不太精确。

步进电机是足够聪明,将工作与任何类型的号码类型的你喜欢-你可以绑定他们IntDouble和更多的,它会自动适应。例如,我们可以创建如下属性:

1
@State private var sleepAmount = 8.0

然后,我们可以将其绑定到步进器,以使其显示当前值,如下所示:

1
2
3
Stepper(value: $sleepAmount) {
Text("\(sleepAmount) hours")
}

该代码运行时,您会看到800万小时,然后可以按-和+向下跳至7、6、5并变为负数,或者向上跳至9、10、11,依此类推。

默认情况下,步进器仅受其存储范围的限制。Double在此示例中,我们使用a ,这意味着滑块的最大值将为1.7976931348623157e + 308。这是科学的表示法,但是它的意思是“ 1.79769乘以10的308的幂” –或更简单地说,确实是一个非常大的数字。

Stepper让我们通过提供一个in范围来限制我们想要接受的值,如下所示:

1
2
3
Stepper(value: $sleepAmount, in: 4...12) {
Text("\(sleepAmount) hours")
}

有了这一更改,步进器将从8开始,然后允许用户在4到12(含)之间移动,但不能超过。这使我们可以控制睡眠范围,以使用户无法尝试24小时不睡觉,但也可以让我们拒绝不可能的值-例如,您不能睡眠-1小时。

第三个有用的参数是Stepper,它是一个step值-每次将值移动多远-或+被点击。同样,它可以是任何类型的数字,但确实需要匹配用于绑定的类型。因此,如果要绑定到整数,则不能使用a Double作为步长值。

在这种情况下,我们可以说用户可以选择4到12之间的任何睡眠值,以15分钟为增量移动:

1
2
3
Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
Text("\(sleepAmount) hours")
}

这开始看起来很有用–我们拥有合理范围的精确值,合理的步长增量,并且用户每次都能准确看到他们选择的内容。

不过,在继续之前,让我们修复该文本:它现在显示为8.000000,这是准确的,但有点过于精确。以前,我们使用如下字符串内插说明符:

1
Text("\(sleepAmount, specifier: "%.2f") hours")

我们可以在这里使用它,但看起来很奇怪:“ 8.00小时”似乎过于临床。这是“%g”说明符在其中有用的一个很好的示例,因为它会自动从数字末尾删除不重要的零。因此,它将显示8、8.25、8.5、8.75、9,依此类推,这对于用户而言更自然。

使用DatePicker

SwiftUI为我们提供了一种专用的选择器类型DatePicker,该类型可以绑定到date属性。是的,Swift有一个专用的日期类型,它被称为–毫不奇怪 Date

因此,要使用它,您将从一个@State这样的属性开始:

1
@State private var wakeUp = Date()

然后,您可以将其绑定到日期选择器,如下所示:

1
2
3
var body: some View {
DatePicker("Please enter a date", selection: $wakeUp)
}

尝试在模拟器中运行该代码,以便查看其外观。您应该会看到带有日期和时间的手纺车轮,以及左侧的“请输入日期”标签。

现在,您可能会认为该标签看起来丑陋,并尝试用以下标签替换它:

1
DatePicker("", selection: $wakeUp)

但是,如果这样做,您将遇到两个问题:日期选择器即使标签为空也仍然为标签留出空间,并且屏幕阅读器处于活动状态的用户(我们对VoiceOver更为熟悉)不会知道日期是什么选择器是为。

有两种选择,都可以解决问题。

首先,我们可以将包裹在DatePickerForm

1
2
3
4
5
var body: some View {
Form {
DatePicker("Please enter a date", selection: $wakeUp)
}
}

就像常规一样,Picker这会改变SwiftUI呈现视图的方式。但是,这次我们没有得到新的观点NavigationView。取而代之的是,我们得到一个列表行,当点击它时它会折叠成日期选择器。

这看起来非常好,并且将表单的简洁性与日期选择器熟悉的基于轮的用户界面相结合。可悲的是,现在这些选择器的显示方式有时会出现一些故障。我们稍后再讲。

除了使用表单,还可以使用labelsHidden()修饰符,如下所示:

1
2
3
4
var body: some View {
DatePicker("Please enter a date", selection: $wakeUp)
.labelsHidden()
}

该标签仍包含原始标签,因此屏幕阅读器可以将其用于VoiceOver,但现在它们在屏幕上不再可见-日期选择器将占据屏幕上的所有水平空间。

日期选择器为我们提供了几个配置选项,以控制它们的工作方式。首先,我们可以displayedComponents用来决定用户应该看到哪种选项:

  • 如果不提供此参数,则用户会看到一天,一小时和一分钟。
  • 如果使用.date用户,请查看月,日和年。
  • 如果使用.hourAndMinute用户,则仅会看到小时和分钟。

因此,我们可以选择这样的精确时间:

1
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)

最后,有一个in参数与之作用相同Stepper:我们可以为它提供一个日期范围,并且日期选择器将确保用户不能选择超出范围。

现在,我们已经使用一段时间了,您已经习惯于看到诸如1 ... 5或的东西0 ..< 10,但是我们也可以将Swift日期与范围一起使用。例如:

1
2
3
4
5
6
7
8
// when you create a new Date instance it will be set to the current date and time
let now = Date()

// create a second Date instance set to one day in seconds from now
let tomorrow = Date().addingTimeInterval(86400)

// create a range from those two
let range = now ... tomorrow

确实对有用DatePicker,但还有更好的地方:Swift让我们形成一个单边范围 –我们指定起始或结束但不同时指定两个范围的范围,而让Swift推断另一边。

例如,我们可以这样创建一个日期选择器:

1
DatePicker("Please enter a date", selection: $wakeUp, in: Date()...)

这将允许将来使用所有日期,但不能使用过去的日期-读作“从当前日期到任何日期”。

DateComponents

Swift为此有一种略有不同的类型,称为DateComponents,它使我们可以读取或写入日期的特定部分而不是整个内容。

因此,如果我们想要一个表示今天上午8点的日期,我们可以编写如下代码:

1
2
3
4
var components = DateComponents()
components.hour = 8
components.minute = 0
let date = Calendar.current.date(from: components)

现在,由于日期验证方面的困难,该date(from:)方法实际上返回了一个可选日期,因此最好使用nil合并来表示“如果失败,请给我返回当前日期”,如下所示:

1
let date = Calendar.current.date(from: components) ?? Date()

第二个挑战是我们如何阅读他们想要醒来的时间。请记住,DatePicker这势必Date会给我们带来很多信息,因此我们需要找到一种仅提取小时和分钟组成部分的方法。

再一次,您DateComponents需要进行救援:我们可以要求iOS从某个日期开始提供特定的组件,然后再将其读取。一个令人困扰的事情是,我们要求的值和由于工作方式而获得的值之间存在脱节DateComponents:我们可以要求小时和分钟,但是我们将返回一个DateComponents实例,该实例的所有属性都带有可选值。是的,我们知道小时和分钟会在那儿,因为这些正是我们所要求的,但是我们仍然需要拆开可选件或提供默认值。

因此,我们可能会编写如下代码:

1
2
3
let components = Calendar.current.dateComponents([.hour, .minute], from: someDate)
let hour = components.hour ?? 0
let minute = components.minute ?? 0

DateFormatter

最后的挑战是如何格式化日期和时间,而Swift再次为我们提供了一种特定的类型来为我们完成大部分工作。这次称为DateFormatter,它使我们可以通过多种方式将日期转换为字符串。

例如,如果我们只想从日期开始的时间,则可以这样写:

1
2
3
let formatter = DateFormatter()
formatter.timeStyle = .short
let dateString = formatter.string(from: Date())

我们还可以设置.dateStyle为获取日期值,甚至可以使用完全自定义格式进行传递dateFormat,但这超出了该项目的范围!


\.self

在这个项目中,我们将使用List稍有不同的方法,因为我们将使它在字符串数组上循环。我们已经使用ForEach了很多范围,无论是硬编码(0..<5)还是依赖变量数据(0 ..< students.count),而且效果很好,因为SwiftUI可以根据其在范围内的位置唯一地标识每一行。

当处理一组数据时,SwiftUI仍然需要知道如何唯一地标识每一行,因此,如果删除了一行,则可以简单地删除该行,而不必重新绘制整个列表。这是传入id参数的地方,并且在这两者中都相同,List并且ForEach–使我们能够准确地告诉SwiftUI是什么使数组中的每个项目变得唯一。

当使用字符串和数字数组时,使这些值唯一的是值本身。也就是说,如果我们有array [2, 4, 6, 8, 10],那么这些数字本身就是唯一的标识符。毕竟,我们没有其他可使用的东西了!

当使用这种列表数据时,我们将使用id: \.self以下代码:

1
2
3
4
5
6
7
8
9
struct ContentView: View {
let people = ["Finn", "Leia", "Luke", "Rey"]

var body: some View {
List(people, id: \.self) {
Text($0)
}
}
}

与相同ForEach,因此,如果我们想混合使用静态行和动态行,则可以这样写:

1
2
3
4
5
List {
ForEach(people, id: \.self) {
Text($0)
}
}

NUM

Here's the new function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func getHaterStatus(weather: WeatherType) -> String? {
switch weather {
case .sun:
return nil
case .wind(let speed) where speed < 10:
return "meh"
case .cloud, .wind:
return "dislike"
case .rain, .snow:
return "hate"
}
}

getHaterStatus(weather: WeatherType.wind(speed: 5))

You can see .wind appears in there twice, but the first time is true only if the wind is slower than 10 kilometers per hour. If the wind is 10 or above, that won't match. The key is that you use let to get hold of the value inside the enum (i.e. to declare a constant name you can reference) then use a wherecondition to check.

Property observers

Swift lets you add code to be run when a property is about to be changed or has been changed. This is frequently a good way to have a user interface update when a value changes, for example.

There are two kinds of property observer: willSet and didSet, and they are called before or after a property is changed. In willSet Swift provides your code with a special value called newValue that contains what the new property value is going to be, and in didSet you are given oldValue to represent the previous value.

Let's attach two property observers to the clothes property of a Person struct:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Person {
var clothes: String {
willSet {
updateUI(msg: "I'm changing from \(clothes) to \(newValue)")
}

didSet {
updateUI(msg: "I just changed from \(oldValue) to \(clothes)")
}
}
}

func updateUI(msg: String) {
print(msg)
}

var taylor = Person(clothes: "T-shirts")
taylor.clothes = "short skirts"

That will print out the messages "I'm changing from T-shirts to short skirts" and "I just changed from T-shirts to short skirts."

Computed properties

It's possible to make properties that are actually code behind the scenes. We already used the uppercased() method of strings, for example, but there’s also a property called capitalized that gets calculated as needed, rather than every string always storing a capitalized version of itself.

To make a computed property, place an open brace after your property then use either get or set to make an action happen at the appropriate time. For example, if we wanted to add a ageInDogYearsproperty that automatically returned a person's age multiplied by seven, we'd do this:

1
2
3
4
5
6
7
8
9
10
11
12
struct Person {
var age: Int

var ageInDogYears: Int {
get {
return age * 7
}
}
}

var fan = Person(age: 25)
print(fan.ageInDogYears)

Computed properties are increasingly common in Apple's code, but less common in user code.

Note: If you intend to use them only for reading data you can just remove the get part entirely, like this:

1
2
3
var ageInDogYears: Int {
return age * 7
}

Environment modifier

As an example, this shows our four text views with the title font, but one has a large title:

1
2
3
4
5
6
7
8
VStack {
Text("Gryffindor")
.font(.largeTitle)
Text("Hufflepuff")
Text("Ravenclaw")
Text("Slytherin")
}
.font(.title)

There, font() is an environment modifier, which means the Gryffindor text view can override it with a custom font.

However, this applies a blur effect to the VStack then attempts to disable blurring on one of the text views:

1
2
3
4
5
6
7
8
VStack {
Text("Gryffindor")
.blur(radius: 0)
Text("Hufflepuff")
Text("Ravenclaw")
Text("Slytherin")
}
.blur(radius: 5)

That won’t work the same way: blur() is a regular modifier, so any blurs applied to child views are added to the VStack blur rather than replacing it.

5. String

components

Swift提供了一种称为的方法components(separatedBy:),该方法可以通过在找到另一个字符串的地方将其分解来将单个字符串转换为字符串数组。例如,这将创建数组["a", "b", "c"]

1
2
let input = "a b c"
let letters = input.components(separatedBy: " ")

TrimmingCharacter

另一个有用的字符串方法是trimmingCharacters(in:),它要求Swift从字符串的开头和结尾删除某些种类的字符。这使用了一种称为的新类型CharacterSet,但是大多数时候我们想要一种特殊的行为:删除空格和换行符–指的是同时包含空格,制表符和换行符。

这种行为非常普遍,它内置在CharacterSet结构中,因此我们可以要求Swift在字符串的开头和结尾处修剪所有空格,如下所示:

1
let trimmed = letter?.trimmingCharacters(in: .whitespacesAndNewlines)

### random

但是Swift提供了另一个有用的选择:该randomElement()方法从数组中返回一个随机项。

例如,这将从我们的数组中读取一个随机字母:

1
let letter = letters.randomElement()

现在,尽管我们可以看到字母数组将包含三个项目,但是Swift并不知道这一点–例如,也许我们试图拆分一个空字符串。结果,该randomElement()方法返回一个可选字符串,我们必须将其解开或与nil合并一起使用。

UITextChecker

检查拼写错误的单词的能力。

该功能通过类提供UITextChecker。您可能没有意识到这一点,但是该名称的“ UI”部分带有两个附加含义:

  1. 此类来自UIKit。但是,这并不意味着我们正在加载所有旧的用户界面框架。我们实际上是通过SwiftUI自动获取的。
  2. 它是使用Apple的较旧语言Objective-C编写的。我们不需要编写Objective-C来使用它,但是对于Swift用户来说,API有点笨拙。

检查字符串中拼写错误的单词总共需要四个步骤。首先,我们创建一个要检查的单词以及一个UITextChecker可以用来检查该字符串的实例:

1
2
let word = "swift"
let checker = UITextChecker()

其次,我们需要告诉检查器我们要检查多少字符串。如果您想象一个文字处理应用程序中的拼写检查器,则可能只想检查用户选择的文本,而不是整个文档。

但是,有一个陷阱:Swift使用非常聪明,非常先进的字符串处理方式,从而使其可以使用复杂字符(例如表情符号)的方式与使用英语字母的方式完全相同。然而,Objective-C中并没有使用存储字母的这种方法,这意味着我们需要问swift利用我们的所有字符的整个长度,这样创造一个Objective-C字符串范围:

1
let range = NSRange(location: 0, length: word.utf16.count)

UTF-16是所谓的字符编码 -一种将字母存储在字符串中的方法。我们在这里使用它,以便Objective-C可以了解Swift的字符串是如何存储的;对于我们来说,这是一种很好的桥接格式。

第三,我们可以要求文本检查器报告在单词中发现任何拼写错误的地方,传递要检查的范围,在该范围内开始的位置(因此我们可以执行“查找下一个”之类的操作),是否应该换行一旦到达末尾,以及字典使用哪种语言:

1
let misspelledRange = checker.rangeOfMisspelledWord(in: word, range: range, startingAt: 0, wrap: false, language: "en")

这会返回另一个Objective-C字符串范围,告诉我们在哪里发现了拼写错误。即使那样,这里仍然存在一个复杂性:Objective-C没有任何可选概念,因此依赖于特殊值来表示丢失的数据。

在这种情况下,如果Objective-C范围返回为空(即,因为字符串正确拼写而没有拼写错误),那么我们将返回特殊值NSNotFound

因此,我们可以检查拼写结果,看是否有这样的错误:

1
let allGood = misspelledRange.location == NSNotFound

6. Alert

基本的SwiftUI警报具有标题,消息和一个关闭按钮,如下所示:

1
Alert(title: Text("Hello SwiftUI!"), message: Text("This is some detail message"), dismissButton: .default(Text("OK")))

如果需要,您可以添加更多代码来更详细地配置按钮,但这已经足够了。更有趣的是我们如何显示警报:我们不将警报分配给变量,而是编写类似之类的内容myAlert.show(),因为这将支持旧的“事件系列”思维方式。

相反,我们创建一些状态来跟踪警报是否显示,如下所示:

1
@State private var showingAlert = false

然后,我们将警报附加到用户界面的某处,告诉它使用该状态来确定是否显示警报。SwiftUI将监视showingAlert,一旦变为true,它将显示警报。

放在一起,下面是一些示例代码,当点击按钮时会显示警报:

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

var body: some View {
Button("Show Alert") {
self.showingAlert = true
}
.alert(isPresented: $showingAlert) {
Alert(title: Text("Hello SwiftUI!"), message: Text("This is some detail message"), dismissButton: .default(Text("OK")))
}
}
}

这会将警报附加到按钮上,但是说实话,在何处使用alert()修饰符都没关系–我们要做的只是说警报存在,并且在showingAlerttrue 时显示。

仔细看一下alert()修饰符:

1
.alert(isPresented: $showingAlert)

这是另一种双向数据绑定,这是因为在取消showingAlert警报时,SwiftUI会自动设置为false。

7. Sheet

sheet()是和一样的修饰符alert(),因此请立即将此修饰符添加到我们的按钮中:

1
2
3
.sheet(isPresented: $showingSheet) {
// contents of the sheet
}

无论如何,这称为@Environment,它使我们能够创建用于存储外部提供给我们的值的属性。用户处于亮模式还是暗模式?他们是否要求较小或较大的字体?他们在哪个时区?所有这些以及更多都是来自环境的值,在这种情况下,我们将从环境中读取视图的表示方式

视图的呈现模式仅包含两个数据,但两者都很有用:一个用于存储视图当前是否显示在屏幕上的属性,以及一种让我们立即关闭视图的方法。

要尝试将该属性添加到中SecondView,该属性会创建一个称为的属性,该属性presentationMode附加到存储在应用程序环境中的演示模式变量中:

1
@Environment(\.presentationMode) var presentationMode

现在,SecondView用以下按钮替换文本视图:

1
2
3
Button("Dismiss") {
self.presentationMode.wrappedValue.dismiss()
}

加入的wrappedValue在那里是必需的,因为presentationMode实际上是有约束力的,因此它可以由系统自动更新-我们需要里面挖检索实际演示模式为我们关闭该视图。

8. onDelete

onDelete()修饰符仅存在于上ForEach,因此,如果我们希望用户从列表中删除项目,则必须将项目放在内ForEach。当我们只有动态行时,这的确意味着少量的额外代码,但另一方面,这意味着创建仅可以删除某些行的列表会更容易。

为了进行onDelete()工作,我们需要实现一个方法,该方法将接收type的单个参数IndexSet。这有点像一组整数,除了它是经过排序的,它只是告诉我们ForEach应该删除的所有项目的位置。

由于我们ForEach是完全由单个数组创建的,因此实际上我们可以直接将索引集直接传递给我们的numbers数组-它具有remove(atOffsets:)接受索引集的特殊方法。

因此,ContentView现在添加此方法:

1
2
3
func removeRows(at offsets: IndexSet) {
numbers.remove(atOffsets: offsets)
}

最后,我们可以ForEach通过将它修改为以下方法,告诉SwiftUI要从中删除数据时调用该方法:

1
2
3
4
ForEach(numbers, id: \.self) {
Text("\($0)")
}
.onDelete(perform: removeRows)

现在继续运行您的应用程序,然后添加一些数字。准备就绪后,从右向左在列表中的任何行上滑动,您会发现出现一个删除按钮。您可以点击它,也可以通过进一步滑动来使用iOS的滑动来删除功能。

考虑到那是多么容易,我认为结果确实很好。但是SwiftUI还有另外一个技巧:我们可以在导航栏中添加“编辑/完成”按钮,这使用户可以更轻松地删除几行。

首先,将包裹在VStackNavigationView,然后将此修饰符添加到中VStack

1
.navigationBarItems(leading: EditButton())

9. UserDefault

UserDefaults 非常适合存储用户设置和其他重要数据-您可能会跟踪用户上次启动该应用程序的时间,他们上次阅读的新闻报道或其他被动收集的信息。

但是,有一个陷阱:它是字符串类型。这有点像个玩笑的名字,因为“强类型”表示像Swift这样的类型安全语言,其中每个常量和变量都具有特定类型,例如IntString,而“字符串类型”表示某些代码在它们所处的地方使用字符串可能会引起问题。

让我们看一些代码。这是一个带有按钮的视图,该视图显示拍子计数,并在每次点击该按钮时递增计数:

1
2
3
4
5
6
7
8
9
struct ContentView: View {
@State private var tapCount = 0

var body: some View {
Button("Tap count: \(tapCount)") {
self.tapCount += 1
}
}
}

因为这显然是一个非常重要的应用程序,所以我们希望保存用户做出的点击次数,因此,当他们将来再次使用该应用程序时,可以从上次停止的地方接听。

好吧,做到这一点只需要做两个改变。首先,我们需要将抽头计数写入UserDefaults更改的位置,因此请在self.tapCount += 1 以下添加:

1
UserDefaults.standard.set(self.tapCount, forKey: "Tap")

在那一行代码中,您可以看到三件事:

  1. 我们需要使用UserDefaults.standard。这是UserDefaults连接到我们应用程序的内置实例,但是在更高级的应用程序中,您可以创建自己的实例。例如,如果要在多个应用程序扩展中共享默认设置,则可以创建自己的UserDefaults实例。
  2. 有一个set()方法可以接受任何类型的数据-整数,布尔值,字符串等。
  3. 我们在此数据上附加一个字符串名称,在本例中为“ Tap”键。就像常规的Swift字符串一样,此键区分大小写,并且很重要–我们需要使用相同的键从中读取数据UserDefaults

说回读数据,而不是从tapCount设置为0 开始,相反,我们应该使它UserDefaults像这样从以下位置读回值:

1
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap")

注意,它如何使用完全相同的键名,以确保它读取相同的整数值。

继续尝试一下该应用程序,看看您的想法-您应该能够轻按几次该按钮,返回Xcode,再次运行该应用程序,然后查看确切的数字。

有两件事情你不能在代码中看到的,但两者的事情。首先,如果我们没有设置“ Tap”键,会发生什么?第一次运行该应用程序时就是这种情况,但是正如您刚刚看到的那样,它可以正常工作–如果找不到密钥,它只会发送回0。

10.Codable

Codable:一种专门用于存档取消存档数据的协议,这是一种“将对象转换为纯文本然后再次转换”的奇特方式。

我们将Codable在未来的项目中进行更多的研究,但是目前我们的需求很简单:我们想要归档一个自定义类型,以便我们可以将其放入UserDefaults,然后在从中退出时对其进行归档UserDefaults

当使用仅具有简单属性的类型(字符串,整数,布尔值,字符串数组等)时,我们需要做的唯一支持归档和取消归档的就是向Codable,添加一个一致性,如下所示:

1
2
3
4
struct User: Codable {
var firstName: String
var lastName: String
}

Swift会自动为我们生成一些代码,这些代码将User根据需要为我们存档和取消存档实例,但是我们仍然需要告诉Swift 何时存档以及如何处理数据。

该过程的这一部分由称为的新类型提供支持JSONEncoder。它的工作是获取符合条件的东西,Codable然后以JavaScript Object Notation(JSON)的形式发送回该对象-该名称暗示该对象特定于JavaScript,但实际上,我们都使用它,因为它是如此快速和简单。

Codable协议不需要我们使用JSON,实际上其他格式也可以使用,但这是迄今为止最常见的格式。在这种情况下,我们实际上并不在乎使用哪种数据,因为它们只会存储在中UserDefaults

要将user数据转换为JSON数据,我们需要在上调用encode()方法JSONEncoder。这可能会引发错误,因此应使用trytry?巧妙地调用它。例如,如果我们有一个属性来存储User实例,如下所示:

1
@State private var user = User(firstName: "Taylor", lastName: "Swift")

然后,我们可以创建一个将用户存档并将其保存为UserDefaults这样的按钮:

1
2
3
4
5
6
7
Button("Save User") {
let encoder = JSONEncoder()

if let data = try? encoder.encode(self.user) {
UserDefaults.standard.set(data, forKey: "UserData")
}
}

data常数是一种新的数据类型,可能会引起混淆Data。它旨在存储您可以想到的任何类型的数据,例如字符串,图像,zip文件等。不过,在这里,我们只关心它是可以直接写入的数据类型之一UserDefaults

当我们返回另一种方式时(当我们拥有JSON数据并且想要将其转换为Swift Codable类型时),我们应该使用JSONDecoder而不是JSONEncoder(),但是过程大致相同。

实例

储存:

1
2
3
4
5
6
7
8
@Published var items: [ExpenseItem] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}

读取:

1
2
3
4
5
6
7
8
9
10
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ExpenseItem].self, from: items) {
self.items = decoded
return
}
}

self.items = []

11. UUID与Indentifiable

其实也有一个简单的解决方案,它叫做UUID-短期的“通用唯一标识符”,如果没有独特的声音,我不知道该怎么做。

UUID是较长的十六进制字符串,例如:08B15DB4-2F02-4AB8-A965-67A9C90D8A44。因此,这是八位数字,四位数字,四位数字,四位数字,然后是十二位数字,其中唯一的要求是在第三块的第一个数字中有一个4。如果减去固定的4,我们最终得到31个数字,每个数字可以是16个值之一–如果在十亿年的时间里每秒产生1个UUID,则可能有最小的机会产生重复的数字。

现在,我们可以更新ExpenseItem为具有以下UUID属性:

1
2
3
4
5
6
struct ExpenseItem {
let id: UUID
let name: String
let type: String
let amount: Int
}

那会起作用。但是,这也意味着我们需要手工生成一个UUID,然后加载并保存UUID以及其他数据。因此,在这种情况下,我们将要求Swift UUID像这样自动为我们生成一个:

1
2
3
4
5
6
struct ExpenseItem {
let id = UUID()
let name: String
let type: String
let amount: Int
}

现在,我们无需担心id费用项目的价值– Swift将确保它们始终是唯一的。

有了这个,我们现在可以修复ForEach,如下所示:

1
2
3
ForEach(expenses.items, id: \.id) { item in
Text(item.name)
}

如果现在运行该应用程序,您将看到我们的问题已解决:SwiftUI现在可以准确地看到删除了哪个费用项目,并将正确地动画化所有内容。

不过,我们尚未完成此步骤。相反,我希望您修改ExpenseItem使其符合名为的新协议Identifiable,如下所示:

1
2
3
4
5
6
struct ExpenseItem: Identifiable {
let id = UUID()
let name: String
let type: String
let amount: Int
}

我们所做的只是添加Identifiable到协议一致性列表中,仅此而已。这是Swift内置的协议之一,表示“可以唯一地标识此类型”。它只有一个要求,那就是必须有一个id包含唯一标识符的称为属性。我们只是添加了它,所以我们不需要做任何额外的工作–我们的类型Identifiable就可以了。

现在,您可能想知道为什么要添加它,因为我们的代码之前运行良好。好吧,因为现在可以保证我们的费用项目是唯一可识别的,所以我们不再需要告诉ForEach标识符要使用哪个属性-它知道将有一个id属性并且它将是唯一的,因为这就是Identifiable协议的重点。

因此,由于此更改,我们可以ForEach再次对此进行修改:

1
2
3
ForEach(expenses.items) { item in
Text(item.name)
}

12. ScrollView

例如,我们可以创建一个包含100个文本视图的滚动列表,如下所示:

1
2
3
4
5
6
7
8
ScrollView(.vertical) {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
}

如果您在模拟器中重新运行该代码,则会看到可以自由拖动滚动视图,如果滚动到底部,您还将看到ScrollView将安全区域视为ListForm–其内容位于home指示器下方,但是它们添加了一些额外的填充,因此最终视图是完全可见的。

您可能还会注意到,直接在中心点击有点烦人-整个区域都可滚动更常见。为了获得这种效果,我们应该VStack占用更多空间,同时保持默认的中心对齐不变,如下所示:

1
2
3
4
5
6
7
8
9
ScrollView(.vertical) {
VStack(spacing: 10) {
ForEach(0..<100) {
Text("Item \($0)")
.font(.title)
}
}
.frame(maxWidth: .infinity)
}

现在,您可以在屏幕上的任意位置点击并拖动,这更加方便了用户。

这一切似乎都非常简单,而且确实ScrollViewUIScrollView我们必须与UIKit一起使用的旧版本要容易得多。但是,您需要注意一个重要的问题:将视图添加到滚动视图时,它们会立即创建。

为了说明这一点,我们可以围绕常规文本视图创建一个简单的包装,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
struct CustomText: View {
var text: String

var body: some View {
Text(text)
}

init(_ text: String) {
print("Creating a new CustomText")
self.text = text
}
}

现在我们可以在我们的内部使用它ForEach

1
2
3
4
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}

结果看起来相同,但是现在当您运行该应用程序时,您会在Xcode的日志中看到打印了一百次的“正在创建新的CustomText” – SwiftUI不会等到您向下滚动才能看到它们,它会立即创建它们。

您可以使用尝试相同的实验List,如下所示:

1
2
3
4
5
6
List {
ForEach(0..<100) {
CustomText("Item \($0)")
.font(.title)
}
}

代码运行时您会发现它的行为是懒惰的:它CustomText仅在真正需要时才创建实例。

1
2
3
4
5
6
7
8
NavigationView {
VStack {
NavigationLink(destination: Text("Detail View")) {
Text("Hello World")
}
}
.navigationBarTitle("SwiftUI")
}

现在运行代码,看看您的想法。您会看到“ Hello World”现在看起来像一个按钮,点击它会在右侧显示一个新视图,显示为“详细信息视图”。更好的是,您会看到“ SwiftUI”标题会向下移动以使其成为后退按钮,您可以点击该按钮或从左边缘滑动以返回。

所以,无论是sheet()NavigationLink允许我们显示从当前一个新的观点,但这样他们这样做是不同的,你应该仔细选择他们:

  • NavigationLink 用于显示有关用户选择的详细信息,就像您正在深入探讨主题一样。
  • sheet() 用于显示不相关的内容,例如设置或撰写窗口。

您看到的最常见的地方NavigationLink是列表,SwiftUI的功能非常出色。

尝试将您的代码修改为此:

1
2
3
4
5
6
7
8
NavigationView {
List(0..<100) { row in
NavigationLink(destination: Text("Detail \(row)")) {
Text("Row \(row)")
}
}
.navigationBarTitle("SwiftUI")
}

现在运行该应用程序时,您将看到100个可以点击以显示详细视图的列表行,但是您还将在右侧看到灰色的显示指示器。这是一种标准的iOS方法,告诉用户在点击该行时另一个屏幕将从右侧滑入,SwiftUI足够聪明,可以在此处自动添加它。如果这些行不是导航链接-如果您注释掉该NavigationLink行及其右括号,您会看到指示消失。

Animation

ScaleEffect()

You provide this with a value from 0 up, and it will be drawn at that size – a value of 1.0 is equivalent to 100%,


CGFloat

由于历史原因,主要是与Apple的旧API交互的原因,我们需要使用一种称为的特定数据类型CGFloat

CGFloat出于各种意图和目的,它是Double一个不同的名称,但在较旧的硬件上,它使用的数字存储类型较小,称为Float。当这个选择很重要时,CGFloatApple不必在乎我们要为哪种类型的硬件建造,但如今几乎所有东西都在使用,Double因此,厌恶地盯着我们只是一小块遗产。

无论如何,所有这些都很重要,因为如果我们使该属性var animationAmount = 1得到一个整数,并且如果我们使用它,var animationAmount = 1.0那么我们得到一个Double,但是没有内置的方法来CGFloat自动获得–我们需要使用类型注释。

因此,请立即将此属性添加到您的视图中:

1
@State private var animationAmount: CGFloat = 1

3D旋转与withAnimation

这需要另一个新的修改器,rotation3DEffect()可以为其指定以度为单位的旋转量以及确定视图旋转方式的轴。通过您的视图将这条轴想成一串:

  • we skewer the view through the X axis (horizontally) then it will be able to spin forwards and backwards.
  • If we skewer the view through the Y axis (vertically) then it will be able to spin left and right.
  • If we skewer the view through the Z axis (depth) then it will be able to rotate left and right.

进行这项工作需要我们可以修改的某些状态,并且旋转度指定为Double。因此,请立即添加此属性:

1
@State private var animationAmount = 0.0

接下来,我们将要求按钮animationAmount沿其Y轴旋转角度,这意味着它将向左和向右旋转。现在将此修饰符添加到按钮:

1
.rotation3DEffect(.degrees(animationAmount), axis: (x: 0, y: 1, z: 0))

现在是重要部分:我们将在按钮的动作中添加一些代码,以便在animationAmount每次点击时将其添加360 。

如果我们只写,self.animationAmount += 360那么更改将立即发生,因为按钮上没有附加动画修改器。这是显式动画出现的地方:如果我们使用withAnimation()闭包,那么SwiftUI将确保由新状态引起的任何更改都将自动进行动画处理。

因此,现在将其放入按钮的操作中:

1
2
3
withAnimation {
self.animationAmount += 360
}

现在运行该代码,我认为它的外观会给您留下深刻的印象–每次点击该按钮,它就会在3D空间中旋转,并且编写起来非常容易。如果有时间,请对轴进行一些试验,以使您真正了解它们的工作原理。如果您感到好奇,可以一次使用多个轴。

withAnimation()可以使用可以在SwiftUI中其他位置使用的所有相同动画来赋予动画参数。例如,我们可以使用如下withAnimation()调用来使旋转效果使用spring动画:

1
2
3
withAnimation(.interpolatingSpring(stiffness: 5, damping: 1)) {
self.animationAmount += 360
}

.animation 必须放在所有需要附加动画的modifier之后

可以使用多个animation,每个animation控制之前的动画。

如果animation(nil)则没有动画,但是不会被之后的animation影响


Geometry

GeometryReader

现在,让我们在屏幕上绘制该图像:

1
2
3
4
5
6
7
struct ContentView: View {
var body: some View {
VStack {
Image("Example")
}
}
}

即使在预览中,您也可以看到对于可用空间来说太大了。图像frame()与其他视图具有相同的修饰符,因此您可以尝试按如下所示将其缩小:

1
2
Image("Example")
.frame(width: 300, height: 300)

但是,这将行不通–您的图片仍将显示为原尺寸。如果您想知道为什么,请仔细查看预览窗口:您会看到图像已放大,但是中间有一个300x300的蓝色框。的图像视图的帧已被正确设定,但内容的图像仍显示为原始大小。

尝试将图像更改为此:

1
2
3
Image("Example")
.frame(width: 300, height: 300)
.clipped()

现在您将更清楚地看到事情:我们的图像视图确实是300x300,但这并不是我们想要的。

如果您也想调整图像内容的大小,则需要使用如下resizable()修饰符:

1
2
3
Image("Example")
.resizable()
.frame(width: 300, height: 300)

更好,但仅此而已。是的,现在可以正确调整图像的大小,但是可能看起来像是被压扁了。我的图像不是方形的,因此,由于它已被调整为方形,因此看上去有些失真。

要解决此问题,我们需要要求图像按比例调整自身大小,这可以使用aspectRatio()修饰符来完成。这可以让我们提供确切的宽高比以及应如何应用,但是如果我们跳过宽高比本身,SwiftUI将自动使用原始的宽高比。

当谈到“应该如何应用”部分,SwiftUI称此为内容的模式,给我们两个选择:.fit意味着整个图像放在容器内,即使这意味着离开视图的某些部分清空,并且.fill手段视图将没有空白部分,即使这意味着我们的某些图像位于容器外部。

尝试它们两者,以自己了解差异。这是.fit应用的模式:

1
2
3
4
Image("Example")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 300, height: 300)

这是.fill应用模式:

1
2
3
4
Image("Example")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 300, height: 300)

如果我们要使用固定尺寸的图像,那么所有这些方法都很好用,但是通常您希望图像能够自动放大以一维或二维填充屏幕。也就是说,您真正要说的不是“ 硬编码300的宽度”,而是“使此图像充满屏幕的宽度”。

SwiftUI为此提供了专用类型,称为GeometryReader,并且功能非常强大。是的,我知道许多SwiftUI功能强大,但老实说:您可以做的事GeometryReader会让您震惊。

我们将GeometryReader在项目15中进行更详细的介绍,但现在,我们将其用于一项工作:确保图像填充其容器视图的整个宽度。

GeometryReader与我们使用过的其他视图一样,它是一个视图,除了在创建视图时,我们将获得一个GeometryProxy要使用的对象。这使我们可以查询环境:容器有多大?我们的立场是什么?是否有安全区域插图?等等。

我们可以使用此几何代理设置图像的宽度,如下所示:

1
2
3
4
5
6
7
8
VStack {
GeometryReader { geo in
Image("Example")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geo.size.width, height: 300)
}
}

现在,无论我们使用哪种设备,图像都将占据我们屏幕的整个宽度。

对于我们的最后一个技巧,让我们height从图像中删除,如下所示:

1
2
3
4
5
6
7
8
VStack {
GeometryReader { geo in
Image("Example")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geo.size.width)
}
}

我们为SwiftUI提供了足够的信息,它可以自动计算出高度:它知道原始宽度,知道我们的目标宽度,并且知道我们的内容模式,因此它了解图像的目标高度如何与图像的高度成比例。目标宽度。

Path

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()修饰符在路径周围画图,而不用填充它:

1
.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))

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

Shape

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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值)和midXminX和之间的中点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度的弧度。

这里发生了两件事:

  1. 在SwiftUI看来,0度不是笔直向上,而是直接向右。
  2. 形状从左下角而不是左上角测量其坐标,这意味着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
}