SwiftUI的Binding 属性包装器让我们在给定的状态和任何希望修改该状态的视图之间建立双向绑定。通常情况下,创建这样的绑定只需要使用$ 前缀来引用我们希望绑定的状态属性,但当涉及到集合时,事情往往就不那么简单了。
不是所有的值都是绑定的
例如,假设我们正在建立一个笔记应用程序,我们想把数组中的每个Note 模型绑定到一系列NoteEditingView 实例上,这些实例是在SwiftUIForEach 循环中创建的--像这样:
struct Note: Hashable, Identifiable {
let id: UUID
var title: String
var text: String
...
}
class NoteList: ObservableObject {
@Published var notes: [Note]
...
}
struct NoteEditView: View {
@Binding var note: Note
var body: some View {
...
}
}
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach(list.notes) { note in
NavigationLink(note.title,
destination: NoteEditView(
note: $note
)
)
}
}
}
}
不幸的是,上面的代码样本不能完全编译,原因与我们不能在经典的for 循环中直接突变一个值一样--被传递到我们的ForEach 闭包中的note 参数都是不可变的、不可绑定的值。
Xcode 13的新元素绑定语法
现在,如果我们在一个使用Xcode 13构建的项目上工作,那么我们很幸运,因为有一个新的语法,让我们自动将一个给定的集合的元素转换成可绑定的值。
我们所要做的就是引用我们的属性,以及我们的ForEach 闭包参数,使用我们在生成绑定时通常使用的相同的$ 语法,系统会处理剩下的事情。
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach($list.notes) { $note in
NavigationLink(note.title,
destination: NoteEditView(
note: $note
)
)
}
}
}
}
注意,我们可以在引用我们的闭包的$note 输入时使用或不使用美元前缀,这取决于我们是想直接访问底层值,还是想访问封装它的绑定。
上述新语法非常好的一点是,它实际上完全向后兼容之前所有支持SwiftUI的操作系统--所以在iOS上,这意味着最早可以追溯到iOS 13。唯一的要求是,我们必须使用Xcode 13中包含的编译器和SDK来构建我们的应用程序。
解决使用早期Xcode版本时的问题
然而,如果我们正在做的项目还没有使用Xcode 13和与之捆绑的SDK,那么我们就必须探索一些其他的,更多的自定义解决方案。
一个选择是遍历我们的notes 数组的索引,这将让我们通过下标到$list.notes ,使用现在传递到我们的ForEach 闭包中的索引,来绑定我们的Note 模型的可变版本。
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach(list.notes.indices) { index in
NavigationLink(list.notes[index].title,
destination: NoteEditView(
note: $list.notes[index]
)
)
}
}
}
}
虽然上面的代码成功地编译了,甚至最初看起来是完全有效的--但只要我们改变我们的笔记数组,我们就会在Xcode控制台中得到以下警告:
ForEach(_:content:) should only be used for *constant* data. Instead conform
data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!
好吧,那么让我们按照SwiftUI告诉我们的去做,在创建我们的ForEach 实例时传递一个明确的id 关键路径--像这样:
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach(list.notes.indices, id: \.self) { index in
NavigationLink(list.notes[index].title,
destination: NoteEditView(
note: $list.notes[index]
)
)
}
}
}
}
在这一点上,我们可能已经真正解决了问题。没有更多的警告被发出来,即使我们在突变我们的Note 数组时,事情也可能继续完美地运行。然而,"可能 "是这里的关键词,因为我们所做的本质上是使每个音符的索引成为其 "重用标识符"。这意味着,如果我们的数组快速变化,我们可能会遇到某些奇怪的行为(或崩溃,甚至),因为SwiftUI现在会认为每个音符的索引是该特定模型及其相关NavigationLink 的稳定标识。
因此,要真正解决这个问题,我们要么重构我们的NoteList 类,以提供一种方法,通过其适当的UUID 的 id 访问每个Note (这将让我们将这些 id 的数组传递给ForEach ,而不是使用Int 的数组索引),要么我们将不得不更深入地研究 Swift 的集合 API,以使我们的数组索引真正独一无二。
可识别的索引
在这种情况下,让我们采取第二种策略,引入一个自定义集合,将另一个集合的索引与它所包含的元素的标识符结合起来。为了开始,让我们定义一个新的类型,叫做IdentifiableIndices ,它包装了一个Base 集合,同时声明了一个Index 和一个Element 类型:
struct IdentifiableIndices<Base: RandomAccessCollection>
where Base.Element: Identifiable {
typealias Index = Base.Index
struct Element: Identifiable {
let id: Base.Element.ID
let rawValue: Index
}
fileprivate var base: Base
}
接下来,让我们使我们的新集合符合标准库的RandomAccessCollection 协议,这主要包括将所需的属性和方法转发给我们的底层base 集合 - 除了subscript 的实现,它返回我们上面定义的Element 类型的实例:
extension IdentifiableIndices: RandomAccessCollection {
var startIndex: Index { base.startIndex }
var endIndex: Index { base.endIndex }
subscript(position: Index) -> Element {
Element(id: base[position].id, rawValue: position)
}
func index(before index: Index) -> Index {
base.index(before: index)
}
func index(after index: Index) -> Index {
base.index(after: index)
}
}
这就是了!我们的新集合现在已经准备好行动了。然而,为了使它使用起来更方便,我们还引入了两个小的扩展,这将极大地改善它的整体工效。首先,让我们通过给所有兼容的基础集合(也就是支持随机访问的集合,同时包含Identifiable 元素)添加以下计算属性,使创建一个IdentifiableIndices 实例变得容易。
extension RandomAccessCollection where Element: Identifiable {
var identifiableIndices: IdentifiableIndices<Self> {
IdentifiableIndices(base: self)
}
}
我们之所以能够自信地将上述内容作为一个计算属性,而不是一个方法,是因为IdentifiableIndices 懒散地计算其元素。也就是说,在第一次创建时,它并没有遍历它的基础集合,而是更像一个透视该集合的索引和标识符。因此,创建它是一个O(1) 的操作。
最后,让我们也用一个方便的API来扩展SwiftUI的ForEach 类型,这将让我们在一个IdentifiableIndices 集合上进行迭代,而不必手动访问每个索引的rawValue 。
extension ForEach where ID == Data.Element.ID,
Data.Element: Identifiable,
Content: View {
init<T>(
_ indices: Data,
@ViewBuilder content: @escaping (Data.Index) -> Content
) where Data == IdentifiableIndices<T> {
self.init(indices) { index in
content(index.rawValue)
}
}
}
有了上述部分,我们现在可以回到我们的NoteListView ,通过让它迭代我们的Note 数组的identifiableIndices ,使它对ForEach 的使用更加稳定和可靠--像这样:
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach(list.notes.identifiableIndices) { index in
NavigationLink(list.notes[index].title,
destination: NoteEditView(
note: $list.notes[index]
)
)
}
}
}
}
然而,尽管上述解决方案在许多不同的情况下都能很好地工作,但如果我们集合的最后一个元素被移除,仍然有可能遇到崩溃和其他错误。似乎SwiftUI对其创建的集合绑定应用了某种形式的缓存,这可能会导致在下标到我们的底层Note 数组时使用一个过时的索引--如果这发生在最后一个元素被移除时,那么我们的应用程序将崩溃,出现越界错误。不是很好。
自定义绑定
虽然这似乎是SwiftUI本身的一个bug,但目前我们仍然可以在本地解决这个问题。与其使用SwiftUI内置的API为每个集合元素检索嵌套绑定,不如创建自定义Binding 实例,这(至少根据我的经验)将完全解决这个问题。
为了实现这一点,让我们修改之前的ForEach 扩展,转而接受对我们希望迭代的集合的Binding 引用(这反过来要求该集合符合MutableCollection ),然后用它来创建自定义的Binding 实例,以获取和设置每个元素。最后,我们将把每个这样的自定义绑定和当前元素的索引一起传递给我们的content 闭包--像这样:
extension ForEach where ID == Data.Element.ID,
Data.Element: Identifiable,
Content: View {
init<T>(
_ data: Binding<T>,
@ViewBuilder content: @escaping (T.Index, Binding<T.Element>) -> Content
) where Data == IdentifiableIndices<T>, T: MutableCollection {
self.init(data.wrappedValue.identifiableIndices) { index in
content(
index.rawValue,
Binding(
get: { data.wrappedValue[index.rawValue] },
set: { data.wrappedValue[index.rawValue] = $0 }
)
)
}
}
}
如果我们现在使用上述新的API来更新我们的NoteListView ,使其看起来像这样,那么我们应该能够随意修改我们的NoteList 模型对象,而不会在我们的视图中遇到任何种类的SwiftUI相关问题。
struct NoteListView: View {
@ObservedObject var list: NoteList
var body: some View {
List {
ForEach($list.notes) { index, note in
NavigationLink(note.wrappedValue.title,
destination: NoteEditView(
note: note
)
)
}
}
}
}
结论
苹果在2021年发布的SwiftUI和Swift编译器中解决了创建集合元素绑定的问题,这真的很好,但是如果我们还没有准备好完全迁移到这些工具链,那么我们也可以使用Swift的一些内置协议,如RandomAccessCollection 和Identifiable ,创建一个更自定义的解决方法。