状态
在上一章,我们介绍了SwiftUI的主要特性,声明式语法。借助SwiftUI,我们可以按希望在屏幕上显示的方式声明视图,余下交由系统来创建所需的代码。但声明式语法不只用于组织视图,还可在应用状态发生变化时更新视图。例如,我们可以有下面图6-1中的界面,显示标题的Text
视图,用户输入新标题的输入字段以及将旧标题替换成新标题的按钮。原标题的Text
视图表示我们界面的初始状态。用户在输入框中输入每个字符时状态都会发生更新(图6-1左图),点击按钮时,界面进入一个新状态,用户插入的标题会替换原标题,文本的颜色也发生变化(图6-1右图)。
图6-1:用户界面
每次状态发生改变时,必须更新视图来进行反馈。在之前的系统中,这要求代码保持数据及界面同步,但在声明式语法中我们只需要声明每个状态的视图配置,系统会负责生成在屏幕上显示这些改变所需的代码。
界面可能经历的状态由存储在应用中的信息决定。例如,用户在输入框架中插入的字符以及示例中使用的颜色都是存储在应用中的值。每当这些值发生改变时,应用都会进入新状态,因此界面会发生更新进行反馈。建立应用数据与界面之间的依赖需要大量的代码,但SwiftUI通过属性包装器让其保持简单。
@State
在第3章中讨论过,属性包装器让我们可以定义用赋给它们的值定义可执行任务的属性。SwiftUI实现了大量的属性包装器来存储值并向视图上报修改。设计用于存储单个视图状态的名为@State
。这个属性包装器将值存储在类型为State
的结构体中,并在值发生改变时通知系统,这样视图会自动更新来在屏幕中进行反映。
属性包装器@State
是设计用于存储单个视图的状态的。因此,我们应将这个类型的属性声明为视图结构体的一部分,并使用private
,这样访问就可以限定在所声明的结构体内了。
示例6-1:定义一个状态
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:定义多个状态
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:定义双向绑定
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
属性
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
结构体的属性
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
属性
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
属性的值
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
结构体
#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
结构体
#Preview("Header") { HeaderView(title: .constant("My Preview Title"), titleInput: .constant("")) }
✍️跟我一起做:在ContentView.swift
文件中添加示例6-9中的结构体。此时会在画布顶部多出现一个按钮,显示HeaderView
视图的预览。
其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记