Building editable lists with SwiftUI
Discover page available: SwiftUITo say that SwiftUI’s List
is the equivalent of UIKit’s UITableView
is both true and false at the same time. It’s definitely true that List
offers a built-in way to construct list-based UIs that are rendered using the same overall appearance as when using UITableView
— however, when it comes to mutations, we instead have to turn to SwiftUI’s core engine for constructing and managing views based on collections of data — the ForEach
type.
Moving and deleting
In general, list editing typically involves two separate kinds of edits — item-specific and list-wide ones. To start with the first variant, here’s an example using the list binding syntax that was introduced in Swift 5.5 — in which we’re rendering a TodoList
view that enables the user to directly edit the text of each item using a TextField
:
struct TodoItem: Identifiable {
let id: UUID
var title: String
}
struct TodoList: View {
@Binding var items: [TodoItem]
var body: some View {
NavigationView {
VStack {
List {
ForEach($items) { $item in
TextField("Title", text: $item.title)
}
}
TodoItemAddButton { newItem in
items.append(newItem)
}
}
.navigationTitle("Todo list")
}
}
}
In this example, our array of TodoItem
models is stored outside of our view and is then passed in using a Binding
. To learn more about that pattern, check out this guide to SwiftUI’s state management system.
Now let’s say that we wanted to add support for list-wide mutations as well — specifically moves and deletions. The good news is that SwiftUI ships with built-in modifiers for both of those tasks, but it turns out that they’re not available on the List
type itself, but rather only on ForEach
.
That’s because, in SwiftUI, List
can sort of be seen more as a styling component, rather than as a view responsible for managing a collection of subviews. So whenever we wish to mutate such a collection, we need to work directly with ForEach
— like this:
struct TodoList: View {
@Binding var items: [TodoItem]
var body: some View {
NavigationView {
VStack {
List {
ForEach($items) { $item in
TextField("Title", text: $item.title)
}
.onMove { indexSet, offset in
items.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
items.remove(atOffsets: indexSet)
}
}
...
}
.navigationTitle("Todo list")
}
}
}
However, even though our list now technically supports both moves and deletions, there’s currently no way for the user to enter “edit mode” to start moving items. To address that, let’s use the toolbar
modifier to insert an instance of SwiftUI’s built-in EditButton
into our app’s navigation bar:
struct TodoList: View {
@Binding var items: [TodoItem]
var body: some View {
NavigationView {
VStack {
...
}
.navigationTitle("Todo list")
.toolbar { EditButton() }
}
}
}
Note that if you’re working on an iOS app that needs to support iOS 13, you’ll need to use the now deprecated navigationBarItems
modifier instead, since the toolbar
API was introduced in iOS 14 (and the rest of Apple’s 2020 operating systems).
With that change in place, we now have a fully editable list that supports both inline item edits, as well as list-wide moves and deletions. Really nice!
A reusable abstraction
Depending on what kind of app that we’re working on, we might need to build multiple lists that each should support the above set of editing features — and while it’s certainly possible to accomplish that by simply copying and pasting the modifiers and EditButton
code that we just added to our TodoList
, it would arguably be much nicer to instead have some form of editable list that we could easily reuse across our code base.
So, let’s build one! Thankfully, because SwiftUI was designed with such a heavy emphasis on composition, implementing a completely reusable EditableList
type simply involves moving our previous editing code into that new view’s body
, and then adding an initializer that’ll let us inject the data that we wish to render, as well as a closure for constructing the view for each item within our list:
struct EditableList<Element: Identifiable, Content: View>: View {
@Binding var data: [Element]
var content: (Binding<Element>) -> Content
init(_ data: Binding<[Element]>,
content: @escaping (Binding<Element>) -> Content) {
self._data = data
self.content = content
}
var body: some View {
List {
ForEach($data, content: content)
.onMove { indexSet, offset in
data.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
data.remove(atOffsets: indexSet)
}
}
.toolbar { EditButton() }
}
}
Note that we don’t strictly need to implement a custom initializer for the above type (unless we want to vend it as public
, outside of the module it was defined in), but the benefit of doing so is that our EditableList
API now works the same way as SwiftUI’s built-in List
, which will make switching between the two much easier.
With the above new type in place, all that we now have to do when we wish to render an editable list is to create an EditableList
instance with the array that we want to enable our users to edit — like this:
struct TodoList: View {
@Binding var items: [TodoItem]
var body: some View {
NavigationView {
VStack {
EditableList($items) { $item in
TextField("Title", text: $item.title)
}
TodoItemAddButton { newItem in
items.append(newItem)
}
}
.navigationTitle("Todo list")
}
}
}
Really neat! Another benefit of encapsulating our list editing code within a stand-alone type is that we’ll now be able to keep adding editing features in a single location, and all of our editable lists will then get those features for free. For example, we might want to add support for drag and drop, sorting, and so on.
Alright, it’s time for the bonus round! While the above EditableList
implementation works perfectly fine as long as our list data always comes in the form of an Array
, it would arguably be nice to also make it support any Collection
type that List
and ForEach
are capable of working with (including custom ones).
To make that happen, we’re going to have to change our generic Element
type to instead refer to any Data
collection that conforms to the same set of standard library protocols that SwiftUI requires to make our list editable. This implementation will have to require iOS 15, though, since SwiftUI’s Binding
type gained support for the standard library’s RandomAccessCollection
protocol in that OS version:
@available(iOS 15, *)
struct EditableList<
Data: RandomAccessCollection & MutableCollection & RangeReplaceableCollection,
Content: View
>: View where Data.Element: Identifiable {
@Binding var data: Data
var content: (Binding<Data.Element>) -> Content
init(_ data: Binding<Data>,
content: @escaping (Binding<Data.Element>) -> Content) {
self._data = data
self.content = content
}
var body: some View {
List {
ForEach($data, content: content)
.onMove { indexSet, offset in
data.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
data.remove(atOffsets: indexSet)
}
}
.toolbar { EditButton() }
}
}
We now have a completely generic EditableList
implementation that can be used with any compatible collection — with the caveat that it’s only compatible with iOS 15 and later. But, if we need to support earlier iOS versions as well, we could likely just use our previous Array
-based version, which is fully backward compatible.
Conclusion
List
is arguably one of the most polarizing SwiftUI views. On one hand, it provides a lot of built-in functionality that enables us to relatively easily build lists that look and behave exactly like the ones found throughout Apple’s own apps, as well as iOS itself.
However, although List
has become much more flexible since it was introduced in 2019, building completely custom-looking lists using it is still quite difficult. So, for those use cases we’ll likely have to fall back to either UIKit or AppKit, depending on what platform that we’re targeting. Still, even though it might be limited when it comes to its visuals, List
is an incredibly useful and powerful part of SwiftUI.
I hope that this article has given you a few insights into exactly what kind of editing capabilities that SwiftUI’s List
and ForEach
types support, and if you have any questions, comments, or feedback, then feel free to reach out via email.
Thanks for reading!