步进器视图
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:创建一个步进器
struct ContentView: View {@State private var currentValue: Float = 0var 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:定义步进器的步长
struct ContentView: View {@State private var currentValue: Double = 0var 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:在值递增或增减时修改界面
struct ContentView: View {@State private var currentValue: Float = 0@State private var goingUp: Bool = truevar 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 += 5goingUp = true}, onDecrement: {currentValue -= 5goingUp = 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:定义一个视图分组
struct ContentView: View {@State private var setting1: Bool = true@State private var setting2: Bool = true@State private var setting3: Bool = truevar 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:在可观测对象中存储数据
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:初始化可观测对象
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:与可观测对象建立双向绑定
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.titleInputappData.titleInput = ""}, label: {Text("Save")})Spacer()}.padding()}
}
和之前一样,我们需要在属性前添加一个美元符号来告知TextField
视图要存储用户插入的值。因为使用@Bindable
修改了appData
属性,可观测对象接收到值并存入模型中。在点击按钮时,我们执行与之前一样的流程。用户插入的字符被赋值给了title
属性,屏幕上的界面进行更新显示这些字符。
✍️跟我一起做:使用示例6-47中的代码更新ContentView
视图。在界面中会看到标题、文本框以及一个按钮。在文本框中插入文本并按下按钮。输入的文本会替换掉原标题。
在示例6-45的模型中,我们定义了两个属性,title
用于存储实际信息,titleInput
用于接收用户的输入。应用中稍后添加的其它视图可能需要访问title
属性来向用户显示其值,但titleInput
属性仅在包含TextField
视图的视图中用到。也就意味着我们在应用的模型存储了视图的私有状态。虽然这种方法没什么错,但推荐做法是用模型存储应用数据,但在视图内管理视图的状态。可以实现不同的模式来组织应用。一种方法是为每个视图分别定义@State
属性,类似前面那样,但另一种试是创建额外的可观测对象。例如,我们可以从模型中删除titleInput
属性,并为视图定义一个可观测对象来管理用户的输入,如下所示。
示例6-48:定义一个视图级别的可观测对象
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.titleInputviewData.titleInput = ""}, label: {Text("Save")})Spacer()}.padding()}
}
基本和之前一样,只是定义了一个针对 ContentView
视图的可观测对象ViewData
。现在用户插入的字符存储于这一对象的titleInput
属性中,因此模型和视图状态做了隔离。在用户点击Save按钮时,我们将titleInput
属性的值赋值给title
属性,这样新标题就存储到了模型中。
✍️跟我一起做:删除ApplicationData
类中的titleInput
属性。使用示例6-48中的代码更新ContentView.swift
文件。功能和此前一样,但现由视图自己取代模型来管理其状态。
使用可观测对象来代替@State
属性管理视图状态的一个好处是,更易于动态初始化对象的属性。例如,我们可以在ContentView
结构体初始化时将存储在模型中的当前标题赋值给titleInput
属性,这样用户就可以在屏幕上看到之前的值。
示例6-49:初始化视图的可观测对象
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:在视图出现时初始化视图的可观测对象
.onAppear {viewData.titleInput = appData.title
}
✍️跟我一起做:从ContentView
结构体中删除示例6-49中添加的初始化方法。对VStack
视图添加示例6-50中的onAppear()
修饰符(位于padding()
修饰符下方)。结果和前面一样。
虽然可观测对象对于构建应用的模型以及管理视图状态很好,但视图并不一定需要在值发生改变时进行更新。如果可观测对象中存在属性,不需要在每次发生值改变时更新视图,就可以实现如下的装饰宏。
- @ObservationIgnored:这个宏生成取消指定属性观测的代码。
例如,我们可以对可观测对象添加一个属性,用于对本例视图中Save按钮点击次数进行计数。
示例6-51:取消可观测对象中属性的观测
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.titleInputviewData.titleInput = ""viewData.counter += 1print("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:将可观测对象赋值给视图的环境
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:从环境中获取可观测对象的引用
import SwiftUI
import Observation@Observable class ViewData {var titleInput: String = ""
}struct ContentView: View {@Bindable var viewData = ViewData()@Environment(ApplicationData.self) private var appDatavar body: some View {VStack(spacing: 8) {Text(appData.title).padding(10)TextField("Insert Title", text: $viewData.titleInput).textFieldStyle(.roundedBorder)Button(action: {appData.title = viewData.titleInputviewData.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:将环境属性转变成可绑定属性
struct ContentView: View {@Environment(ApplicationData.self) private var appDatavar body: some View {MyInputView(appData: appData)}
}struct MyInputView: View {@Bindable var appData: ApplicationDatavar body: some View {VStack(spacing: 8) {Text(appData.title).padding(10)TextField("Insert Title", text: $appData.titleInput).textFieldStyle(.roundedBorder)Button(action: {appData.title = appData.titleInputappData.titleInput = ""}, label: { Text("Save") })Spacer()}.padding()}
}
上例中,我们像之前一样从环境中访问模型,但现在将值传递给另一个视图MyInputView
。这个视图通过@Bindable
属性包装器使得模型可绑定,此时就可以使用模型中的titleInput
属性管理用户输入的值。
如果仅部分属性需要双向绑定,我们可以不使用属性包装器,而是通过Bindable
结构体逐一转化属性。下例和之前功能一致,但我们通过仅在需要时将appData
属性转化为可绑定属性简化了代码。
示例6-55:通过Bindable
结构体创建一个可绑定属性
struct ContentView: View {@Environment(ApplicationData.self) private var appDatavar 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.titleInputappData.titleInput = ""}, label: { Text("Save") })Spacer()}.padding()}
}
✍️跟我一起做:使用示例6-54中的代码更新ContentView.swift
,再使用示例6-45中的ApplicationData.swift
。应用功能和之前相同,但现在可以管理模型中的双向绑定属性。