Bindable SwiftUI list elements
Discover page available: SwiftUISwiftUI’s Binding
property wrapper lets us establish a two-way binding between a given piece of state and any view that wishes to modify that state. Typically, creating such a binding simply involves referencing the state property that we wish to bind to using the $
prefix, but when it comes to collections, things are often not quite as straightforward.
Not all values are bindings
For example, let’s say that we’re building a note-taking app, and that we’d like to bind each Note
model within an array to a series of NoteEditingView
instances that are being created within a SwiftUI ForEach
loop — like this:
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
)
)
}
}
}
}
Unfortunately, the above code sample doesn’t fully compile, for the same reason that we can’t directly mutate a value within a classic for
loop — the note
arguments that are being passed into our ForEach
closure are all immutable, non-bindable values.
Xcode 13’s new element binding syntax
Now, if we’re working on a project that’s built using Xcode 13, then we’re in luck, because there’s a new syntax that lets us automatically convert a given collection’s elements into bindable values.
All that we have to do is to reference our property, and our ForEach
closure argument, using the same $
syntax that we typically use when generating bindings, and the system will take care of the rest:
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 that we can reference our closure’s $note
input either with or without its dollar prefix, depending on whether we want to access the underlying value directly, or the binding that encapsulates it.
Something that’s really nice about the above new syntax is that it’s actually fully backward compatible with all previous operating systems on which SwiftUI is supported — so on iOS, that means as far back as iOS 13. The only requirement is that we have to build our app using the compiler and SDK that’s included in Xcode 13.
Solving the problem when using earlier Xcode versions
However, if we’re working on a project that’s not yet using Xcode 13 and the SDKs that are bundled with it, then we’ll have to explore a few other, more custom solutions.
One option would be to iterate over the indices of our notes
array, which will let us bind to mutable versions of our Note
models by subscripting into $list.notes
using the index that’s now passed into our ForEach
closure:
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]
)
)
}
}
}
}
While the above code successfully compiles, and might initially even seem to be fully working — as soon as we’ll mutate our array of notes, we’ll get the following warning printed within the Xcode console:
ForEach(_:content:) should only be used for *constant* data. Instead conform
data to Identifiable or use ForEach(_:id:content:) and provide an explicit id!
Alright, so let’s do what SwiftUI tells us, by passing an explicit id
key path when creating our ForEach
instance — like this:
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]
)
)
}
}
}
}
At this point, we might have actually solved the problem. There are no more warnings being emitted, and things might continue to work perfectly fine even as we mutate our Note
array. However, “might” is really the keyword here, as what we’ve essentially done is to make the index of each note its “reuse identifier”. What that means is that we might run into certain odd behaviors (or crashes, even) if our array ever changes rapidly, as SwiftUI will now consider each note’s index a stable identifier for that particular model and its associated NavigationLink
.
So to truly fix the problem, we’re either going to have to refactor our NoteList
class to also offer a way to access each Note
by its proper UUID
-based id (which would let us pass an array of those ids to ForEach
, rather than using Int
-based array indices), or we’re going to have to dive a bit deeper into Swift’s collection APIs in order to make our array indices truly unique.
Identifiable indices
In this case, let’s go for the second strategy, by introducing a custom collection that’ll combine the indices of another collection with the identifiers of the elements that it contains. To get started, let’s define a new type called IdentifiableIndices
, which wraps a Base
collection and also declares an Index
and an Element
type:
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
}
Next, let’s make our new collection conform to the standard library’s RandomAccessCollection
protocol, which mostly involves forwarding the required properties and methods to our underlying base
collection — except for the implementation of subscript
, which returns an instance of the Element
type that we defined above:
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)
}
}
That’s it! Our new collection is now ready for action. However, to make it a bit more convenient to use, let’s also introduce two small extensions that’ll heavily improve its overall ergonomics. First, let’s make it easy to create an IdentifiableIndices
instance by adding the following computed property to all compatible base collections (that is, ones that support random access, and also contains Identifiable
elements):
extension RandomAccessCollection where Element: Identifiable {
var identifiableIndices: IdentifiableIndices<Self> {
IdentifiableIndices(base: self)
}
}
The reason we can confidently make the above a computed property, rather than a method, is because IdentifiableIndices
computes its elements lazily. That is, it doesn’t iterate over its base collection when first created, but rather acts more like a lens into that collection’s indices and identifiers. So creating it is an O(1)
operation.
Finally, let’s also extend SwiftUI’s ForEach
type with a convenience API that’ll let us iterate over an IdentifiableIndices
collection without also having to manually access the rawValue
of each index:
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)
}
}
}
With the above pieces in place, we can now go back to our NoteListView
and make its usage of ForEach
much more stable and reliable by making it iterate over our Note
array’s identifiableIndices
— like this:
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]
)
)
}
}
}
}
However, while the above solution should prove to work really well in many different kinds of situations, it’s still possible to encounter crashes and other bugs if the last element of our collection is ever removed. It seems like SwiftUI applies some form of caching to the collection bindings that it creates, which can cause an outdated index to be used when subscripting into our underlying Note
array — and if that happens when the last element was removed, then our app will crash with an out-of-bounds error. Not great.
Custom bindings
While this certainly seems to be a bug within SwiftUI itself, it’s still something that we can work around locally for now. Rather than using SwiftUI’s built-in API for retrieving nested bindings for each collection element, let’s instead create custom Binding
instances, which (at least in my experience) will completely solve the problem.
To make that happen, let’s modify our previous ForEach
extension to instead accept a Binding
reference to the collection that we wish to iterate over (which, in turn, requires that collection to conform to MutableCollection
), and to then use that to create custom Binding
instances for getting and setting each element. Finally, we’ll pass each such custom binding to our content
closure, along with the index of the current element — like this:
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 }
)
)
}
}
}
If we now use the above new API to update our NoteListView
to instead look like this, then we should be able to modify our NoteList
model object however we please without encountering any kind of SwiftUI-related issues within our view:
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
)
)
}
}
}
}
Conclusion
It’s really great that Apple addressed the issue of creating bindings to a collection’s elements in the 2021 release of SwiftUI and the Swift compiler, but if we’re not yet ready to fully migrate to those toolchains, then we can also create a more custom workaround using some of Swift’s built-in protocols, such as RandomAccessCollection
and Identifiable
.
Thanks for reading, and feel free to reach out via either Twitter or email if you have any questions, comments or feedback.