表面上和前一小节没有变化,但是内部的数据结构变为了自定义结构体,后续就可以愉快地持续扩展结构体里的属性了。
输入框和按钮对待办清单 App 来说,光有列表还不够,最起码得能输入新条目。所以得添加以下两个控件:
- 文本输入框 TextField ,用于输入新条目的文本。
- 按钮 Button ,用于提交输入的文本。
新增以下代码:
struct ContentView: View {
@State private var todoList = [
// 省略已有代码 ...
]
// 新增本地状态 newName
// 用于接收用户输入的文本
@State private var newName: String = ""
var body: some View {
// 新增代码
// VStack: 垂直方向布局
VStack {
// HStack: 水平方向布局
HStack {
// 文本控件
TextField("输入新事项", text: $newName)
// 按钮控件
Button("确认") {
// 点击按钮后执行的操作
let newItem = ToDoItem(name: newName)
todoList.append(newItem)
newName = ""
}
}
.padding()
// 省略已有代码
List {
// ...
}
}
}
}
有点轻车熟路了对吧,在 Swift 的世界里,尽量不让你指挥它“这个控件要如何执行初始化、那个界面要如何添加对象”,你只要告诉它“这里有什么”就行。
稍微研究下新写的代码:
- 新增了一个状态 newName ,用来放置用户在输入框里键入的文本。
- VStack 和 HStack 分别代表垂直方向布局和水平方向布局。所以后面你可以看到,输入控件整体和列表是垂直布局的,而输入框和按钮是水平布局的。
- TextField 是输入框控件,注意它第二个参数 text: $newName 里的美元符号 $ ,这种特殊写法表示传入的 newName 是双向绑定的状态,而不是简单的传入了 newName 变量里面的值。双向绑定的意思是不管你在代码里改变 newName 的值,还是在输入框控件里修改,它两是完全同步变化的,并且这个变化会立刻反应到 UI 中。
- Button 是按钮控件,点击后会创建一个新的列表元素,把它添加到 todoList 中,并且将输入框控制的文本清除。
重新渲染模拟器,看看效果:
由于输入效果是动态的,所以你需要点一下上图箭头指的那个按钮,让渲染从静态切换为动态。
然后随便输入点东西,并点击确认按钮:
顺利的话,新条目就添加到列表里了:
怎么样,是不是有点意思?
让我们更进一步经过上面的折腾,虽然我们已经把列表数据转化为自定义结构体 ToDoItem() 了,但数据和界面还可以进一步解耦。随着项目逐渐扩展,程序架构需要更明确的分层和细化。
如果把 UI 界面描述为视图(View),数据描述为模型(Model),那么我们还需要一个桥梁,帮助视图和模型进行多对多的通信和数据流的双向绑定。
这个桥梁在上面的代码中是没有的,因此先来写它。
实际写项目时应该把视图、模型和“桥梁”都作为单独的文件。本文为了方便就没这么做了。
新增一个 ToDoViewModel 类如下:
class ToDoViewModel: ObservableObject {
// @Published 装饰需要进行绑定的数据
@Published private(set) var todoList: [ToDoItem]
// 初始化
init() {
self.todoList = [
ToDoItem(name: "Apple"),
ToDoItem(name: "Pear"),
ToDoItem(name: "Tomato")
]
}
// 新增数据条目
func append(_ item: ToDoItem) {
todoList.append(item)
}
// 改变条目是否完成的状态
func toggle(_ item: ToDoItem) {
// 这一行代码将 item 的 id 赋值给 index 变量
// 语法原理暂时不要去深究
if let index = todoList.firstIndex(where: {$0.id == item.id}) {
// toggle() 函数将布尔值反转
todoList[index].isOn.toggle()
}
}
}
这个类的关键就在于用 @Published 装饰的 todoList ,它就是需要和视图进行绑定的数据模型。视图不应该直接修改模型,所以你看到有关键字 private(set) 限制了类外部的指令只能读取不能修改。对模型的修改要通过 ToDoViewModel 内置的方法。“桥梁类”里可以有多个 @Published 装饰的模型,也可以提供给多个视图使用。
接下来的模型没有变化,还是之前的那个结构体:
struct ToDoItem: Identifiable {
let id = UUID()
var isOn = true
let name: String
}
最后是视图,稍微有点长:
struct ContentView: View {
// @StateObject 用于自定义的“桥梁类”,作用和前面的 @State 差不多
@StateObject private var viewModel = ToDoViewModel()
// newName 状态无改动
@State private var newName: String = ""
var body: some View {
VStack {
// 之前的 TextField 和 Button
// 无改动
HStack {
// ...
}
.padding()
List {
ForEach(viewModel.todoList) { item in
HStack {
// .foregroundColor 根据 isOn 的状态改变文本的颜色
// (a ? b : c) 被称为三元操作符
Text(item.name)
.foregroundColor(item.isOn ? .primary : .gray)
// 在两个元素间填充占位的空白
Spacer()
// Group 里模拟了单选框
// Group 是和 VStack 类似的布局对象
// 注意它里面是可以写 if 这种控制流语句的
// 根据 isOn 的值,改变单选框的外观
Group {
if item.isOn {
// Image 调用了内置的图片
// "circle" 是个中空的圈
Image(systemName: "circle")
}
else {
// 中间带勾的圈
Image(systemName: "checkmark.circle.fill")
}
}
// 将单选框渲染为蓝色
.foregroundColor(.blue)
// 定义了单选框的点击事件
// 点击后触发了“桥梁”类的 toggle() 方法
.onTapGesture {
viewModel.toggle(item)
}
}
}
}
}
}
}
看起来改了很多,但其实真的挺简单的:
- 自定义的“桥梁类”要用 @StateObject 装饰。
- UI 里 Group 那一坨模拟了单选框,单选框的外观会根据 item.isOn 布尔值来调整。
来看看效果。刷新模拟器: