其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记
状态
在上一章,我们介绍了SwiftUI的主要特性,声明式语法。借助SwiftUI,我们可以按希望在屏幕上显示的方式声明视图,余下交由系统来创建所需的代码。但声明式语法不只用于组织视图,还可在应用状态发生变化时更新视图。例如,我们可以有下面图6-1中的界面,显示标题的Text
视图,用户输入新标题的输入字段以及将旧标题替换成新标题的按钮。原标题的Text
视图表示我们界面的初始状态。用户在输入框中输入每个字符时状态都会发生更新(图6-1左图),点击按钮时,界面进入一个新状态,用户插入的标题会替换原标题,文本的颜色也发生变化(图6-1右图)。
图6-1:用户界面
每次状态发生改变时,必须更新视图来进行反馈。在之前的系统中,这要求代码保持数据及界面同步,但在声明式语法中我们只需要声明每个状态的视图配置,系统会负责生成在屏幕上显示这些改变所需的代码。
界面可能经历的状态由存储在应用中的信息决定。例如,用户在输入框架中插入的字符以及示例中使用的颜色都是存储在应用中的值。每当这些值发生改变时,应用都会进入新状态,因此界面会发生更新进行反馈。建立应用数据与界面之间的依赖需要大量的代码,但SwiftUI通过属性包装器让其保持简单。
@State
在第3章中讨论过,属性包装器让我们可以定义用赋给它们的值定义可执行任务的属性。SwiftUI实现了大量的属性包装器来存储值并向视图上报修改。设计用于存储单个视图状态的名为@State
。这个属性包装器将值存储在类型为State
的结构体中,并在值发生改变时通知系统,这样视图会自动更新来在屏幕中进行反映。
属性包装器@State
是设计用于存储单个视图的状态的。因此,我们应将这个类型的属性声明为视图结构体的一部分,并使用private
,这样访问就可以限定在所声明的结构体内了。
示例6-1:定义一个状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @State private var title: String = "Default Title" var body: some View { VStack { Text(title) .padding(10) Button(action: { title = "My New Title" }, label: { Text("Change Title") }) Spacer() }.padding() } } |
示例6-1中的代码声明了一个String
类型名为title
的@State
属性。该属性使用"Default Title"
值进行初始化。在视图内容中,我们在垂直堆叠中以Text
视图显示这个值,并在其下放了一个Button
视图来修改其值。稍后我们会学习Button
视图,但现在读者只需要知道Button
视图显示一个标签并在用户点击按钮时操作一个操作。为展示标签,我们使用带"Change Title"
文本的Text
视图来让用户知道按钮的作用,并定义好操作,我们提供一个闭包修改title
属性的值为"My New Title"
,这样在点击按钮时标题就会发生修改。
使用@State
包装器创建的title
属性在两个地方用到了,第一个是向用户显示当前值的Text
视图,第二是Button
视图中修改其值的操作。因此,每交点击按钮时,title
属性的值会发生改变化,@State
属性包装器通知系统应用的状态发生的变化,body
属性的内容自动刷新在屏幕上显示新值。
图6-2:初始状态(左)和点击按钮后的状态(右)
✍️跟我一起做:创建一个多平台项目。使用示例6-1中的代码更新ContentView
视图。确保对画布启用了实时预览(图5-18,1号图)。点击Change Title按钮将字符串赋值给Text
视图。会看到像图6-2右图中的效果。
整个过程是自动完成的。我们不用对Text
视图赋新值或是告诉该视图新的值,这一切都由@State
属性包装器处理。我们可以包含多个存储界面状态的@State
属性。例如,下例中我们对视图添加了一个Bool
类型的@State
属性,在每次点击按钮时为title
属性赋不同的文本。
示例6-2:定义多个状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
struct ContentView: View { @State private var title: String = "Default Title" @State private var isValid: Bool = true var body: some View { VStack { Text(title) .padding(10) Button(action: { isValid.toggle() title = isValid ? "Valid" : "Invalid" }, label: { Text("Change Validation") }) Spacer() }.padding() } } |
isValid
属性存储表示当前校验状态的布尔值,这样可以在屏幕上显示相应的文本。赋值给title
属性的字符串通过三元表达式来进行选取。使用三元运算符来设置视图是一种推荐的实践,因为它让系统可以获取视图能响应的所有可能的状态,并在状态间产生平滑的过渡。如isValid
的值为true
,将单词”Valid”赋值给title
属性,否则赋值”Invalid”。每次点击按钮时,isValid
属性的值都会发变化,屏幕上会显示不同的文本(参见示例3-55了解更多有关toggle()
方法的信息)。
注意:示例6-2中有两种状态,同时发生改变,但系统会接管这一情况,保障界面仅在需要时发生更新。
@State
属性创建自身和视图之间的依赖,因此在每次值发生改变时视图更新。说法是视图与属性发生了绑定。到目前为止我们使用的都是单向绑定。属性发生修改时视图更新。但也存在视图中值被用户修改,必须要在没有代码介入的情况下将值存回属性的情况。为此,SwiftUI支持我们定义双向绑定。双向绑定声明的方式是在属性名前添加$
符号。
需要双向绑定的视图通常是控制视图,比如创建用户可打开和关闭的开关,或可插入文本的输入框。下例实现了一个TextField
视图来演示这一功能。TextField
视图创建一个输入框。其初始化方法需要的值是字符串及占位符文本,我们用绑定属性来存储用户插入的值。(稍后我们会学习TextField
视图及其它控制视图。)
示例6-3:定义双向绑定
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 title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack { Text(title) .padding(10) TextField("Insert Title", text: $titleInput) .textFieldStyle(.roundedBorder) Button(action: { title = titleInput titleInput = "" }, label: { Text("Change Title") }) Spacer() }.padding() } } |
本例中,我们向视图添加了一个存储用户插入文本的@State
属性,然后在标题和按钮之间定义了一个TextField
视图。TextField
视图使用占位文本”Insert Title”进行初始化,将新的titleInput
属性喂给视图的绑定属性($titleInput
)。这在TextField
视图和属性之间创建了一个永久的连接,每当用户在输入框中输入或删除字符时,新值都会赋值给该属性。
在Button
视图的操作中,我们做了两个修改。首先将titleInput
属性的值赋值给title
属性。这样就会用户插入的文本更新视图标题。然后将空字符会串赋值给titleInput
属性,来清除输入框以便用户再次输入。
✍️跟我一起做:使用示例6-3中的代码更新ContentView
视图。点击输入框,输入文本。按下Change Title按钮。标题就会修改为该段文本。
@Binding
@State
属性属于声明它的结构体,应仅在结构体内部的代码访问(因此我们将其声明为private
),但在第5章中我们学到,在视图急剧增长时,建议将它们分别整合到独立的结构体中。这样整理视图的问题是其它结构体就无法再引用这些@State
属性了,也就无法再读取或修改它们的值了。在其它视图中定义新的@State
属性也不是解决方案,因为这创建的是新状态。我们需要的是建立一个视图中@State
属性与其它视图中代码的双向绑定。为此,SwiftUI内置了Binding
结构体和@Binding
属性包装器。
以下示例和前例相同,但这次我们将Text
和TextField
视图放到一个单独的HeaderView
中。这个视图中包含两个@Binding
属性,用于访问ContentView
视图中的@State
属性,这样处理就是同样的状态了。
示例6-4:使用@Binding
属性
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 |
struct ContentView: View { @State private var title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack { HeaderView(title: title, titleInput: $titleInput) Button(action: { title = titleInput titleInput = "" }, label: { Text("Change Title") }) Spacer() }.padding() } } struct HeaderView: View { var title: String @Binding var titleInput: String var body: some View { VStack { Text(title) .padding(10) TextField("Insert Title", text: $titleInput) .textFieldStyle(.roundedBorder) } } } |
@Binding
属性总是会从@State
属性中接收值,因此不用为其赋默认值,但建立的连接是双向的,因此要记住在@State
属性的前面添加$
符号来与@Binding
属性进行连接(HeaderView(title: title, titleInput: $titleInput)
)。因@Binding
属性和@State
属性之间是双向绑定,用户输入的值存储在同一个地方,每当系统识别到按钮点击变化时,HeaderView
结构体的body
属性会再次进行处理,新的值就会显示到屏幕上了。
✍️跟我一起做:使用示例6-4中的代码更新ContentView
视图。记住保留底部的#Preview
宏以便在画布中进行预览。在输入框中插入值,点击Change Title按钮。效果和之前相同。
绑定结构体
前面讨论过,属性包装器以结构体进行定义,因此包含自己的属性。SwiftUI允许通过在属性名前加下划线(如_title
)来访问属性的底层结构体。访问到结构体后,就可以处理其属性了。定义@State
属性包装器的结构体为State
。这是一个泛型结构体,可以处理任意类型的值。以下是该结构体定义用于存储状态值的属性。
- wrappedValue:此属性返回由
@State
属性管理的值。 - projectedValue:此属性返回
Binding
类型的结构体,创建与视图间的双向绑定。
wrappedValue
属性存储赋给@State
属性的值,就像是上例中赋值给title
属性的”Default Title”字符串。projectedValue
属性存储Binding
类型的结构体,创建将值存回到属性的双向绑定。如果直接读取@State
属性(如title
),返回的值存储在wrappedValue
属性中,如果在属性名前加上$
符号(如$title
),我们访问的是存储在projectedValue
属性中的Binding
结构体。这是SwiftUI推荐的使用@State
属性的方式,但在理论上我们也可以直接访问这些属性,如下例所示。
示例6-5:访问State
结构体的属性
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 title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack { Text(_title.wrappedValue) .padding(10) TextField("Inserted Title", text: _titleInput.projectedValue) .textFieldStyle(.roundedBorder) Button(action: { _title.wrappedValue = _titleInput.wrappedValue _titleInput.wrappedValue = "" }, label: { Text("Change Title") }) Spacer() }.padding() } } |
它和前面的示例效果一样,但没有使用SwiftUI的快捷方式,而是直接读取了State
结构体中的wrappedValue
和projectedValue
属性。当前不必这么做,但有时可用于克服SwiftUI自身的缺陷。比如,SwiftUI不允许我们在赋值给body
的闭包外处理@State
属性,但我们可以用一个State
结构体来替换另一个。为此,可以使用以下由State
结构体所提供的初始化方法。
- State(initialValue: Value):该初始化方法使用
initialValue
所提供的值创建一个State
属性。 - State(wrappedValue: Value):该初始化方法使用
wrappedValue
所提供的包装值创建一个State
属性。
例如,我们希望对前例的输入框赋一个初始值,可以对ContentView
结构体添加一个初始化方法,并用它对该属性赋一个新的State
结构体。
示例6-6:初始化@State
属性
1 2 3 |
init() { _titleInput = State(initialValue: "Hello World") } |
✍️跟我一起做:使用示例6-5中的代码更新ContentView
视图。在ContentView
结构体中添加示例6-6的初始化方法(放在@State
属性下面)。这时会看到输入框初始化为了”Hello World”。
注意:这样访问绑定属性的内容仅在没有其它选择时才推荐使用。只要有可能,就应使用SwiftUI所提供的属性包装器或在第5章(示例5-58)中介绍过的onAppear()
修饰符中安装始化@State
属性,或者是在可观测对象是中存储状态,这个稍后会学到。
我们可以按访问@State
属性同样的方式访问@Binding
属性。如果只像示例6-5那样读取该属性,返回值就是其中存储的值,如果在名称前面加$
,返回值是属性包装器用于建立与视图双向绑定的Binding
结构体。但如果在@Binding
属性的名称前添加下划线(如_title
),返回值就不是State
结构体而不是Binding
结构体。这是因为@Binding
属性包装器在类型为Binding
的结构体中定义。当然,结构体中还包含访问这些值的属性。
- wrappedValue:该属性返回由
@Binding
属性管理的值。 - projectedValue:该属性返回创建与视图间双向绑定的类型为
Binding
的结构体。
和State
属性一样,我们可以访问及处理Binding
结构体中存储的值。比如,下例中又实现了一个单独的视图,和示例6-4一样管理标题和输入框。在初始化了HeaderView
之后,我们通过wrappedValue
属性获取到Binding
结构体中存储的值,对字符串的字符数计数,然后将结果与标题共同显示出来。
示例6-7:访问@Binding
属性的值
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 |
struct ContentView: View { @State private var title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack { HeaderView(title: $title, titleInput: $titleInput) Button(action: { title = titleInput titleInput = "" }, label: { Text("Change Title") }) Spacer() }.padding() } } struct HeaderView: View { @Binding var title: String @Binding var titleInput: String let counter: Int init(title: Binding<String>, titleInput: Binding<String>) { _title = title _titleInput = titleInput let sentence = _title.wrappedValue counter = sentence.count } var body: some View { VStack { Text("\(title) (\(counter))") .padding(10) TextField("Inserted Title", text: $titleInput) .textFieldStyle(.roundedBorder) } } } |
在示例6-7的HeaderView
中,我们定义了一个属性counter
,并使用wrappedValue
属性返回的字符串的字符数进行初始化。因为@Binding
属性没有初始值,必须使用ContentView
视图接收到的值进行初始化(_title = title
)。注意HeaderView
结构体接收到值是可管理String
类型值的Binding
结构体,因此参数的类型必须用Binding<String>
来声明。
初始化完值之后,我们可以在视图中进行显示。标题现在显示 为用户插入的文本以及字符串的字符数。
图6-3:@Binding
属性的值定义的标题
✍️跟我一起做:使用示例6-7中的代码更新ContentView.swift
文件。插入标题。会看到如图6-3所示的标题及其右侧的字符数。
HeaderView
视图的@Binding
属性与ContentView
视图的@State
属性相连接,因此可接收到该属性的值,但有时这种结构体实例是单独创建的,因而需要一个绑定值。要定义这种值,可以自己创建一个Binding
结构体。结构体中包含如下初始化方法和类型方法。
- Binding(get: Closure, set: Closure):这一初始化方法创建一个
Binding
结构体。get
参数是一个会返回当前值的闭包,set
参数是一个接收存储或处理新值的闭包。 - constant(Value):这个类型方法创建一个带不可变值的
Binding
结构体。该参数是我们希望赋值给该结构体的值。
很多场景下需要用到Binding
值。例如,我们希望创建一个HeaderView
的预览,必须为title
和titleInput
属性提供值。以下示例描绘了如何创建一个新的Binding
结构体提供这些值,以及如何定义这一视图的预览。
示例6-8:创建一个Binding
结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#Preview("Header") { let constantTitle = Binding<String>( get: { return "My Preview Title" }, set: { value in print(value) }) let constantInput = Binding<String>( get: { return "" }, set: { value in print(value) }) return HeaderView(title: constantTitle, titleInput: constantInput) } |
Binding
结构体包含一个getter和一个setter。getter返回当前值,setter接收赋值给结构体的值。本例中,返回的是字符串,因为没对结构体赋新值,只是在控制台中打印出了这个值。实例赋值给常量constantTitle
和constantInput
,然后发送给HeaderView
结构体,因此在画布该视图有值可以显示。
本例中的Binding
结构体没有多大用处,只是提供了Binding
结构体所需要的值。在这种场景,可以使用constant()
方法来简化代码。这一类型方法使用不可变值创建并返回一个Binding
结构体,所以我们不用自己创建结构体。
示例6-9:通过不可变值创建一个Binding
结构体
1 2 3 |
#Preview("Header") { HeaderView(title: .constant("My Preview Title"), titleInput: .constant("")) } |
✍️跟我一起做:在ContentView.swift
文件中添加示例6-9中的结构体。此时会在画布顶部多出现一个按钮,显示HeaderView
视图的预览。
控制视图
控件是交互工具,用户通过交互修改界面状态、选取选项或插入、修改或删除信息。我们实现过其中的一部分,如前例中的Button
视图以及TextField
视图。要定义一个有用的接口,需要学习有关视图的更多知识以及其它由SwiftUI所提供的控制视图。
按钮视图
我们已经学到,Button
视图创建一个简单的控件,在点击时执行操作。以下是该结构体部分初始化方法。
- Button(String, action: Closure):此初始化方法创建一个
Button
视图。第一个参数是定义按钮标签的字符串,action
参数是在点击按钮时执行的代码的闭包。 - Button(action: Closure, label: Closure):此初始化方法创建一个
Button
视图。action
参数是在点击按钮时执行的代码的闭包,label
参数是返回用于创建标签的视图的闭包。 - Button(String, role: ButtonRole?, action: Closure):此初始化方法创建一个
Button
视图。第一个参数是定义按钮标签的字符串。role
参数一个结构体,包含描述按钮目的的类型属性。有两个属性:cancel
和destructive
。action
参数是在点击按钮时执行的代码的闭包。
我们已经实现过第二个初始化方法创建按钮,但如果仅需对标签使用字符中,可以简化代码使用第一个初始方法加后置的用于操作的闭包。
示例6-10:实现Button
视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @State private var colorActive: Bool = false var body: some View { VStack(spacing: 10) { Text("Default Title") .padding() .background(colorActive ? Color.green : Color.clear) Button("Change Color") { colorActive.toggle() } Spacer() }.padding() } } |
上例在VStack
中包仿一个Text
视图和一个Button
视图。Text
视图展示固定的文本,背景色用colorActive
属性定义。如果属性值是true
,我们将green
色赋值给背景,否则颜色为clear
(透明)。在按下按钮时,会切换这一属性的值,再次运算body
属性的值,文本的背景修改为下一个颜色。
图6-4:按钮视图
✍️跟我一起做:创建一个多平台项目。使用示例6-10的代码更新ContentView
视图。点击Change Color按钮。会看到文本背景色的变化(参见图6-4右图)。
如果希望将视图与控件执行的操作进行分离,可以将相关语句移到函数中。例如,可以上在ContentView
结构体中添加一个函数,用于切换colorActive
属性的值,然后在按钮的操作中调用这个函数。应用的功能的相同,但代码更有条理。
示例6-11:使用函数来组织代码
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 colorActive: Bool = false var body: some View { VStack(spacing: 10) { Text("Default Title") .padding() .background(colorActive ? Color.green : Color.clear) Button("Change Color") { changeColor() } Spacer() }.padding() } func changeColor() { colorActive.toggle() } } |
如果按钮唯一的操作就是调用方法,可以简化视图的定义为声明action
参数并指定所要执行操作的方法名。如下所示。
示例6-12:引用方法
1 |
Button("Change Color", action: changeColor) |
声明方法名称带括号会马上执行方法,但仅声明名称会提供一个方法的引用供系统稍后执行。
✍️跟我一起做:使用示例6-11中的代码更新ContentView
视图。应用功能和之前相同。使用示例6-12中的Button
视图更新Button
视图。点击按钮确定所执行的操作。
上例中,我们使用了三元运算符来根据colorActive
属性的值选取background()
修饰符的值。这是推荐的做法,这样SwiftUI可以识别视图并有效管理状态的转换,但我们也可以使用if else
语句来响应修改。例如,有时会用按钮这类控件在界面中显示或隐藏视图。
示例6-13:在界面中添加及删除视图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @State private var showInfo = false var body: some View { VStack(spacing: 10) { Button("Show Information") { showInfo.toggle() }.padding() if showInfo { Text("This is the information") } Spacer() } } } |
本例中的按钮切换@State
属性showInfo
的值。在按钮下方,可查看到该属性的当前值。若其值为true
,显示 Text
视图,否则什么也不显示。因此,在按下按钮时,showInfo
的值发生改变,body
属性的内容会重新绘制,Text
视图根据showInfo
的当前值出现或消失。
图6-5:动态界面
if else
语句可用于选择是否执行按钮的操作,但SwiftUI提供了如下修饰符来在要做禁用操作时禁用按钮。
- disabled(Bool):这一修饰符决定该控件是否响应用户的交互。
下例使用了该修饰符在点击后禁用按钮,因此用户只能执行一次操作。
示例6-14:禁用按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct ContentView: View { @State private var color = Color.clear @State private var buttonDisabled = false var body: some View { VStack(spacing: 10) { Text("Default Title") .padding() .background(color) Button("Change Color") { color = Color.green buttonDisabled = true } .disabled(buttonDisabled) Spacer() }.padding() } } |
这个视图包含两个@State
属性,一个用于追踪颜色,另一个表示按钮是否处于禁用状态。在点击按钮时,操作中将true
赋值给buttonDisabled
属性,按钮就停止运作了,这样用户只能点一次。
图6-6:按钮禁用
和之前一样,Button
视图的初始化方法可以包含一个label
参数来定义所需视图的标签。这个参数非常灵活,可以包含像Text
视图和Image
视图的视图。按钮中的图片以原始渲染模式显示 ,也就是说以原始颜色显示,但还有一种模式可以创建带图片的蒙版,以应用的着重色或赋值给控件的前景色显示。为选取渲染模式,Image
视图包含如下修饰符。
- renderingMode(TemplateRenderingMode):这个修饰符对
Image
视图定义了渲染械。参数是包含original
和template
值的枚举。
下例定义了一个带图片和文本的按钮。将renderingMode()
修饰符应用于Image
视图来以模板显示图片。
示例6-15:定义带图按钮的标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
struct ContentView: View { @State private var expanded: Bool = false var body: some View { VStack(spacing: 10) { Text("Default Title") .frame(minWidth: 0, maxWidth: expanded ? .infinity : 150, maxHeight: 50) .background(Color.yellow) Button(action: { expanded.toggle() }, label: { VStack { Image(expanded ? .contract : .expand) .renderingMode(.template) Text(expanded ? "Contract" : "Expand") } }) Spacer() }.padding() } } |
示例6-15中的视图包含一个@State
属性expanded
,用于控制Text
视图的宽度。如该属性的值为true
,我们使用infinity
值让宽度为最宽,否则,宽度为150点。每当用户点击按钮时,expanded
属性的值通过toggle()
方法进行切换,Text
视图的宽度随之发生变化。
图6-7:带模板图片的按钮
✍️跟我一起做:下载expand.png和contract.png并添加至资源目录。使用示例6-15中的代码更新ContentView
视图,点击Expand按钮。此时会看到图6-7中的界面。删除renderingMode()
修饰符。我们应当会看到原色图。
可以通过如下修饰符对按钮赋标准样式。
- buttonStyle(ButtonStyle):此修饰符定义了按钮的样式。参数是遵循
ButtonStyle
协议的一个结构体。 - controlSize(ControlSize):此修饰符定义了按钮的样式。参数是一个枚举,值有
large
、mini
、regular
和small
。
SwiftUI框架自带有PrimitiveButtonStyle
协议提供标准样式。为此,该协议定义了类型属性automatic
、bordered
、borderedProminent
、borderless
和plain
。这些样式满足不同目的。例如,bordered
样式创建一个灰色背景的按钮,表示二级操作,borderedProminent
样式创建一个应用着重色的按钮,表示主操作,比如用于保存或提交数据。例如以下视图包含两个按钮,一个取消处理,另一个将信息发送给服务端。
示例6-16:按钮样式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct ContentView: View { var body: some View { VStack(spacing: 10) { HStack { Button("Cancel") { print("Cancel Action") }.buttonStyle(.bordered) Spacer() Button("Send") { print("Send Information") }.buttonStyle(.borderedProminent) } Spacer() }.padding() } } |
突出按钮应仅用于表示主操作。本例中,Cancel按钮加了边框,告诉用户这是一个二级操作,重要级为次级,但Send按钮为突出的,表示在点击该按钮时执行重要操作。
图6-8:标准样式按钮
按钮用于取消处理(如上例)或删除某一项时,我们可以通过Button
的初始化方法为其赋一个特定的角色。这样系统可以根据角色在应用运行的设备上对按钮添加样式。例如,在移动设备上,destructive
角色的按钮以红色显示。
示例6-17:赋予角色
1 2 3 |
Button("Delete", role: .destructive) { print("Delete Action") }.buttonStyle(.bordered) |
图6-9:销毁按钮
✍️跟我一起做:使用示例6-16的代码更新ContentView
视图。会看到如图6-8中所示的按钮。将Cancel按钮替换为示例6-17中的Button
视图。会看到如图6-9中所示的删除按钮。
这些样式对SF图标进行了美化。SF图标替换普通图片的优势是它们会按对按钮添加的字体大小进行缩放。这配合对按钮自身进行缩放的controlSize()
修饰符,使得我们可以创建不同大小的按钮。
示例6-18:缩放按钮
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct ContentView: View { var body: some View { VStack(spacing: 10) { Button(action: { print("Send information") }, label: { HStack { Image(systemName: "mail") .imageScale(.large) Text("Send") } }) .buttonStyle(.borderedProminent) .font(.largeTitle) .controlSize(.large) Spacer() }.padding() } } |
本例中,我们应用了imageScale()
修饰符来缩放SF图标,font()
修饰符对按钮添加了大字体,controlSize()
修饰符对按钮进行缩放。结果如下。
图6-10:自定义大小的按钮
如果希望定义一个样式与系统自带的进行区分,则需要创建自己的ButtonStyle
结构体。该协议只要求实现如下方法。
- makeBody(configuration: Configuration):该方法定义并返回一个替换按钮体的视图。
configuration
参数为包含按钮信息的Configuration
类型的值。
该方法接收一个Configuration
类型的值,是ButtonStyleConfiguration
的类型别名,包含返回按钮相关信息的属性。以下是其中的属性。
- isPressed:该属性返回表示按钮是否按下的布尔值。
- label:该属性返回定义按钮当前标签的一个或多个视图。
以下示例定义在点击时会放大的示例。样式包含一个内边距和绿色边框。要应用这些样式,必须创建一个符合ButtonStyle
协议的结构体,即实现makeBody()
方法并通过该方法返回希望赋值给按钮体的视图。
组成按钮体的视图由Configuration
结构体的label
属性提供,因此我们可以读取并修改这一属性值来应用新的样式,如下所示。
示例6-19:为按钮添加自定义样式
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 MyStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { let pressed = configuration.isPressed return configuration.label .padding() .border(Color.green, width: 5) .scaleEffect(pressed ? 1.2 : 1.0) } } struct ContentView: View { @State private var color = Color.gray var body: some View { VStack(spacing: 10) { Text("Default title") .padding().foregroundColor(color) Button("Change Color") { color = Color.green }.buttonStyle(MyStyle()) Spacer() }.padding() } } |
在示例6-19中,我们定义了一个结构体MyStyle
并实现了所要求的makeBody()
方法。该方法通过类型属性获取到了按钮的当前配置,进而修改并返回标签。首先,我们读取isPressed
属性的值来了解按钮是否被按下,然后对label
属性应用新的样式。这个属性返回创建按钮当前标签的一个视图拷贝,然后通过修改其值我们也就修改了标签。本例中,我们应用了一个内边距、一个边框,然后根据isPressed
属性的值赋了一个缩放比例。如果该值为true
,也就是按钮被按下了,我们将比例设为1.2进行放大,但在值为false
时,比例又回到了1。
在这一视图中,我们创建了该结构体的实例并通过buttonStyle()
修饰符将其值赋值给Button
视图。如果如下所示。
图6-11:带自定义样式的按钮
✍️跟我一起做:使用示例6-19中的代码更新ContentView.swift
文件。点击按钮。会看到按钮如图6-11右图那样放大了。SwiftUI自动对按钮添加了动画。我们会在第11章中学习自定义动画以及如何创建。
文本框视图
TextField
又是一个我们之前介绍过的控件。该视图创建一个输入框,用户可进行交互并插入值(单行文本)。以下是结构体中所包含的一个初始化方法。
- TextField(String, text: Binding, axis: Axis):此初始化方法创建一个输入框。第一个参数定义该字段的占位符,
text
参数是用于存储由用户所插入值的绑定属性,axis
参数定义在文本超出视图边界时沿哪条轴进行滚动。这是一个枚举,值有horizontal
和vertical
。
框架为TextField
视图定义了几个修饰符。以下是最常用的一些。
- textFieldStyle(TextFieldStyle):此修饰符定义文本框的样式。参数是一个符合
TextFieldStyle
协议的结构体。框架自带了几个提供标准样式的结构体。这些结构体定义了类型属性automatic
、plain
、roundedBorder
和squareBorder
。 - autocorrectionDisabled(Bool):此修饰符启用或禁用系统的自动修正特性。默认,该值为
true
(禁用状态)。 - textInputAutocapitalization(TextInputAutocapitalization?):此修饰符定义用于格式化文本的大写样式。该参数是一个结构体,包含类型属性
characters
、never
、, sentences (默认值)
和words
。 - keyboardType(UIKeyboardType):此修饰符定义待定输入框后系统打开的键盘类型。其参数是一个枚举,值有
default
、asciiCapable
、numbersAndPunctuation
、URL
、numberPad
、phonePad
、namePhonePad
、emailAddress
、decimalPad
、twitter
、webSearch
、asciiCapableNumberPad
和alphabet
。
我们已经学习如何包含一个简单的TextField
视图来获取用户的输入,但只使用了少数几个修饰符。下例展示了如何对视图添加样式让单词变成大写。
示例6-20:配置文本框
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
struct ContentView: View { @State private var title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack(spacing: 15) { Text(title) .lineLimit(1) .padding() .background(Color.yellow) TextField("Insert Title", text: $titleInput) .textFieldStyle(.roundedBorder) .textInputAutocapitalization(.words) Button("Save") { title = titleInput titleInput = "" } Spacer() }.padding() } } |
示例6-20中对TextField
视图应用的样式为roundedBorder
。它为输入框添加一个边框,让视图占据的区域变得可见,如下所示。
图6-12:带圆角边框的文本框
✍️跟我一起做:使用示例6-20中的代码更新ContentView
视图。在输入框中插入文本并按下Save按钮。会看到如图6-12所示的效果。
除了按钮,用户通过期望能够通过点击键盘上的Done按钮保存数据。为此框架提供了如下的修饰符。
- onSubmit(of: SubmitTriggers, Closure):在发生触发条件(比如在键盘上按下Done/Return按钮时)时该修饰符执行一个操作。
of
参数是指定修饰符所响应的触发条件类型的结构体。结构体中包含search
和text
(默认)属性。第二个参数是希望执行的闭包。 - submitLabel(SubmitLabel):该修饰符指定虚拟键盘中Done按钮所使用的标签。参数结构体包含的类型属性有
continue
、done
、go
、join
、next
、return
、route
、search
和send
。 - submitScope(Bool):该修饰符指定在发生触发条件时是否提交视图。
赋值给onSubmit()
修饰符的闭包在聚焦于视图(例如用户编辑输入框)时执行。如果应用于TextField
视图,可省略of
参数,如下例如下。
示例6-21:响应Done按钮
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 |
struct ContentView: View { @State private var title: String = "Default Title" @State private var titleInput: String = "" var body: some View { VStack(spacing: 15) { Text(title) .lineLimit(1) .padding() .background(Color.yellow) TextField("Insert Title", text: $titleInput) .textFieldStyle(.roundedBorder) .submitLabel(.continue) .onSubmit { assignTitle() } HStack { Spacer() Button("Save") { assignTitle() } } Spacer() }.padding() } func assignTitle() { title = titleInput titleInput = "" } } |
示例6-21中的代码实现了submitLabel()
修饰符来修改Done按钮的标题为Continue,然后向结构体添加一个名为assignTitle()
的方法,执行和之前同样的操作。该方法在两处有调用,赋值给onSubmit()
修饰符的闭包和Button
视图操作,因此在按下界面的按钮或点击键盘上的Done/Return按钮时执行该操作。不管用户决定执行什么操作,插入文本框的值总是存储于title
属性中。
✍️跟我一起做:使用示例6-21中的代码更新ContentView
结构体,并在iPhone模拟器上运行应用。点击输入框,插入文本并在键盘上点击Continue按钮。(若要在模拟器上启用虚拟键盘,打开I/O菜单,点击Keyword,选择Toggle Software Keyboard选项。)文本会像此前一样赋值给标题。
在视图可接收输入或处理用户选定的反馈时,我们就说视图聚焦了。SwiftUI包含了一些处理这种状态的工具。可以在视图获得焦点时处理某一任务、知道视图是否获得焦点或是从视图移除焦点。为此有两个属性包装器:@FocusState
和@FocusedBinding
。@FocusState
存储表明焦点当前存储在哪里的值,@FocusedBinding
用于将状态传递给其它视图。为管理状态,框架内置了如下 修饰符。
- focused(Binding, equals: Hashable):此修饰符将视图当前状态存储于绑定属性中。第一个参数是对
@FocusState
属性的引用,equals
参数是用于标识视图的可哈希值。 - focusable(Bool):此标识符表示是否可将焦点放在视图上。
为追踪视图的状态,我们需要一个可哈希数据类型的@FocusState
属性,提供用于标识视图的值。下例中,属性通过枚举值进行创建。定义了两个值name
和surname
,用于追踪两个输入框的聚焦状态,并在用户输入时修改背景色。
示例6-22:响应焦点中的变化
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 |
enum FocusName: Hashable { case name case surname } struct ContentView: View { @Environment(\.colorScheme) var colorScheme @FocusState var focusName: FocusName? @State private var title: String = "Default Name" @State private var nameInput: String = "" @State private var surnameInput: String = "" var body: some View { let color: Color = colorScheme == .dark ? .black : .white VStack(spacing: 10) { Text(title) .lineLimit(1) .padding() .background(Color.yellow) TextField("Insert Name", text: $nameInput) .textFieldStyle(.roundedBorder) .padding(4) .background(focusName == .name ? Color(white: 0.9) : color) .focused($focusName, equals: .name) TextField("Insert Surname", text: $surnameInput) .textFieldStyle(.roundedBorder) .padding(4) .background(focusName == .surname ? Color(white: 0.9) : color) .focused($focusName, equals: .surname) HStack { Spacer() Button("Save") { title = nameInput + " " + surnameInput } } Spacer() }.padding() } } |
@FocusState
属性的初始值是nil
,表示未聚焦于任何视图。在用户点击文本框时,焦点移至该视图,标识视图的值会被赋值给该属性。通过将该值与枚举中的值进行比较,我们就知道是哪个TextField
视图于聚焦状态,相应地修改背景色。注意roundedBorder
样式对文本框添加了一个边框和白色背景,所以本例中只有边距的背景可见。
图6-13:聚焦
在移动设备中,在可处理输入的视图(如TextField
视图)获取到焦点时会打开虚拟键盘。只要焦点还在该视图上键盘就保持打开状态。也就是说要关闭键盘,我们必须移除该视图的焦点。在SwiftUI中通过对@FocusState
属性的赋值nil
来实现,如下所示。
示例6-23:关闭键盘
1 2 3 4 |
Button("Save") { title = nameInput + " " + surnameInput focusName = nil } |
示例6-22中的Button
视图换成了示例6-23中的Button
视图。现在,每当点击Save按钮时,会对值进行处理并关闭键盘。
✍️跟我一起做:使用示例6-22中的代码更新ContentView.swift
文件并在iPhone模拟器上运行应用。点击输入框。背景会像图6-13那样变成灰色。使用示例6-23中的视图替换原Button
视图。再次运行应用。在两个文本框中插入值并点击Save按钮。此时标题会被赋上新值,虚拟键盘关闭。
上例中,我们没有检测用户是否插入了值,但通常应用必须防止用户保存无效值或空值。有几种控制方式。一种是在存储之前就检测值。我们允许用户输入任意值,但仅保存应用所接受的值。
示例6-24:在存储前检测值
1 2 3 4 5 6 7 8 9 |
Button("Save") { let tempName = nameInput.trimmingCharacters(in: .whitespaces) let tempSurname = surnameInput.trimmingCharacters(in: .whitespaces) if !tempName.isEmpty && !tempSurname.isEmpty { title = nameInput + " " + surnameInput focusName = nil } } |
本例中,我们首先对nameInput
和surnameInput
进行修剪去除其首尾的空格(参数第4章字符串一节),然后在将它们赋值给title
属性之前检测结果值是否为空。Save按钮仍保持为激活状态,但仅在用户对两个字段都插入值时才执行保存。
✍️跟我一起做:使用示例6-24中的代码更新ContentView
视图中的Button
视图。此时必须同时对名和姓两个字段插入值才能修改标题。
另一种方式是在用户插入的为非应用预期值时通过disabled()
修饰符禁用按钮。
示例6-25:禁用按钮
1 2 3 4 5 6 7 8 9 10 |
Button("Save") { let tempName = nameInput.trimmingCharacters(in: .whitespaces) let tempSurname = surnameInput.trimmingCharacters(in: .whitespaces) if !tempName.isEmpty && !tempSurname.isEmpty { title = nameInput + " " + surnameInput focusName = nil } } }.disabled(nameInput.isEmpty || surnameInput.isEmpty) |
本例中,我们使用了前面介绍的disabled()
修饰符来在用户在两个字段中输入文本前禁用按钮。如果其中一个或两个字段为空,按钮就无法使用。
✍️跟我一起做:使用示例6-25中的代码更新Button
视图。只有同时插入名和姓时才能按下Save按钮。
除了可检测属性是否包含有效值,我们还能限定用户在字段中输入的内容。例如,我们可以只接受数字或指定数量的字符。这时,我们需要在每次视图状态发生改变时检测用户插入的值是否有效。框架为此内置了如下的修饰符。
- onChange(of: State, initial: Bool, Closure):该修饰符在状态发生改变时执行闭包。
of
参数是存储待检测值的属性,initial
参数为指定在视图出现时是否还执行检测的布尔值,最后一个参数是在系统报出值发生改变时执行的闭包。闭包可接收两个值,一个表示属性的老值,另一个表示新值。
该修饰符只能检测一个状态,因此我们应对所有希望进行控制的视图应用该修饰符。例如,我们可以在示例中对那两个TextField
视图使用它来限定允许用户输入的字符数。如果超出,会移除掉多余的字符将结果赋回属性,如下所示。
示例6-26:控制用户的输入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Text(title) .lineLimit(1) .padding() .background(Color.yellow) TextField("Insert Name", text: $nameInput) .textFieldStyle(.roundedBorder) .padding(4) .background(focusName == .name ? Color(white: 0.9) : color) .focused($focusName, equals: .name) .onChange(of: nameInput, initial: false) { old, value in if value.count > 10 { nameInput = String(value.prefix(10)) } } TextField("Insert Surname", text: $surnameInput) .textFieldStyle(.roundedBorder) .padding(4) .background(focusName == .surname ? Color(white: 0.9) : color) .focused($focusName, equals: .surname) .onChange(of: surnameInput, initial: false) { old, value in if value.count > 15 { surnameInput = String(value.prefix(15)) } } |
在示例6-26的代码中,我们检测存储文本框状态的属性的变化。在用户输入或删除字符时,相应的属性值发生改变,执行赋值给onChange()
修饰符的闭包。闭包接收属性的值。使用该值,我们检测用户插入的文本是否有效并进行相应的响应。在示例中,我们计算字符串中的字符数,如果值超出上限,我们使用prefix()
方法从文本的开头进行截取,并将结果赋回给属性,这会更新视图并删除文本框中多余的字符。结果 是在字符数超出上限时,用户就无法输入更多的字符了。
✍️跟我一起做:使用示例6-26中的代码更新项目中的TextField
视图。在iPhone模拟器中运行应用。插入名和姓。在名超过10个字符、姓超过15个字符时都无法再添加更多的字符。
当然,我们可以指定字符数外的其它条件。下例创建了一个仅接收整数数字的小应用。
示例6-27:仅接收整数数字
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 |
struct ContentView: View { @State private var title: String = "Default Name" @State private var numberInput = "" var body: some View { VStack(spacing: 10) { Text(title) .padding() .background(Color.yellow) TextField("Insert Number", text: $numberInput) .textFieldStyle(.roundedBorder) .padding(4) .keyboardType(.numbersAndPunctuation) .onChange(of: numberInput, initial: false) { old, value in if !value.isEmpty && Int(value) == nil { numberInput = old } } HStack { Spacer() Button("Save") { title = numberInput numberInput = "" } } Spacer() }.padding() } } |
和之前一样,视图中包含一个带有onChange()
修饰符的TextField
。不同之处于在于如何对输入有进行有效性检测。本例中,我们需要确保文本框不为空,然后查看是否可以将其转化为整数,这表示用户只输入了数字。如果不能,就将闭包接收到的旧值赋值给numberInput
属性,文本框回复到之前的状态。
注意我们还实现了keyboardType()
修饰符来显示适配我们预期用户输入内容(本例为数字)的键盘。
✍️跟我一起做:使用示例6-27中的代码更新ContentView.swift
文件。在iPhone模拟器上运行应用。此时只能输入数字。
默认,TextField
视图只显示一行文本,但我们可以使用lineLimit()
修饰符来允许视图进行扩展来包含更多的文本。(此前展开Text
视图实现的同一个修饰符)。除了应用修饰符来设置我们所需的行数,我们还要告诉视图在纵轴上滚动内容,如下所示。
示例6-28:定义多行文本框
1 2 3 4 5 6 7 8 9 10 |
struct ContentView: View { @State private var text: String = "" var body: some View { TextField("Insert Text", text: $text, axis: .vertical) .textFieldStyle(.roundedBorder) .padding(20) .lineLimit(5) } } |
本例中,TextView
视图会进行扩展,直至到5行的高度时,然后会在垂直方向上滚动来允许用户持续输入。如若要对视图设置最小和最大尺寸,可以使用区间来声明修饰符,如lineLimit(3...5)
。
图6-14:多行文本框
安全域视图
SwiftUI还内置了创建安全文本框的视图。这一视图会把用户输入的字符替换成点以及隐藏敏感信息,比如密码。
- SecureField(String, text: Binding):该初始化方法创建一个安全输入框。第一个参数定义占位文本,
text
参数为存储用户插入值的绑定属性。
实现方式与TextField
视图相同,我们也可以应用相同的修饰符,如下所示。
示例6-29:使用安全文本框
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var pass: String = "" var body: some View { VStack(spacing: 15) { Text(pass) .padding() SecureField("Insert Password", text: $pass) .textFieldStyle(.roundedBorder) Spacer() }.padding() } } |
SecureField
视图和TextField
视图外观一样。唯一的差别是其中的字符是隐藏的。
图6-15:安全文本框
✍️跟我一起做:创建一个多平台项目。使用示例6-29中的代码更新ContentView
视图。在输入框中插入字符。会看到字符被替换成为小黑点,如图6-15所示。
文本编辑器视图
SwiftUI还自带一个称为TextEditor
的视图让用户可以插入多行文本。以下是该视图的初始化方法。
- TextEditor(text: Binding):此初始化方法创建一个文本编辑器。
text
参数是存储用户所插入文本的绑定属性。
该视图可以接收前面用于格式化文本的TextField
和Text
视图的一些修饰符。例如,我们可以设置视图中文本的对齐、行间距以及是否做错误检查。
示例6-30:实现一个文本编辑器
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var text: String = "" var body: some View { TextEditor(text: $text) .multilineTextAlignment(.leading) .lineSpacing(10) .autocorrectionDisabled(true) .padding(8) } } |
图6-16:文本编辑器
开关视图
Toggle
视图创建一个在两种状态间切换的控件。默认在移动设备上显示为对用户友好的开关,在Mac上显示为复选框。该视图包含如下初始化方法。
- Toggle(String, isOn: Binding):该初始化方法创建一个
Toggle
视图。第一个参数定义标签,isOn
参数为存储当前状态的绑定属性。本视图还自带有一个由闭包返回视图的标签(Toggle(isOn: Binding, label: Closure))。
该视图要求绑定属性存储当前值。在下例中,我们提供了一个@State
属性并使用属性值来选取适当的标签。
示例6-31:实现一个开关
1 2 3 4 5 6 7 8 9 10 11 12 |
struct ContentView: View { @State private var currentState: Bool = true var body: some View { VStack { Toggle(isOn: $currentState, label: { Text(currentState ? "On" : "Off") }) Spacer() }.padding() } } |
示例6-31中的代码使用三元运算符来检测currentState
属性的值并显示相应的文本(On或Off)。默认我们设置属性值为true
,因此开关处于打开状态并在屏幕上显示On标签,但如果点击开关,就会关闭,视图更新为显示Off标签。
图6-17:打开和关闭开关
✍️跟我一起做:创建一个多平台项目。使用示例6-31中的代码更新ContentView
视图。点击打开或关闭开关。使用这个项目测试下面的例子。
赋值给label
参数的闭包可以包含另外一个定义副标题的视图,如下例所示。
示例6-32:添加副标题
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var currentState: Bool = true var body: some View { VStack { Toggle(isOn: $currentState, label: { Text(currentState ? "On" : "Off") Text("Enable or Disable") }) Spacer() }.padding() } } |
图6-18:带标题和副标题的开关
Toggle
视图创建了一个包含标题和控件中间为弹性空间的横向布局,结果就是整个视图占满容器的横向空间,标签和控件位于两端。如果希望对视图的位置和尺寸做精确控制,可以应用此前介绍过的fixedSize
修饰符来降低视图的尺寸,或使用如下修饰符隐藏标签。
- labelsHidden():此修饰符隐藏赋值给控件的标签。
这一修饰符适用于多款控件,但对开关尤为有用。下例展示了如何实现它来为控件定义一个自定义标签。
示例6-33:为Toggle
视图定义一个自定义标签
1 2 3 4 5 6 7 8 9 10 11 12 13 |
struct ContentView: View { @State private var currentState: Bool = true var body: some View { HStack { Toggle("", isOn: $currentState) .labelsHidden() Text(currentState ? "On" : "Off") .padding() .background(Color(currentState ? .yellow : .gray)) }.padding() } } |
视图现在为控件的大小并在屏幕中心显示。标签不再显示 ,因此我们将其声明为空字符串,但在当前值的侧边包含一个Text
视图。
图6-19:自定义大小以及开关的标签
类似Button
视图,Toggle
视图也实现了修饰符用于定义控件的样式。
- toggleStyle(ToggleStyle):这一修饰符定义开关的样式。参数是一个遵循
ToggleStyle
协议的结构体。为创建标准的结构体,框架包含了automatic
、button
、checkbox
和switch
这些属性。
默认值为automatic
,表示控件的样式由系统选择。如果希望保持同一种样式,可以赋值switch
或checkbox
(仅能用于Mac)。这些值用于指定标准样式,但框架还内置了button
值来创建完全不同类型的控件。在将这一样式赋值给视图时,系统显示一个开关按钮来表示开和关的状态。在按钮处于开的状态时,高亮显示,否则显示 为标准按钮。
示例6-34:实现一个开关按钮
1 2 3 4 5 6 7 8 9 10 11 12 |
struct ContentView: View { @State private var currentState: Bool = true var body: some View { HStack { Toggle(isOn: $currentState, label: { Label("Send", systemImage: "mail") }) .toggleStyle(.button) }.padding() } } |
图6-20:切换按钮为开关状态
框架提供的样式是有限的,但我们可以自行创建。只需要定义一个遵循ToggleStyle
协议的结构体。该协议要求结构体实现如下方法。
- makeBody(configuration: Configuration):该方法定义并返回一个替换开关主体的视图。
configuration
参数是一个Configuration
类型的值,包含控件相关信息。
这个方法接收一个类型为Configuration
的值,是ToggleStyleConfiguration
的类型别名,包含如下属性来返回控件相关的信息。
- isOn:该属性返回一个表示开关处于开或关状态的布尔值。
- label:该属性返回定义开关标签的视图。
isOn
是一个绑定属性,创建与视图的双向绑定,因此我们可以读取并修改其值来激活或停用控件。在下例中,我们创建了一个类似复选框的Toggle
视图。点击控件时,图形变换颜色来表示当前的状态(灰色为停用,绿色为激活)。
示例6-35:定义一个自定义Toggle
视图
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 MyStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { HStack(alignment: .center) { configuration.label Spacer() Image(systemName: "checkmark.rectangle.fill") .font(.largeTitle) .foregroundColor(configuration.isOn ? Color.green : Color.gray) .onTapGesture { configuration.$isOn.wrappedValue.toggle() } } } } struct ContentView: View { @State private var currentState: Bool = false var body: some View { VStack { HStack { Toggle("Enabled", isOn: $currentState) .toggleStyle(MyStyle()) Spacer() }.padding() } } } |
在自定义Toggle
视图前必须要考虑几件事。首先,Configuration
结构体的label
属性包含一个控件当前标签的视图副本,因此如果想要保留这个标签,必须在新的内容中包含这个值。第二,Toggle
视图使用HStack
视图和标签与控件间的Spacer
视图来设计。如果想要保留标准设计,必须保持这种布局。第三,我们负责响应用户的交互以及更新控件的状态,因此必须检测手势并在用户执行手势时通过修改isOn
属性的值来变更控件状态。
在示例6-35中,我们定义了一个结构体MyStyle
并实现了所要求的makeBody()
方法来为Toggle
视图提供新设计。为保留标准样式,我们使用HStack
视图来包装视图并使用Spacer
视图来分隔标签与控件。首先我们读取label
属性的值来添加当前标签,然后声明Spacer
视图,最后声明一个Image
视图来展示外观像复选框的SF图标。为将Image
视图转换为控件,我们使用font()
修饰符定义其大小,应用foregroundColor()
修饰符来根据isOn
属性的当前值来修改图标的颜色,最后,使用onTapGesture()
修饰符监测用户何时点击Image
视图。我们会在第12章中学习更多有关手势修饰符的知识。现在,只需要知道这一修饰符在每次用户点击视图时执行一个闭包。在这个闭包中,我们访问isOn
属性的绑定值并通过对wrappedValue
属性中的布尔值应用toggle()
修饰符切换值。(本例中,绑定值的setter是私有的,因此通过wrappedValue
属性访问它,本章前面做过讲解。)这会修改该属性的当前值,进而改变控件的状态,将其打开及关闭。
图6-21:Toggle
视图的自定义样式
滑块视图
Slider
视图创建一个控件,允许用户选择一个范围内的值。显示为一个带结点的横条,结点对应所选择的值。该结构体包含如下初始化方法。
- Slider(value: Binding, in: Range, step: Float, onEditingChanged: Closure):这个初始化方法创建一个
Slider
视图。value
参数是希望用于存储当前值的绑定属性,in
参数是指定用户选择的最大、最小值范围,step
参数表示当前值递增或递减的量,onEditingChanged
参数是在用户开始或结束移动滑块时执行的闭包。
要创建滑动,必须至少提供一个@State
属性来存储值以及一个决定允许最小和最值的范围。
示例6-36:创建滑块
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var currentValue: Float = 5 var body: some View { VStack { Text("Current Value: \(currentValue.formatted(.number.precision(.fractionLength(0))))") Slider(value: $currentValue, in: 0...10, step: 1.0) Spacer() }.padding() } } |
示例6-36中的代码创建一个0到10的滑块,以Text
视图显示当前值。Slider
视图接收类型为Float
和Double
类型的值,因此允许我们选择浮点值,但我们可以通过声明step
参数值为1.0来指定希望用户选择的是整数,如本例所示。(注意我们需要使用第4章中介绍的formatted()
方法将Text
视图的值格式化为整数。)因为currentValue
属性使用数字5进行了初始化,结点的初始位置就位于正中间。
图6-22:整数值滑块
Slider
初始化方法还内置了onEdittingChanged
参数,接收一个闭包,闭包接收表示用户开始或结束移动滑块的布尔值。我们可以使用它来高亮显示编辑中的值,如下例所示。
示例6-37:响应滑块状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct ContentView: View { @State private var currentValue: Float = 5 @State private var textAcitve: Bool = false var body: some View { VStack { Text("Current Value: \(currentValue.formatted(.number.precision(.fractionLength(0))))") .padding() .background(textAcitve ? Color.yellow : Color.clear) Slider(value: $currentValue, in: 0...10, step: 1.0, onEditingChanged: { self.textAcitve = $0 }) Spacer() }.padding() } } |
示例6-37中的视图包含一个名为textActive
的新@State
属性。赋值给闭包的onEdittingChanged
参数在用户开始移动滑块时对该属性赋值true
,释放结点时赋值false
。Text
视图的background()
修饰符读取该值根据当前状态设置不同的背景色。因此,在用户移动滑块时显示当前值的文本为黄色背景,否则没有背景色。
进度视图
SwiftUi内置有ProgressView
视图来用于创建进度条。该视图设计用于显示任务的进度。
- ProgressView(String, value: Binding, total: Double):此初始化方法创建一个进度条。第一个参数指定标签,
value
参数表示当前进度,total
参数指定表示任务完成度的值。(默认值为0.0到1.0)。
视图的实现非常直观。我们只需要一个表示当前进度的属性值。
示例6-38:显示进度
1 2 3 4 5 6 7 8 9 10 |
struct ContentView: View { @State private var currentValue: Float = 5 var body: some View { VStack { ProgressView(value: currentValue, total: 10) Spacer() }.padding() } } |
这一ProgressView
视图值由0.0到10.0,初始值为5(通过currentValue
赋了初始值),因此进度位于中心。
图6-24:进度条
ProgressView
视图设计用于显示任务即时的进度,比如从服务端下载的当前数据量,或是还差多少完成任务。稍后我们会学习如何执行其中一些任务,但现在我们通过Slider
视图来做测试,如下所示。
示例6-39:模拟进度
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var currentValue: Float = 5 var body: some View { VStack { ProgressView(value: currentValue, total: 10) Slider(value: $currentValue, in: 0...10) Spacer() }.padding() } } |
本例中,我们对Slider
和ProgressView
设置了相同的值。范围为0到10,因此每当我们移动滑块时,进度条就显示相同值。
图6-25:使用中的进度条
ProgressView
结构体内置了如下修饰符用于定义进度条的样式。
- progressViewStyle(ProgressViewStyle):这一修饰符指定
ProgressView
视图的颜色 。参数是一个遵循ProgressViewStyle
协议的结构体。框架定义了automatic
、circular
和linear
属性来创建标准视图。
默认样式为automatic
,表示视图显示为一个线性进度条,但我们可以指定circular
值来创建活动指示。这是一个表示任务在处理中的转盘,但不同于进度条,这类指示符没有隐性界限,所示无需指定任何值,如下所示。
示例6-40:显示活动指示器
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var currentValue: Float = 5 var body: some View { VStack { ProgressView() .progressViewStyle(.circular) Spacer() }.padding() } } |
图6-26:活动指示器
步进器视图
Stepper
视图创建一个带递增和递减按钮的控件。该结构体提供了多个初始化方法,包含不同的配置参数组合。以下是最常用的一部分。
- Stepper(String, value: Binding, in: Range, step: Float, onEditingChanged: Closure):此初始化方法创建一个
Stepper
视图。第一个参数定义标签,value
参数是希望用于存储当前值的绑定属性,in
参数是允许的最大最小值范围,step
参数是一个指定递增或递减值Float
或Double
(取决于绑定属性。),onEditingChanged
参数是在用户开始及结束编辑该值时执行的闭包。 - Stepper(String, onIncrement: Closure?, onDecrement: Closure?, onEditingChanged: Closure):该初始化方法创建一个
Stepper
视图。第一个参数定义标签,onIncrement
参数是在用户点击+按钮时执行的闭包,onDecrement
参数是在用户点击-按钮时执行的闭包,onEditingChanged
参数是在用户开始及结束编辑该值时执行的闭包。
要实现一个Stepper
视图,我们需要一个存储当前值的@State
属性,并定义希望用户选取的范围值。
示例6-41:创建一个步进器
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var currentValue: Float = 0 var body: some View { VStack { Text("Current Value: \(currentValue.formatted(.number.precision(.fractionLength(0))))") Stepper("Counter", value: $currentValue, in: 0...100) Spacer() }.padding() } } |
Stepper
视图使用Float
或Double
类型的浮点值,因此我们将值格式化为显示整数。结果如下所示。
图6-27:步进器
默认,值按1个单位递增或递减,但我们可以通过step
参数来进行修改。下例定义了一个按5个单位递增或递减的Stepper
视图。
示例6-42:定义步进器的步长
1 2 3 4 5 6 7 8 9 10 11 |
struct ContentView: View { @State private var currentValue: Double = 0 var body: some View { VStack { Text("Current Value: \(currentValue.formatted(.number.precision(.fractionLength(0))))") Stepper("Counter", value: $currentValue, in: 0...100, step: 5) Spacer() }.padding() } } |
类似Toggle
视图,Stepper
视图通过水平堆叠以及标签与控件之间的弹性空间实现。如果希望提供自己的标签并对控件定义自定义位置,我们需要应用labelsHidden()
修饰符,如示例6-33。下例定义了一个自定义标签并通过onIncrement
和onDecrement
参数创建一个视图在屏幕上显示箭头在告知用户最终是递增或递减。
示例6-43:在值递增或增减时修改界面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct ContentView: View { @State private var currentValue: Float = 0 @State private var goingUp: Bool = true var body: some View { VStack { HStack { Text("Current Value: \(currentValue.formatted(.number.precision(.fractionLength(0))))") Image(systemName: goingUp ? "arrow.up" : "arrow.down") .foregroundColor(goingUp ? Color.green : Color.red) Stepper("", onIncrement: { currentValue += 5 goingUp = true }, onDecrement: { currentValue -= 5 goingUp = false }).labelsHidden() } Spacer() }.padding() } } |
本例中,我们定义了两个@State
属性:currentValue
用于存储步进器的当前值,布尔类型的属性goingUp
用于表示最终值是做了递增还是递减。各视图位于HStack
中并排显示。第一个是和之前一样的显示步进器当前值的Text
视图。在它之后,有一个Image
视图,检测goingUp
属性来根据属性值朝向、朝下的SF图标。同一个属性用于决定箭头的颜色。最后,我们定义了带onIncrement
和onDecrement
参数的Stepper
视图。在赋值给这些参数的闭包中,我们按5进行递增和递减,并修改goingUp
属性的值来表示最终是做了递增还是递减。结果就是,用记点击+按钮时看到绿色的向上箭头,点击-按钮时为红色的向下箭头。
图6-28:自定义步进器
组合框视图
SwiftUi内置了一个GroupBox
视图用于在一堆视图周边创建一个框。该视图定义有背景色以及视觉上对视图和控件分组的圆角。以下是该视图的一个初始化方法。
- GroupBox(String, content: Closure):该初始化方法创建一个
GroupBox
视图。第一个参数定义在框顶显示的标签,content
参数是一个闭包,定义组中所包含的视图。
该视图默认样式带有背景色,因此我们增压机实现它并添加一个包含希望放到框内视图的闭包。
示例6-44:定义一个视图分组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct ContentView: View { @State private var setting1: Bool = true @State private var setting2: Bool = true @State private var setting3: Bool = true var body: some View { GroupBox("Settings") { VStack(spacing: 10) { Toggle("Autocorrection", isOn: $setting1) Toggle("Capitalization", isOn: $setting2) Toggle("Editable", isOn: $setting3) } }.padding() } } |
图6-29:组合框
模型
专业应用中包含有多个视图,分别表示可以导航的不同界面。这些视图要访问同样的数据并对应用中状态的改变进行响应。因此 ,这些应用必须可供访问的独立数据源并可由所有视图修改。这一数据源通常称之为模型。模型是应用基本结构的一个部分。在这一范式中,一组长结构体或对象定义该模型(应用的数据及状态),而连接数据的视图在屏幕上展示数据并根据用户的输入更新模型。
示例6-30:数据模型
这种组织方式无法通过@State
属性创建。前面示例中使用的@State
属性封装器只能存储控制单一视图状态的值。我们需要的是一个可以传递给其它视图并对系统上报变更的对象。SwiftUI内置了如下的宏来定义这一对象。
- @Observable:这个宏对类添加允许属性存储和管理应用状态所需的代码。
借助这个宏,我们可以将任意的类转换为可观测对象,也就是说我们可以使用该对象的属性存储和管理应用的状态。下面可以看到这种类定义的示例。我们称之为ApplicationData
,但可以使用任意其它的名称。注意@Observable
宏在Observation框架中定义,因此需要导入该框架才能使用这个宏。
示例6-45:在可观测对象中存储数据
1 2 3 4 5 6 7 |
import SwiftUI import Observation @Observable class ApplicationData { var title: String = "Default Title" var titleInput: String = "" } |
这一模型包含两个属性。名为title
的属性用于存储书的标题,另一个名为titleInput
的属性用于允许用户输入新标题。但因为我们是使用@Observable
宏来修改该类,就无需再在视图中声明@State
属性了。这个宏会处理该属性存储状态和上报变更到系统所需要代码的生成。
重要:定义用于存储数据的类型是应用的核心部分,可能会很大,因此将它们放到单独的Swift文件中比较合理。只需打开屏幕上方的File菜单,选择New/File,再在iOS面板中选择Swift File图标(见图5-107)。就会添加该文件,文件内部所定义的数据可在代码的任意位置访问。
有了模型之后,我们需要创建一个该类的实例并将其传递给视图。对于简单的应用,我们只需要创建实例并将其赋值给视图的属性即可,如下例所示。
示例6-46:初始化可观测对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct ContentView: View { var appData = ApplicationData() var body: some View { VStack(spacing: 0) { Text(appData.title) .padding(10) Button(action: { appData.title = "New Title" }, label: { Text("Save") }) Spacer() }.padding() } } |
和之前极其相似,但不再读取和存储@State
属性中的值,而是对ApplicationData
对象的属性进行操作。视图中包含一个读取title
属性的Text
视图并在屏幕上显示其值,以及一个对属性赋值新字符串的按钮。在点击按钮时,新字符串New Title被赋值给了title
属性,该属性向系统上报变更,系统更新视图来在界面中显示新的值。
✍️跟我一起做:创建一个多平台荐。打开屏幕顶部的File菜单,点击New/File选项创建一个新的Swift文件(见图5-107)。将文件命名为ApplicationData.swift
,使用示例6-45中的代码替换其中的内容。使用示例6-46中的代码更新ContentView
视图。此时会在屏幕上看到一个标题和一个按钮。点击按钮,标题会发生改变。
在本例是中,我们显示了title
属性并在点击按钮时修改了其值,但前面我们已经学到,有控件可以让用户输入新值。此时,我们需要创建一个双向绑定以便每次用户与控件进行交互时,新值存储到相应的属性中,界面得到更新。为此,我们需要使用如下的属性包装器将可观测对象声明为可绑定。
- @Bindable:这一属性包装器在属性和可观测对象之间创建了一个双向绑定。
在下面的代码中,我们对前例添加了一个TextField
视图来演示如何与可观测对象建立双向绑定。
示例6-47:与可观测对象建立双向绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct ContentView: View { @Bindable var appData = ApplicationData() var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: $appData.titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = appData.titleInput appData.titleInput = "" }, label: { Text("Save") }) Spacer() }.padding() } } |
和之前一样,我们需要在属性前添加一个美元符号来告知TextField
视图要存储用户插入的值。因为使用@Bindable
修改了appData
属性,可观测对象接收到值并存入模型中。在点击按钮时,我们执行与之前一样的流程。用户插入的字符被赋值给了title
属性,屏幕上的界面进行更新显示这些字符。
✍️跟我一起做:使用示例6-47中的代码更新ContentView
视图。在界面中会看到标题、文本框以及一个按钮。在文本框中插入文本并按下按钮。输入的文本会替换掉原标题。
在示例6-45的模型中,我们定义了两个属性,title
用于存储实际信息,titleInput
用于接收用户的输入。应用中稍后添加的其它视图可能需要访问title
属性来向用户显示其值,但titleInput
属性仅在包含TextField
视图的视图中用到。也就意味着我们在应用的模型存储了视图的私有状态。虽然这种方法没什么错,但推荐做法是用模型存储应用数据,但在视图内管理视图的状态。可以实现不同的模式来组织应用。一种方法是为每个视图分别定义@State
属性,类似前面那样,但另一种试是创建额外的可观测对象。例如,我们可以从模型中删除titleInput
属性,并为视图定义一个可观测对象来管理用户的输入,如下所示。
示例6-48:定义一个视图级别的可观测对象
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 |
import SwiftUI import Observation @Observable class ViewData { var titleInput: String = "" } struct ContentView: View { @Bindable var viewData = ViewData() var appData = ApplicationData() var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: $viewData.titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = viewData.titleInput viewData.titleInput = "" }, label: { Text("Save") }) Spacer() }.padding() } } |
基本和之前一样,只是定义了一个针对 ContentView
视图的可观测对象ViewData
。现在用户插入的字符存储于这一对象的titleInput
属性中,因此模型和视图状态做了隔离。在用户点击Save按钮时,我们将titleInput
属性的值赋值给title
属性,这样新标题就存储到了模型中。
✍️跟我一起做:删除ApplicationData
类中的titleInput
属性。使用示例6-48中的代码更新ContentView.swift
文件。功能和此前一样,但现由视图自己取代模型来管理其状态。
使用可观测对象来代替@State
属性管理视图状态的一个好处是,更易于动态初始化对象的属性。例如,我们可以在ContentView
结构体初始化时将存储在模型中的当前标题赋值给titleInput
属性,这样用户就可以在屏幕上看到之前的值。
示例6-49:初始化视图的可观测对象
1 2 3 |
init() { viewData.titleInput = appData.title } |
这个初始化方法将模型的title
属性值赋值给viewData
对象的titleInput
属性,因而TextField
视图在整体视图出现时显示当前值。
图6-31:使用模型中的值初始化文本字段
✍️跟我一起做:将示例6-49中的初始化方法添加到ContentView
结构体中(body
属性定义的上方)。赋值给模型中title
属性的字符串会如图6-31那样出现在文本框内。
另一种使用可观测对象的属性或@State
属性初始化的方法是onAppear()
修饰符。如前所见,这一修饰符在视图出现于屏幕上时执行一个闭包。例如,我们可以将其赋值给ContentView
视图的VStack
视图来在屏幕上显示视图时初始化titleInput
属性。
示例6-50:在视图出现时初始化视图的可观测对象
1 2 3 |
.onAppear { viewData.titleInput = appData.title } |
✍️跟我一起做:从ContentView
结构体中删除示例6-49中添加的初始化方法。对VStack
视图添加示例6-50中的onAppear()
修饰符(位于padding()
修饰符下方)。结果和前面一样。
虽然可观测对象对于构建应用的模型以及管理视图状态很好,但视图并不一定需要在值发生改变时进行更新。如果可观测对象中存在属性,不需要在每次发生值改变时更新视图,就可以实现如下的装饰宏。
- @ObservationIgnored:这个宏生成取消指定属性观测的代码。
例如,我们可以对可观测对象添加一个属性,用于对本例视图中Save按钮点击次数进行计数。
示例6-51:取消可观测对象中属性的观测
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 |
import SwiftUI import Observation @Observable class ViewData { var titleInput: String = "" @ObservationIgnored var counter: Int = 0 } struct ContentView: View { @Bindable var viewData = ViewData() var appData = ApplicationData() var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: $viewData.titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = viewData.titleInput viewData.titleInput = "" viewData.counter += 1 print("Current Counter: \(viewData.counter)") }, label: { Text("Save") }) Spacer() }.padding() } } |
每次按下Save按钮时,我们都对可观测对象中的counter
属性累加1,但因为这个属性通过ObservationIgnored
宏进行修饰,视图不会在每次值发生改变时更新。
访问模型
我们的应用可能一个展示菜单的视图、一个展示内容列表的视图以及另一个显示用户所做选择信息的视图。所有这些视图都必须访问相同的数据,因此都要包含对模型的引用。将指针从一个视图传到另一个视图直到需要使用值的视图比较笨重且容易出错。更好的选择是将模型的指针传入环境,然后在需要时从环境中读取引用。
图6-32:通过环境访问模型
如前所述,环境是存储应用与视图相关信息的通用容易,但它也可以存储自定义数据,包含对可观测对象的引用。在图6-32中,可观测对象的一个实例添加到环境中,然后仅由需要使用它的视图访问。
可观测对象通过environment()
修饰符添加,并通过@Environment
属性所定义的属性访问。必须要考虑的是environment()
修饰符将对象赋值给一个视图层级的对象,因此我们必须将其应用于界面上所有视图的初始视图才能访问它。下例展示了我们需要对App
结构体所做的修改,创建了一个ApplicationData
对象并将其添加到应用的初始视图的环境中(ContentView
视图)。
示例 6-52:将可观测对象赋值给视图的环境
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import SwiftUI @main struct TestApp: App { @State private var appData = ApplicationData() var body: some Scene { WindowGroup { ContentView() .environment(appData) } } } |
ApplicationData
类的实例必须存储于@State
属性中。有了这个对象后,我们对ContentView
应用environment()
修饰符将对象注入到环境中。在视图中访问对象很简单。只需要通过@Environment
属性包装器创建一个属性,如下所示。
示例6-53:从环境中获取可观测对象的引用
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 |
import SwiftUI import Observation @Observable class ViewData { var titleInput: String = "" } struct ContentView: View { @Bindable var viewData = ViewData() @Environment(ApplicationData.self) private var appData var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: $viewData.titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = viewData.titleInput viewData.titleInput = "" }, label: { Text("Save") }) Spacer() }.padding() } } #Preview { ContentView() .environment(ApplicationData()) } |
使用@Environment
属性包装器,我们可以在界面中的任意视图中访问模型。但这并不适用于不同层级的视图,比如由预览所创建的ContentView
视图。这一视图有其自身的层级和环境,因此我们需要再创建一个模型实例,将其注入到预览自己的环境中才能使用。
✍️跟我一起做:使用示例6-52中的代码更新App
结构体,并用示例6-53中的代码更新ContentView.swift
文件。应用和之前功能相同,这现在是通过环境获取可观测对象,因此它们在ContentView
相同层级的所有视图中都可使用。我们会在第8章中学习如何对相同层级添加更多视图。
如果希望环境属性能够处理双向绑定属性,需要使用@Bindable
属性包装器将其转化为可绑定属性。例如,我们像示例6-45中那样将用户输入存储到模型的属性中,还需要像如下那样创建单独的视图来处理其值。
示例6-54:将环境属性转变成可绑定属性
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 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { MyInputView(appData: appData) } } struct MyInputView: View { @Bindable var appData: ApplicationData var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: $appData.titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = appData.titleInput appData.titleInput = "" }, label: { Text("Save") }) Spacer() }.padding() } } |
上例中,我们像之前一样从环境中访问模型,但现在将值传递给另一个视图MyInputView
。这个视图通过@Bindable
属性包装器使得模型可绑定,此时就可以使用模型中的titleInput
属性管理用户输入的值。
如果仅部分属性需要双向绑定,我们可以不使用属性包装器,而是通过Bindable
结构体逐一转化属性。下例和之前功能一致,但我们通过仅在需要时将appData
属性转化为可绑定属性简化了代码。
示例6-55:通过Bindable
结构体创建一个可绑定属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
struct ContentView: View { @Environment(ApplicationData.self) private var appData var body: some View { VStack(spacing: 8) { Text(appData.title) .padding(10) TextField("Insert Title", text: Bindable(appData).titleInput) .textFieldStyle(.roundedBorder) Button(action: { appData.title = appData.titleInput appData.titleInput = "" }, label: { Text("Save") }) Spacer() }.padding() } } |
✍️跟我一起做:使用示例6-54中的代码更新ContentView.swift
,再使用示例6-45中的ApplicationData.swift
。应用功能和之前相同,但现在可以管理模型中的双向绑定属性。
代码请见:GitHub仓库