@State

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 private var alertIsVisible: Bool = false
@State private var sliderValue: Double = 80.0

//当将 alertIsVisible 设置为 true 时,会发生什么?那就是改变了应用的状态,所以随后用户界面的更新要保持一致。 
//已经用@state标记了的变量,iOS就会自动刷新body 。
//所以你的工作是确保 "body " 考虑到应用程序的状态,并在alertIsVisible为真时,显示一个警报弹出。

@State 的使用

1. 属性的双向绑定

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 将确保您为每种视图类型使用正确的类型。

2. 创建自定义绑定

如果你想针对一些绑定值,运行一些逻辑来计算当前值该怎么办呢?如果想对绑定值的更改做出反应,我们可能会尝试利用 didSet 属性观察器,但你会发现这在 swiftUI 中不可行。

这就是自定义绑定发挥作用的时候了,我们可以使用 Binding 类型手动创建绑定,该类型可以提供自定义 get 和 set 闭包,以便在读取值或写入值。

3. 属性绑定不会触发观察

例如以下代码:希望每当文本模糊属性值改变时,打印一条消息。这时会发现:当拖动滑块时,文本模糊量发生了变化,但 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 }
	...
}
  1. 用 @State 来包装属性时,它会将属性包装在另一个 struct 中,这个 struct 不会变;
  2. wrappedValue 才是要存储的实际值,例如字符串。这个生成的接口告诉我们的是,该属性可以读取( get )和写入( set )。但是当设置该值时,它实际上不会改变 struct 本身。它在幕后将该值储存到 SwiftUI 可以自由修改的位置,struct 本身不会改变;
  3. 当使用按钮更改属性时,它会通过非可变的 setter 去改变,因此它可以触发 didSet 观察属性;
  4. 而当使用“属性绑定”时,它会绕过 setter ,直接改变内部存储的实际值,因此它不会触发 didSet 观察属性;

<aside> 💡 结论:因此希望观察绑定属性的变化,不应该用观察属性,应该用 onChange 修饰符。参见:onChange 当改变时

</aside>

4. 可将 @State 值和动画关联

绑定后,所有用到该状态值的UI属性,都有动画效果

Screenshot - 2023-05-29 13.58.55.png


@State 的初始化

在 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/

1. 声明时赋值

最简单的就是可以在定义@State属性时直接给它一个初始值。

@State private var isPlaying = false

2. 由其它视图传入

如果视图在代码中没有给 @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 }
        }
    }
}

3. 在自身的 init 方法中赋值

如果需要对其他视图直接传入的参数,做一些处理加一些逻辑,一般会通过 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>

4. 从环境对象获取

@State 属性的初始值还可以从环境对象 (@EnvironmentObject) 中获取,和上面一样也是在视图的初始化方法中实现。

5. 通过计算属性赋值

由于属性包裹器(如@State)不能用于计算属性,所以会带来一些问题。当我们某个属性是动态变化的(例如获取当前时间作为某控件默认值),但又支持用户使用控件去修改它(通过DatePicker去调整默认值),就不好办了。于是可以:

修复方案很简单:可以将 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 是存储本地数据的最简单的方法,有时它完全是正确的做法。但同时它也是可测试性的基准:你使用 @State 的次数越多,你编写的代码可以测试的希望就越小。