0. 概览
这是一段非常简单的 SwiftUI 代码,我们将 Item 数组传递到子视图并在子视图中对其进行修改,修改的结果会立即在主视图中反映出来。
不幸的是,当我们修改 Item 名称时却发现不能连续输入:每次敲一个字符键盘都会立即收起并且原输入焦点会马上丢失,这是怎么回事呢?
在本篇博文中您将学到以下内容
- 0. 概览
- 1. 不该发生的错误
- 2. 无效的尝试:用子视图包装
- 3. 寻根究底
- 4. 解决之道
- 总结
该问题这是初学者在 SwiftUI 开发中常常会犯的一个错误,不过看完本篇之后相信大家都会对此自胸有成竹!
废话不再,Let‘s fix it!!!😉
1. 不该发生的错误
照例我们先看一下源代码。
例子中我们创建了 Item 结构用来作为 Model 中的“真相之源”。
想要了解更多 SwiftUI 编程和“真相之源”奥秘的小伙伴们,请观赏我专题专栏中的如下文章:
- 『第十章』仪态万千的雨燕:UIKit 和 SwiftUI
注意,我们让 Item 遵守了 Identifiable 协议,这样可以更好的适配 SwiftUI 列表中的显示:
struct Item: Identifiable {
var id: String {
name
}
var name: String
var count: Int
}
let g_items: [Item] = [
.init(name: "宇宙魔方", count: 11),
.init(name: "宝石手套", count: 1),
.init(name: "大黄蜂", count: 1)
]
接下来是主视图 ItemListView,可以看到我们将 items 状态传递到子视图的 ForEach 循环中去了:
struct ItemListView: View {
@State var items = g_items
private var total: Int {
items.reduce(0) { $0 + $1.count}
}
private var desc: [String] {
items.reduce([String]()) { $0 + [$1.name]}
}
var body: some View {
NavigationStack {
// 子视图 ForEach 循环...
ForEach($items) { $item in
// 代码马上来...
}
VStack {
Text(desc.joined(separator: ","))
.font(.title3)
.foregroundStyle(.pink)
HStack {
Text("宝贝总数量:\(total)")
.font(.headline)
Spacer().frame(width: 20)
Button("所有 +1"){
for idx in items.indices {
guard items[idx].count < 100 else { continue}
items[idx].count += 1
}
}
.font(.headline)
.buttonStyle(.borderedProminent)
}
}.offset(y: 200)
}
}
}
最后是 ForEach 循环中的内容,如下所示我们用单个 item 的值绑定来实现修改其内容的目的:
ForEach($items) { $item in
HStack {
TextField("输入项目名称", text: $item.name)
.font(.title2.weight(.heavy))
Text("数量:\(item.count)")
.foregroundStyle(.gray)
Slider(value: .init(get: {
Double(item.count)
}, set: {
item.count = Int($0)
}), in: 0.0...100.0)
}
}
.padding()
这样一段看起来“天衣无缝”的代码为什么会出现在更改 Item 名称时键盘反复关闭、输入焦点丢失的问题呢?
2. 无效的尝试:用子视图包装
我们首先猜测是子视图中 Item 名称的更改导致了父视图的“冗余”刷新,从而引起键盘不正确被重置。
更多 SwiftUI 和 Swift 代码调试的例子,请观赏我专题专栏中的博文:
- 『第十三章』雨燕的自我修养:Swift 调试技巧(上)
- 『第十四章』雨燕的自我修养:Swift 调试技巧(下)
因为键盘所属的视图发生重建所以键盘本身也会被重置,那么如何验证我们的猜测呢?一种方式是使用如下的调试技术:
- SwiftUI 如何快速识别视图(View)界面的刷新是由哪个状态的改变导致的?
在这里我们假设病根果真如此。那么一种常用的解决办法立即浮现于脑海:我们可以将引起刷新的子视图片段包装在新的 View 结构中,这样做到原因是 SwiftUI 渲染器足够智能可以只刷新子视图而不是父视图中大段内容的更改。
更详细的原理请参考如下链接:
- SwiftUI 中为什么应该经常用子视图替换父视图中的大段内容?
So,让我撸起袖子开动起来!
首先,将 ForEach 循环中编辑单个 Item 的 View 包装为一个新的视图 ItemEditView:
struct ItemEditView: View {
@Binding var item: Item
var body: some View {
HStack {
TextField("输入项目名称", text: $item.name)
.font(.title2.weight(.heavy))
Text("数量:\(item.count)")
.foregroundStyle(.gray)
Slider(value: .init(get: {
Double(item.count)
}, set: {
item.count = Int($0)
}), in: 0.0...100.0)
}
}
}
接着,我们将 ForEach 循环本身用一个新视图取代:
struct EditView: View {
@Binding var items: [Item]
var body: some View {
ForEach($items) { $item in
ItemEditView(item: $item)
}
.padding()
}
}
最后,我们所要做的就是将父视图 ItemListView 中的 ForEach 循环变为 EditView 视图:
NavigationStack {
EditView(items: $items)
// 其它代码不变...
}
再次运行代码…不幸的是问题依旧:
看来这并不是简单父视图“过度”刷新的问题,一定是有什么不应有的行为触发了父视图的刷新,到底是什么呢?
3. 寻根究底
问题一定出在 ForEach 循环里!
回顾之前 Item 的定义,我们用 Identifiable 协议满足 ForEach 对子项目唯一性的挑剔,我们用 Item.name 构建了 id 属性。
当 Model 元素遵守 Identifiable 协议时,应该确保在任意时刻所有 Item 的 id 属性值都是唯一的!从目前来看,上述代码在修改 Item 名称时并没有发生重名的情况(虽然可能发生),所以对于唯一性是没有问题的。
当然在实际代码中用户很可能会输入重复的 Item 名称,所以还是不可接收的。
不过,这段代码在这里只是作为例子来向大家展示解决问题的推理过程,所以不必深究 😉
但是 id 还有另一个重要的特征:稳定性!
一般的,当 Identifiable 实体对象的 id 属性改变时,SwiftUI 会认为其不再是同一个对象,而立即刷新其所对应的视图界面。
所以,正如大家所看到的那样:每次用户输入 name 中的新字符时,键盘会被立即关闭焦点也随即丢失!
4. 解决之道
知道了问题原因,解决起来就很容易了。
我们只需要在 Item 生命周期中保证 id 的稳定性就可以了,这意味着不能再用 name 值作为 id 的“关联”值:
struct Item: Identifiable {
let id = UUID()
var name: String
var count: Int
}
如上代码所示,我们在 Item 创建时为 id 生成一个唯一的 UUID 对象,这可以保证两点:
- 任意时刻 Item 的唯一性;
- 任意 Item 在其生命周期中的稳定性;
有了如上修改之后,我们再来运行代码看看结果:
可以看到,现在我们可以毫无问题的连续输入 Item 的名字了,焦点不会再丢失,一切回归正常,棒棒哒!!!💯
总结
在本篇博文中,我们讨论了 SwiftUI 开发中一个非常常见的问题,并借助一步步溯本回原的推理找到症结根本之所在,最后一发入魂将其完美解决!相信小伙伴们都能由此受益匪浅。
感谢观赏,再会!😎