大师学SwiftUI第6章 - 声明式用户界面 Part 1

状态

在上一章,我们介绍了SwiftUI的主要特性,声明式语法。借助SwiftUI,我们可以按希望在屏幕上显示的方式声明视图,余下交由系统来创建所需的代码。但声明式语法不只用于组织视图,还可在应用状态发生变化时更新视图。例如,我们可以有下面图6-1中的界面,显示标题的​​Text​​​视图,用户输入新标题的输入字段以及将旧标题替换成新标题的按钮。原标题的​​Text​​视图表示我们界面的初始状态。用户在输入框中输入每个字符时状态都会发生更新(图6-1左图),点击按钮时,界面进入一个新状态,用户插入的标题会替换原标题,文本的颜色也发生变化(图6-1右图)。

大师学SwiftUI第6章 - 声明式用户界面 Part 1_第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-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, titleInput: Binding) {
        _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​来声明。

初始化完值之后,我们可以在视图中进行显示。标题现在显示 为用户插入的文本以及字符串的字符数。

大师学SwiftUI第6章 - 声明式用户界面 Part 1_第2张图片

图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(
        get: { return "My Preview Title" },
        set: { value in
            print(value)
        })
    let constantInput = Binding(
        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开发学习笔记​​

你可能感兴趣的:(swiftui,ui,apple,vision,pro,ios)