Example SwiftUI code to manage a list on macOS.
An example list could appear as
Supported user actions:
- Selection. Arrow keys and mouse clicks can be used to change which item in the list is selected. (The selected list is highlighted.)
- Add an item. An add button appears in the list, which when clicked adds an item to the list.
- Rename an item. Clicking on the name of a list item allows the name to be edited.
- Rearrange the order. Dragging and moving items reorders the list.
- Delete an item. Using the backspace or delete key will remove a selected item. Using control-right-click deletes the item where the click takes place, which may differ from the selected item.
A final example demonstrates creating multiple lists with different characteristics:
The implementation makes use of the Observation framework. The implementation addresses
- Defining, maintaining, and viewing a single item in the list
- Defining, maintaining, and viewing the list itself
An item in the list begins with a base class providing the minimum requirements for a list, namely
- an
id
parameter that uniquely identifies the item in the list - an
isSelected
parameter indicating whether the item is selected
The base item class, EItem
, is defined as
@Observable
class EItem: Identifiable, Hashable {
var id: UUID
private var _isSelected: Bool // back-stored value
var isSelected: Bool {
get { return _isSelected }
set {
_isSelected = newValue
}
}
init(id: UUID, isSelected: Bool) {
self.id = id
self._isSelected = isSelected
}
func newItem() -> EItem {
return EItem(id: UUID(), isSelected: false)
}
}
In a more complete implementation one may want to do something more involved when parameters change, such as check ranges or read and write values to UserDefaults. In these cases making use of back-stored values and get
and set
closures comes in handy. For demonstration purposes, simply declaring a parameter as
var isSelected: Bool
would be sufficient.
In the example above a single item may appear as
In this case a derived class includes an item name and an image (well, an emoji string), where the image appears in a button used to change the image:
@Observable
class Food: EItem {
private var _name: String // back-stored value
var name: String {
get { return _name }
set {
_name = newValue
}
}
private var _image: String // back-stored value
var image: String {
get { return _image }
set {
_image = newValue
}
}
init(id: UUID, isSelected: Bool, name: String, image: String = "?") {
self._name = name
self._image = image
super.init(id: id, isSelected: isSelected)
}
override func newItem() -> Food {
let item = Food(id: UUID(), isSelected: false, name: "new food", image: "?")
return item
}
In the example the view presents the item's name
as an editable text field, and the items's image
is displayed as a button; when the button is clicked, the image
can be edited.
struct FoodView: View {
@State var item: Food
@State private var isEditorPresented = false
var body: some View {
HStack {
TextField("", text: $item.name, onCommit: {
print(item.name)
})
Button(action: {
isEditorPresented = true
}) {
Text(item.image)
}
}
.sheet(isPresented: $isEditorPresented) {
FoodImageEditView(item: item)
}
}
}
struct FoodImageEditView: View {
@Bindable var item: Food
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack() {
TextField("Title", text: $item.image)
.textFieldStyle(.roundedBorder)
.onSubmit {
dismiss()
}
Button("Close") {
dismiss()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
As the view can vary for different lists, an entry view is used to call the appropriate view for a given class.
struct EItemView<T>: View where T: EItem {
@State var item: T
@State private var isEditorPresented = false
var body: some View {
switch item.self {
case is Food:
FoodView(item: item as! Food)
default:
Text(item.id.uuidString)
}
}
}
The list is implemented with
- A list class that defines implementation and support for a list of class objects; this can be used as is to handle lists of EItem objects -- no need to create a derived class
- A list view that implements the GUI for the list and handles user actions; this calls EItemView to display each item in the list
The Observation framework handles communication between the class and view implementations.
The list class has these features:
- A
listItems
parameter defining the list; this is an array of list item objects. - A
selectedIndex
parameter indicating the index of the first selected item in the array - A
selectedItem
parameter indicating the item first selected item in the array - A function
moveListItem
to reorder the list - A function
insertListItemAtEnd
to append a new item to the list - A function
deleteListItem
to delete a list item at a given array offset - A function
deleteListItems
to delete list items at given array offsets - A function
selectListItem
when changing the item selected in the list
@Observable
class EList {
typealias RowModel = EItem
private var _listItems: [RowModel] = []
var listItems: [RowModel] {
get { return _listItems }
set {
_listItems = newValue
}
}
/**
Get the array index of the first selected list-item or return 0 (the index for the first item in the list).
*/
var selectedIndex: Int? {
guard listItems.count > 0 else { return nil }
if let idx = listItems.firstIndex(where: { $0.isSelected }) {
return idx
}
return 0 // fatalError("list item not found")
}
/**
Get the first selected list-item, or return the first item in the list.
*/
var selectedItem: RowModel? {
get {
guard listItems.count > 0 else { return nil }
if let idx = listItems.firstIndex(where: { $0.isSelected }) {
return listItems[idx]
}
return listItems[0] // fatalError("list item not found")
}
}
init(listItems: [RowModel] = []) {
self.listItems = listItems
}
}
// MARK: - Default implementations for list management functions
extension EList {
func moveListItem(at source: IndexSet, to destination: Int) {
print("move listItems")
listItems.move(fromOffsets: source, toOffset: destination)
}
func insertListItemAtEnd(item: RowModel) {
let row = item.newItem()
listItems.append(row)
}
func insertListItems(at indices: IndexSet, as newElements: [RowModel]) {
print("insert listItems")
for (index, element) in zip(indices, newElements) {
listItems.insert(element, at: index)
}
}
func deleteListItem(for row: RowModel) {
print("delete item")
if let idx = listItems.firstIndex(where: { $0 == row }) { // { $0.id == row.id }) {
listItems.remove(at: idx)
}
}
func deleteListItems(at indices: IndexSet) {
print("delete listItems")
listItems.remove(atOffsets: indices)
}
// mutating func selectListItem(newSelection: RowModel) {
// print("selected \(newSelection.name)")
// for index in 0..<listItems.count {
// listItems[index].isSelected = false
// if listItems[index].id == newSelection.id {
// listItems[index].isSelected = true
// }
// }
// }
func selectListItem(oldSelection: RowModel, newSelection: RowModel) {
print("selected \(oldSelection.id.uuidString) to \(newSelection.id.uuidString)")
if let oldIdx = listItems.firstIndex(where: { $0.id == oldSelection.id }) {
listItems[oldIdx].isSelected = false
}
if let newIdx = listItems.firstIndex(where: { $0.id == newSelection.id }) {
listItems[newIdx].isSelected = true
}
}
}
The view presents the list, calling EItemView to display each item. Callbacks are included to support editing of the list.
struct EListView: View {
@State var listModel: EList
@State var selectedItem: EItem
var body: some View {
VStack(alignment:.leading) {
HStack {
// The .onMove modifier is available on ForEach but not List: use ForEach instead.
List(selection: $selectedItem) {
ForEach(listModel.listItems, id: \.self) { row in // \.self is needed to highlight selection
EItemView(item: row)
.contextMenu { // support ctrl-right-click to delete
Button(action: {
print("select item: \(selectedItem), item to delete: \(row)")
listModel.deleteListItem(for: row)
}) {
Text("Delete")
}
}
}
.onMove{indices, offset in // support reordering
withAnimation {
listModel.moveListItem(at: indices, to: offset)
}
}
Button(action: { // support adding new items
listModel.insertListItemAtEnd(item: selectedItem)
}, label: {
Label("Add", systemImage: "plus")
})
}
.onDeleteCommand { // support delete keys
print("select item: \(selectedItem.id.uuidString)")
let idx = listModel.listItems.firstIndex(where: { $0.id == selectedItem.id } )
if idx != nil {
listModel.deleteListItems(at: [idx!])
}
}
.onChange(of: selectedItem) { oldSelection, newSelection in // support selection change
listModel.selectListItem(oldSelection: oldSelection, newSelection: newSelection)
}
// .onChange(of: selectedItem) { newSelection in
// listModel.selectListItem(newSelection: newSelection)
// }
}
}
}
}
This example displays two lists:
A class defining multiple lists is declared as
@Observable
class MyLists {
var fruits = EList(listItems: gFruits)
var vegetables = EList(listItems: gVegetables)
}
where the initial list contents are defined as
let gFruits: [Food] = [
.init(id: UUID(), isSelected: true , name: "Apples" , image: "π"),
.init(id: UUID(), isSelected: false, name: "Bananas" , image: "π"),
.init(id: UUID(), isSelected: false, name: "Oranges" , image: "π"),
.init(id: UUID(), isSelected: false, name: "Strawberries" , image: "π"),
.init(id: UUID(), isSelected: false, name: "Blueberries" , image: "π«"),
]
let gVegetables: [Food] = [
.init(id: UUID(), isSelected: true , name: "Tomatos" , image: "π
"),
.init(id: UUID(), isSelected: false, name: "Beans" , image: "π«"),
.init(id: UUID(), isSelected: false, name: "Onions" , image: "π§
"),
.init(id: UUID(), isSelected: false, name: "Peppers" , image: "πΆοΈ"),
.init(id: UUID(), isSelected: false, name: "Carrots" , image: "π₯"),
]
The @main
is defined as
@main
struct ListUIApp: App {
@State var myLists = MyLists()
var body: some Scene {
WindowGroup {
ContentView()
.environment(myLists)
}
}
}
The startup view is declared to display the lists:
struct ContentView: View {
@Environment(MyLists.self) private var myLists
var body: some View {
VStack(alignment: .leading) {
EListView(listModel: myLists.fruits, selectedItem: myLists.fruits.selectedItem!)
EListView(listModel: myLists.vegetables, selectedItem: myLists.vegetables.selectedItem!)
}
.padding()
}
}
This example adds a new type of list:
Displayed are two text fields, a category (name) and a varieties string (name2), and an edit button.
@Observable
class Category: EItem {
private var _name: String // back-stored value
var name: String {
get { return _name }
set {
_name = newValue
}
}
private var _name2: String // back-stored value
var name2: String {
get { return _name2 }
set {
_name2 = newValue
}
}
init(id: UUID, isSelected: Bool, name: String, name2: String = "?") {
self._name = name
self._name2 = name2
super.init(id: id, isSelected: isSelected)
}
override func newItem() -> Category {
let item = Category(id: UUID(), isSelected: false, name: "new item", name2: "?")
return item
}
}
The new list is added to MyLists
:
@Observable
class MyLists {
var fruits = EList(listItems: gFruits)
var vegetables = EList(listItems: gVegetables)
var categories = EList(listItems: gCategories)
}
where the initialization is
let gCategories: [Category] = [
.init(id: UUID(), isSelected: true , name: "Apples" , name2: "gala, fiji, golden delicious"),
.init(id: UUID(), isSelected: false, name: "Cherries" , name2: "bing, queen anne" ),
.init(id: UUID(), isSelected: false, name: "Grapes" , name2: "red, green" ),
]
The item view is extended to include the new type of list:
struct EItemView<T>: View where T: EItem {
@State var item: T
@State private var isEditorPresented = false
var body: some View {
switch item.self {
case is Food:
FoodView(item: item as! Food)
case is Category:
CategoryView(item: item as! Category)
default:
Text(item.id.uuidString)
}
}
}
where
struct CategoryView: View {
@State var item: Category
@State private var isEditorPresented = false
var body: some View {
HStack {
Text(item.name)
.foregroundColor(item.isSelected ? .white : .blue)
.frame(width: 100)
TextField("", text: $item.name2, onCommit: {
print(item.name2)
})
Button(action: {
isEditorPresented = true
}) {
Text("βοΈ")
}
}
.sheet(isPresented: $isEditorPresented) {
CategoryEditView(item: item)
}
}
}
struct CategoryEditView: View {
@Bindable var item: Category
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack() {
HStack {
Text("Category: ")
TextField("Category", text: $item.name)
.textFieldStyle(.roundedBorder)
}
HStack {
Text("Varieites: ")
TextField("Varieites", text: $item.name2)
.textFieldStyle(.roundedBorder)
}
Button("Close") {
dismiss()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
This example adds a new type of list:
An item is displayed as a name and checkbox. The item's derived class is
@Observable
class Side: EItem {
private var _name: String // back-stored value
var name: String {
get { return _name }
set {
_name = newValue
}
}
private var _isChecked: Bool // back-stored value
var isChecked: Bool {
get { return _isChecked }
set {
_isChecked = newValue
}
}
init(id: UUID, isSelected: Bool, name: String, isChecked: Bool = false) {
self._name = name
self._isChecked = isChecked
super.init(id: id, isSelected: isSelected)
}
override func newItem() -> Side {
let item = Side(id: UUID(), isSelected: false, name: "new side", isChecked: false)
return item
}
}
The new list is added to MyLists
:
@Observable
class MyLists {
var fruits = EList(listItems: gFruits)
var vegetables = EList(listItems: gVegetables)
var sides = EList(listItems: gSides)
var categories = EList(listItems: gCategories)
}
where the initialization is
let gSides: [Side] = [
.init(id: UUID(), isSelected: true, name: "Potatoes" , isChecked: false),
.init(id: UUID(), isSelected: false, name: "Onions" , isChecked: false),
.init(id: UUID(), isSelected: false, name: "Corn" , isChecked: false),
.init(id: UUID(), isSelected: false, name: "Bread" , isChecked: false),
.init(id: UUID(), isSelected: false, name: "Salad" , isChecked: false),
]
The item view is extended to include the new type of list:
struct EItemView<T>: View where T: EItem {
@State var item: T
@State private var isEditorPresented = false
var body: some View {
switch item.self {
case is Food:
FoodView(item: item as! Food)
case is Side:
SideView(item: item as! Side)
case is Category:
CategoryView(item: item as! Category)
default:
Text(item.id.uuidString)
}
}
where
struct SideView: View {
@State var item: Side
var body: some View {
HStack {
TextField("", text: $item.name, onCommit: {
print(item.name)
})
Toggle(isOn: $item.isChecked)
}
}
}