Skip to content

solcat124/ListUI

Repository files navigation

About

Example SwiftUI code to manage a list on macOS.

An example list could appear as

ReadMe-list.png

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:

ReadMe-list4.png

Implementation

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

Item Base Class

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.

Item Derived Class

In the example above a single item may appear as

ReadMe-item.png

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
    }

An Item View

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)
        }
    }
}

A List

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.

List Class

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
        }
    }
}

A List View

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)
//                }
            }
        }
    }
}

Example 1

This example displays two lists:

ReadMe-app.png

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()
    }
}

Example 2

This example adds a new type of list:

ReadMe-list2.png

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()
    }
}

Example 3

This example adds a new type of list:

ReadMe-list3.png

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) 
        }
    }
}

About

Editable lists on macOS using the Observation framework

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages