SwiftUI 的 @State
属性包装器主要为当前视图的简单数据而设计的。SwiftUI 说 “视图是其状态的函数”,意思是用户界面的外观(人可以看到的内容以及可交互的内容),是由程序的状态决定的。@State
就是一种当前视图的程序状态。
<aside>
💡 如果您发现很难记,请尝试:每当在属性包装器中看到“State”时,例如 @State
、 @StateObject
、 @GestureState
,都表示“当前视图拥有此数据”, 它们都应该只和当前视图有关。
</aside>
<aside>
💡 虽然 @State
主要用于值类型,但也可以将其与引用类型一起使用。这确实意味着每当值更改时,您的视图主体不会被重新调用,但通常没关系。我们实际上把 @State
用作缓存,这样就不会在每次重新创建视图时都一次又一次地重新创建引用对象。这对于不符合 ObservableObject 协议的类来说比较有用。
</aside>
以下代码会报错。因为 ContentView
是一个结构体,可以将其创建为常量的实例,这意味着它是不可变的,我们不能随意改变它的属性。当创建“要更改属性”的方法时,需要添加 mutating
关键字,参照:结构 Struct 的 mutating 方法
struct ContentView: View {
var tapCount = 0
var body: some View {
Button("Tap Count: \\(tapCount)") {
tapCount += 1
}
}
}
但是 Swift 不允许改变计算属性,这意味着不能编写 mutating var body: some View
的代码。这似乎陷入了僵局:我们希望能够在程序运行时更改值,但 Swift 不允许这样做,因为视图是结构体。于是 Swift 提供了一个特殊的解决方案,称为属性包装器(即可以在属性之前放置一个特殊属性,从而有效地赋予它超能力)。
@State
解决了结构体的限制:我们无法更改 struct 的属性,因为 struct 是固定的。但 @State
允许 SwiftUI 将该值单独存储在可以修改的地方,而 struct 变成了去引用它。
@state
来标注属性时,SwiftUI 会自动将后面的参数储存在应用程式的某个地方,而后面的代码只是一个指针,指向那个储存的参数。无论在哪里改变储存的参数,都会刷新 body 。这样一来 body 和状态将始终同步,用户界面和状态总是一致。@State
专为存储在一个视图中的简单属性而设计,因此 Apple 建议将 @State
属性标记为 private
,以真正强调它们并非设计为在别处访问。只能从视图本身(或者从被它所调用的函数)內部存取一个状态属性,可以避免视图的用户去存取它。@State private var alertIsVisible: Bool = false
@State private var sliderValue: Double = 80.0
//当将 alertIsVisible 设置为 true 时,会发生什么?那就是改变了应用的状态,所以随后用户界面的更新要保持一致。
//已经用@state标记了的变量,iOS就会自动刷新body 。
//所以你的工作是确保 "body " 考虑到应用程序的状态,并在alertIsVisible为真时,显示一个警报弹出。
Swift 会区分 “仅显示属性的值” 和 “显示属性的值,但同时将任何更改写回该属性” 两种情况,这就是双向绑定。我们用美元 $ 符号标记这些双向绑定,以便它们更明显。这告诉 Swift 它应该读取属性的值,但也应该在发生任何更改时将其写回。
因此,当您在属性名称之前看到美元符号时,请记住它创建了双向绑定:可以读取属性的值,也可以写入该属性的值。
//例子1:
struct ContentView: View {
@State private var name = ""
var body: some View {
Form {
//1. 这里是双向绑定,即可以显示,而对文本字段的任何更改也会更新到 @State 属性中
TextField("Enter your name", text: $name)
//2. 请注意这里不是双向绑定(使用 name 而不是 $name),因为这里只想读取值,而没有要求可以修改值
Text("Your name is \\(name)")
}
}
}
//例子2
.alert(isPresented: $alertIsVisible) { () -> Alert in
var roundValue: Int = Int(self.sliderValue.rounded())
return Alert(title: Text("Hello there!"), message: Text("The slider's value is \\(roundValue)"), dismissButton: .default(Text("Awesome")))
}
Slider(value: self.$sliderValue, in: 1 ... 100)
<aside>
💡 TextField 有一个参数是:text: Binding<String>)
,所以它是要用到绑定参数的。而 text 就没有这个类型的参数。
</aside>
当你使用到双向绑定时,预览模块经常会报错,因为它需要一个确定的值进行预览。这时使用常量绑定很有帮助:虽然硬编码值不会更改,但可以仍像常规绑定一样用,让你的预览代码可以正常工作。
Toggle(isOn: .constant(true)) {
Text("Show advanced options")
}
// 这些常量绑定有各种类型:布尔值、字符串、整数等等,Swift 将确保您为每种视图类型使用正确的类型。
如果你想针对一些绑定值,运行一些逻辑来计算当前值该怎么办呢?如果想对绑定值的更改做出反应,我们可能会尝试利用 didSet
属性观察器,但你会发现这在 swiftUI 中不可行。
这就是自定义绑定发挥作用的时候了,我们可以使用 Binding
类型手动创建绑定,该类型可以提供自定义 get
和 set
闭包,以便在读取值或写入值。
最简单的自定义绑定例子:它只是将值存储在另一个 @State
属性中,然后读回该值:
struct ContentView: View {
@State private var selection = 0
var body: some View {
// 该绑定 binding 实际只是传递,它本身不存储或计算任何数据,只是充当 UI 和正在操作的基础状态值之间的填充码
// 该类型可以提供自定义 get 和 set 闭包
let binding = Binding(
get: { selection },
set: { selection = $0 }
)
return VStack {
// 注意:当绑定到自定义 Binding 实例时,不需要在绑定名称前使用美元符号,你已经在读取双向绑定了
Picker("Select a number", selection: binding) {
ForEach(0..<3) {
Text("Item \\($0)")
}
}
}
}
}
创建高级的自定义绑定:它不仅仅是传递单个值。例如,假设我们有一个包含三个切换开关的表单:用户是否同意条款和条件、是否同意隐私政策以及是否同意接收有关运输的电子邮件。
//我们可以将其表示为三个布尔 @State 属性:
@State var agreedToTerms = false
@State var agreedToPrivacyPolicy = false
@State var agreedToEmails = false
//虽然用户只需手动切换它们,但我们可以使用自定义绑定一次性完成所有操作。如果这三个布尔值都为 true,则此绑定为 true,但如果它被更改,那么它将更新它们,如下所示:
let agreedToAll = Binding(
get: {
agreedToTerms && agreedToPrivacyPolicy && agreedToEmails
},
set: {
agreedToTerms = $0
agreedToPrivacyPolicy = $0
agreedToEmails = $0
}
)
这样,我们就可以创建四个切换开关了:其中三个开关是用于控制单个布尔值,而一个全局开关控制所有三个开关:
struct ContentView: View {
@State private var agreedToTerms = false
@State private var agreedToPrivacyPolicy = false
@State private var agreedToEmails = false
var body: some View {
// 这里声明了一个布尔值类型的自定义绑定
let agreedToAll = Binding<Bool>(
get: {
agreedToTerms && agreedToPrivacyPolicy && agreedToEmails
},
set: {
// 绑定后,$0是闭包传入的第一个参数,在这个例子中也就是开关的布尔值
agreedToTerms = $0
agreedToPrivacyPolicy = $0
agreedToEmails = $0
}
)
return VStack {
Toggle("Agree to terms", isOn: $agreedToTerms)
Toggle("Agree to privacy policy", isOn: $agreedToPrivacyPolicy)
Toggle("Agree to receive shipping emails", isOn: $agreedToEmails)
Toggle("Agree to all", isOn: agreedToAll)
}
}
}
<aside>
💡 请注意,let agreedToAll = Binding<Bool>
这个绑定是放到 body 属性里面的,如果放到了 body 外面会报错:“Cannot use instance member 'agreedToTerms' within property initializer; property initializers run before 'self' is available”。大体意思是:
如果放到 body 的外面:因为 @State 属性标记的值,是会在 struct 实例化以后专门找地方存起来的,如果还没有实例化,这个 agreedToTerms
成员都尚不可用,值不存在。我们不能在属性初始化器中尝试使用一些实例化的成员。因此这个时候去算 agreeToAll
的计算属性,是无法算的。
属性初始化器必须在结构体实例完成后才有效。所以这里需要放在计算属性 body 里面。
</aside>
例如以下代码:希望每当文本模糊属性值改变时,打印一条消息。这时会发现:当拖动滑块时,文本模糊量发生了变化,但 print()
语句不会被触发,什么也不会输出。但如果尝试按下按钮,将看到打印一条消息。
@State private var blurAmount = 0.0 {
didSet { print("New value is \\(blurAmount)") }
}
VStack {
Text("Hello").blur(radius: blurAmount)
Slider(value: $blurAmount, in: 0...20)
Button("Random Blur") {
blurAmount = Double.random(in: 0...20)
}
}
属性包装器,其实是将属性包装在另一个结构中。这意味着当使用 @State
来包装字符串时,得到的属性类型是 State<String>
。类似地当使用 @Environment
和其他内容时,最终会得到一个 Environment
类型的结构。之前说过不能修改视图中的属性,因为它们是 structs
。那现在 @State
本身又会生成一个 structs
,那怎么样可以修改该结构体呢?
// 使用 Cmd+Shift+O 快速访问(它可以在项目或已导入的框架中查找任何文件或类型),然后输入“State”,选择标记了 SwiftUI 的 State ,可以看到:
@propertyWrapper public struct State<Value> : DynamicProperty {
public var wrappedValue: Value { get nonmutating set }
...
}
<aside> 💡 结论:因此希望观察绑定属性的变化,不应该用观察属性,应该用 onChange 修饰符。参见:onChange 当改变时
</aside>
绑定后,所有用到该状态值的UI属性,都有动画效果
在 SwiftUI 中,每个 @State 状态属性都要有一个初始值,这是因为 @State
属性是用来存储视图自身的状态,必须在初始化时就给它一个合适的初始值。以下是给 @State
状态属性赋【初始值】的常用的方式:
参考文章:
https://developer.apple.com/documentation/swiftui/state
https://onevcat.com/2021/01/swiftui-state/
https://fatbobman.com/zh/posts/swiftui-state/
最简单的就是可以在定义@State
属性时直接给它一个初始值。
@State private var isPlaying = false
如果视图在代码中没有给 @State 属性赋默认值,可以通过 视图A 实例化视图 B 时补上。
//这种方式每次进入视图B,状态属性初始值都是由视图A传入值决定
//视图A
@State private var value = 99
var body: some View {
DetailView0(number: value)
}
//视图B
struct DetailView0: View {
@State var number: Int
var body: some View {
HStack {
Text("0: \\(number)")
Button("+") { number += 1 }
}
}
}
如果需要对其他视图直接传入的参数,做一些处理加一些逻辑,一般会通过 init 初始化函数进行赋值。
// 写法1:直接写逻辑
struct DetailView1: View {
@State private var number: Int
init(number: Int) {
self.number = number + 1
}
}
// 方式2: 通过 init 初始化方法设置 initialValue
// 在提供的代码中,_name 和_description 使用 State(initialValue:) 来初始化,这是一种设置 @State 属性初始值的方式。
// 状态参数
@State private var name: String
@State private var description: String
// 初始化
init(location: Location, onSave: @escaping (Location) -> Void) {
self.location = location
self.onSave = onSave
_name = State(initialValue: location.name)
_description = State(initialValue: location.description)
}
// 方式3: 通过 init 初始化方法设置 wrappedValue
struct DetailView2: View {
@State private var number: Int
init(number: Int) {
_number = State(wrappedValue: number + 1)
}
var body: some View {
return HStack {
Text("2: \\(number)")
Button("+") { number += 1 }
}
}
}
<aside>
💡 @State
属性包装器用于在视图中管理状态。在给 @State
属性初始化时,需要使用下划线来设置初始值。这是因为 @State
属性是一个属性包装器,它需要通过下划线来访问其包装的值。
</aside>
@State
属性的初始值还可以从环境对象 (@EnvironmentObject
) 中获取,和上面一样也是在视图的初始化方法中实现。
由于属性包裹器(如@State)不能用于计算属性,所以会带来一些问题。当我们某个属性是动态变化的(例如获取当前时间作为某控件默认值),但又支持用户使用控件去修改它(通过DatePicker去调整默认值),就不好办了。于是可以:
@State
包裹器,让它去做绑定;修复方案很简单:可以将 defaultWakeTime
设为静态变量,这意味着它属于 ContentView
结构本身,而不是该结构的单个实例。这反过来意味着 defaultWakeTime 可以随时读取,因为它不依赖于任何其他属性的存在。于是加上 static
静态关键词
static var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? .now
}
@State private var wakeUp = defaultWakeTime
<aside>
💡 关于 Struct 中静态属性和非静态属性的相互引用,看这里:静态属性/方法 static
【静态属性/方法】不能引用【非静态属性/方法】;而从【非静态代码】访问【静态代码】需要加上类型的名称或者 Self
</aside>
使用 @State
是存储本地数据的最简单的方法,有时它完全是正确的做法。但同时它也是可测试性的基准:你使用 @State 的次数越多,你编写的代码可以测试的希望就越小。