If you develop apps on any Apple platform (iOS, macOS, etc.) then you’re familiar with the Welcome to Xcode window in Xcode and the list of recent projects on the side of the window. Any project opens when double-clicking on it, while there’s a context menu appearing on right-click allowing to show the selected project in Finder.
That list and the way we interact with its items is pretty straightforward, but have you ever wondered how we can create a similar list in SwiftUI? That’s what we’ll find out in this post, unveiling the steps and APIs it takes to end up with a similar list in our projects.
Getting started with an Identifiable type
For the sake of the example, let’s suppose that we’ll present various kinds of documents in a list, so users can select one with double-click, and show a context menu with additional options on right-click. Programmatically, we’ll represent a document with a custom type. Instances of that type will exist in a collection, which is going to be the datasource of the SwiftUI list. We’ll call this custom type DocumentItem:
|
1 2 3 4 5 6 7 |
struct DocumentItem: Identifiable { let id = UUID() var title: String var type: String } |
It conforms to Identifiable protocol, so iteration through DocumentItem elements can work flawlessly in the list.
With the custom Identifiable type defined, we’ll turn to the view and declare a collection of DocumentItem objects:
|
1 2 3 4 5 6 7 |
private let documents = [ DocumentItem(title: “Summary”, type: “PDF”), DocumentItem(title: “Notes”, type: “Text”), DocumentItem(title: “Logo”, type: “Image”) ] |
But in addition to that, we’ll also declare a state property that’s crucial to make things work next; a property to store the id of the selected item, or in other words, a property to keep the selected row:
|
1 2 3 |
@State private var selectionID: UUID? = nil |
We keep it nil initially, as there’s no selection made by default. Note that id is a UUID value in this example, but it could perfectly be of another type in other cases. We can write the above a bit differently, so the declaration fits any type of the id property:
|
1 2 3 |
@State private var selectionID: DocumentItem.ID? = nil |
The ID is a type alias of the type of id. In this case it’s a type alias to UUID, but it could be to Int, String, and so on.
Implementing the List with selectable rows
The List container in SwiftUI has various initializers, but here we need one that accepts the selection property as argument. Given that the datasource of the list is the documents collection and its elements conform to Identifiable protocol, the best initializer to choose is List(_:selection:rowContent:) as shown next:
|
1 2 3 4 5 |
List(documents, selection: $selectionID) { document in } |
The documents argument feeds the list with data, and the rowContent is a closure where we implement the content of the list rows. But the important part here is the selection argument; it’s the binding value of the selectionID property that we declared to the view previously. When selecting a row in the list, this property will get the value of the id matching the selected row.
Time to add the row content, which is just a label with the document title and a respective icon. A few modifiers style it a bit, with the combination of frame and contentShape ensuring that the whole row is clickable next:
|
1 2 3 4 5 6 7 8 9 10 11 |
List(documents, selection: $selectionID) { document in HStack { Image(systemName: image(for: document.type)) Text(document.title) } .font(.title2).fontWeight(.light) .frame(height: 60) .contentShape(.rect) } |
The image(for:) is a helper method that returns the name of an SF Symbol based on the document type:
|
1 2 3 4 5 6 7 8 9 10 |
private func image(for type: String) -> String { switch type { case “PDF”: return “doc.richtext” case “Text”: return “doc.text” case “Image”: return “photo” default: return “doc” } } |
Note that we can use a ForEach container with the List too. In that case, the List(selection:content:) is the appropriate initializer to use:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
List(selection: $selectionID) { ForEach(documents) { document in HStack { Image(systemName: image(for: document.type)) Text(document.title) } .font(.title2).fontWeight(.light) .frame(height: 60) .contentShape(.rect) } } |
No matter the way we choose to present documents, the fact that we store the selection makes rows already selectable. Simply running the app shows that:
Enabling double-click and context menu
When talking about actions triggered on double-click, it’s unavoidable to think of the onTapGesture modifier first; the gesture that makes views respond to such interaction. However, even though it sounds straightforward, this modifier has no effect, and we need the following instead:
|
1 2 3 |
contextMenu(forSelectionType:menu:primaryAction:) |
This modifier gets three arguments:
forSelectionType: The type that we use to indicate selection, which is the type of theidin theDocumentItemstruct; aUUIDvalue.menu: A closure to implement the contents of the context menu, i.e. a series of action buttons.primaryAction: Another closure, and the most important part of the modifier, as this is what actually enables the double-click functionality. And we specify just one action, the one that should be performed on double-click.
|
1 2 3 4 5 6 7 8 |
List(documents, selection: $selectionID) { document in … } .contextMenu(forSelectionType: DocumentItem.ID.self) { selection in } primaryAction: { selection in } |
Notice the selection parameter in both closures in the above snippet. In this sample, each one is a Set<UUID>, since the same modifier can be used for multiple row selection.
☝️Note:
Multiple row selection might be the topic of another tutorial.
Generally, the parameter of each closure is a Set containing identifiers that uniquely point to selected rows. Since the focus is on single-row selection here, it’s necessary to get the first item in the Set, and then use it in any actions.
With that in mind, the following demonstrates the implementation of three buttons that appear in the context menu, when we apply a right-click on any row:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
.contextMenu(forSelectionType: DocumentItem.ID.self) { selection in if !selection.isEmpty, let document = documents.first(where: { $0.id == selection.first! }) { Button(“Open”, systemImage: “square.and.arrow.up”) { open(document) } Button(“Rename”, systemImage: “pencil”) { rename(document) } Button(“Delete”, systemImage: “trash”) { delete(document) } } } primaryAction: { selection in } |
And finally, the action to perform when double-clicking on a row:
|
1 2 3 4 5 6 7 8 9 10 11 |
.contextMenu(forSelectionType: DocumentItem.ID.self) { selection in … } primaryAction: { selection in guard !selection.isEmpty, let document = documents.first(where: { $0.id == selection.first! }) else { return } open(document) } |
☝️ A note about keyboard handling
Besides mouse interactions, row selection also works with the keyboard. Arrow keys let users move up and down in the list, while pressing Return triggers the primary action that’s defined in the contextMenu(forSelectionType:menu:primaryAction:) modifier. There’s no extra configuration required for this to work.
Wrapping up
Enabling row selection and activating double-click along with a context menu on List rows is just a matter of choosing the right APIs. As presented in this post, there are two essential parts; to use a List initializer with the selection argument, and of course, to apply the contextMenu(forSelectionType:menu:primaryAction:) modifier to the List. These two, along with all the other bits of configuration, lead to the expected behavior and experience that users are familiar with. I hope you found this post useful. Thanks for reading!