{"id":26560760,"url":"https://github.com/solcat124/listui","last_synced_at":"2026-05-01T21:32:22.832Z","repository":{"id":298270868,"uuid":"955671750","full_name":"solcat124/ListUI","owner":"solcat124","description":"Editable lists on macOS using the Observation framework","archived":false,"fork":false,"pushed_at":"2025-03-27T02:24:55.000Z","size":126,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-10-25T22:26:56.780Z","etag":null,"topics":["lists","listviews","macos","observable"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/solcat124.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2025-03-27T02:24:53.000Z","updated_at":"2025-03-27T02:32:00.000Z","dependencies_parsed_at":"2025-06-13T08:46:50.756Z","dependency_job_id":null,"html_url":"https://github.com/solcat124/ListUI","commit_stats":null,"previous_names":["solcat124/listui"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/solcat124/ListUI","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solcat124%2FListUI","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solcat124%2FListUI/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solcat124%2FListUI/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solcat124%2FListUI/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/solcat124","download_url":"https://codeload.github.com/solcat124/ListUI/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/solcat124%2FListUI/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32513511,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-30T13:12:12.517Z","status":"online","status_checked_at":"2026-05-01T02:00:05.856Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["lists","listviews","macos","observable"],"created_at":"2025-03-22T13:29:52.218Z","updated_at":"2026-05-01T21:32:22.806Z","avatar_url":"https://github.com/solcat124.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# About\n\nExample SwiftUI code to manage a list on macOS. \n\nAn example list could appear as \n\n![ReadMe-list.png](ReadMe-list.png)\n\nSupported user actions:\n\n- Selection. Arrow keys and mouse clicks can be used to change which item in the list is selected. (The selected list is highlighted.)\n- Add an item. An *add* button appears in the list, which when clicked adds an item to the list.\n- Rename an item. Clicking on the name of a list item allows the name to be edited.\n- Rearrange the order. Dragging and moving items reorders the list.\n- 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.\n\nA final example demonstrates creating multiple lists with different characteristics:\n\n![ReadMe-list4.png](ReadMe-list4.png)\n\n\n# Implementation\n\nThe implementation makes use of the Observation framework. The implementation addresses \n\n- Defining, maintaining, and viewing a single item in the list\n- Defining, maintaining, and viewing the list itself\n\n## An Item\n\n### Item Base Class\n\nAn item in the list begins with a base class providing the minimum requirements for a list, namely  \n\n- an `id` parameter that uniquely identifies the item in the list\n- an `isSelected` parameter indicating whether the item is selected\n\nThe base item class, `EItem`, is defined as \n\n```\n@Observable\nclass EItem: Identifiable, Hashable {\n    var id: UUID\n\n    private var _isSelected: Bool               // back-stored value\n    var isSelected: Bool {\n        get { return _isSelected }\n        set {\n            _isSelected = newValue\n        }\n    }\n    \n    init(id: UUID, isSelected: Bool) {\n        self.id = id\n        self._isSelected = isSelected\n    }\n    \n    func newItem() -\u003e EItem {\n        return EItem(id: UUID(), isSelected: false)\n    }\n}\n```\n\nIn 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\n\n```\n var isSelected: Bool\n```\nwould be sufficient. \n\n### Item Derived Class\n\nIn the example above a single item may appear as \n\n![ReadMe-item.png](ReadMe-item.png)\n\nIn 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:\n\n```\n@Observable\nclass Food: EItem {\n    private var _name: String                   // back-stored value\n    var name: String {\n        get { return _name }\n        set {\n            _name = newValue\n        }\n    }\n    \n    private var _image: String                  // back-stored value\n    var image: String {\n        get { return _image }\n        set {\n            _image = newValue\n        }\n    }\n    \n    init(id: UUID, isSelected: Bool, name: String, image: String = \"?\") {\n        self._name = name\n        self._image = image\n        super.init(id: id, isSelected: isSelected)\n    }\n    \n    override func newItem() -\u003e Food {\n        let item = Food(id: UUID(), isSelected: false, name: \"new food\", image: \"?\")\n        return item\n    }\n```\n\n### An Item View\n\nIn 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.\n\n```\nstruct FoodView: View {\n    @State var item: Food\n    @State private var isEditorPresented = false\n\n    var body: some View {\n        HStack {\n            TextField(\"\", text: $item.name, onCommit: {\n                print(item.name)\n            })\n            Button(action: {\n                isEditorPresented = true\n            }) {\n                Text(item.image)\n            }\n        }\n        .sheet(isPresented: $isEditorPresented) {\n            FoodImageEditView(item: item)\n        }\n    }\n}\n\nstruct FoodImageEditView: View {\n    @Bindable var item: Food\n    @Environment(\\.dismiss) private var dismiss\n    \n    var body: some View {\n        VStack() {\n            TextField(\"Title\", text: $item.image)\n                .textFieldStyle(.roundedBorder)\n                .onSubmit {\n                    dismiss()\n                }\n                \n            Button(\"Close\") {\n                dismiss()\n            }\n            .buttonStyle(.borderedProminent)\n        }\n        .padding()\n    }\n}\n\n```\n\nAs the view can vary for different lists, an entry view is used to call the appropriate view for a given class. \n\n```\nstruct EItemView\u003cT\u003e: View where T: EItem {\n    @State var item: T\n    @State private var isEditorPresented = false\n\n    var body: some View {\n        switch item.self {\n        case is Food:\n            FoodView(item: item as! Food)\n        default:\n            Text(item.id.uuidString)\n        }\n    }\n}\n```\n\n## A List\n\nThe list is implemented with\n\n- 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\n- A list view that implements the GUI for the list and handles user actions; this calls EItemView to display each item in the list\n\nThe Observation framework handles communication between the class and view implementations. \n\n### List Class\n\nThe list class has these features:\n\n- A `listItems` parameter defining the list; this is an array of list item objects.\n- A `selectedIndex` parameter indicating the index of the first selected item in the array\n- A `selectedItem` parameter indicating the item first selected item in the array\n- A function `moveListItem` to reorder the list\n- A function `insertListItemAtEnd` to append a new item to the list\n- A function `deleteListItem` to delete a list item at a given array offset\n- A function `deleteListItems` to delete list items at given array offsets\n- A function `selectListItem` when changing the item selected in the list\n\n```\n@Observable\nclass EList {\n    typealias RowModel = EItem\n    \n    private var _listItems: [RowModel] = []\n    var listItems: [RowModel] {\n        get { return _listItems }\n        set {\n            _listItems = newValue\n        }\n    }\n    \n    /**\n     Get the array index of the first selected list-item or return 0 (the index for the first item in the list).\n     */\n    var selectedIndex: Int? {\n        guard listItems.count \u003e 0 else { return nil }\n        if let idx = listItems.firstIndex(where: { $0.isSelected }) {\n            return idx\n        }\n        return 0                    // fatalError(\"list item not found\")\n    }\n    \n    /**\n     Get the first selected list-item, or return the first item in the list.\n     */\n    var selectedItem: RowModel? {\n        get {\n            guard listItems.count \u003e 0 else { return nil }\n            if let idx = listItems.firstIndex(where: { $0.isSelected }) {\n                return listItems[idx]\n            }\n            return listItems[0]     // fatalError(\"list item not found\")\n        }\n    }\n\n    init(listItems: [RowModel] = []) {\n        self.listItems = listItems\n    }\n}\n\n// MARK: - Default implementations for list management functions\n\nextension EList {\n    func moveListItem(at source: IndexSet, to destination: Int) {\n        print(\"move listItems\")\n        listItems.move(fromOffsets: source, toOffset: destination)\n    }\n    \n    func insertListItemAtEnd(item: RowModel) {\n        let row = item.newItem()\n        listItems.append(row)\n    }\n\n    func insertListItems(at indices: IndexSet, as newElements: [RowModel]) {\n        print(\"insert listItems\")\n        for (index, element) in zip(indices, newElements) {\n            listItems.insert(element, at: index)\n        }\n    }\n    \n    func deleteListItem(for row: RowModel) {\n        print(\"delete item\")\n        if let idx = listItems.firstIndex(where: { $0 == row }) {     // { $0.id == row.id }) {\n            listItems.remove(at: idx)\n        }\n    }\n    \n    func deleteListItems(at indices: IndexSet) {\n        print(\"delete listItems\")\n        listItems.remove(atOffsets: indices)\n    }\n\n//    mutating func selectListItem(newSelection: RowModel) {\n//        print(\"selected \\(newSelection.name)\")\n//        for index in 0..\u003clistItems.count {\n//            listItems[index].isSelected = false\n//            if listItems[index].id == newSelection.id {\n//                listItems[index].isSelected = true\n//            }\n//        }\n//    }\n\n    func selectListItem(oldSelection: RowModel, newSelection: RowModel) {\n        print(\"selected \\(oldSelection.id.uuidString) to \\(newSelection.id.uuidString)\")\n        if let oldIdx = listItems.firstIndex(where: { $0.id == oldSelection.id }) {\n            listItems[oldIdx].isSelected = false\n        }\n        if let newIdx = listItems.firstIndex(where: { $0.id == newSelection.id }) {\n            listItems[newIdx].isSelected = true\n        }\n    }\n}\n```\n\n### A List View\n\nThe view presents the list, calling EItemView to display each item. Callbacks are included to support editing of the list.\n\n```\nstruct EListView: View {\n    @State var listModel: EList\n    @State var selectedItem: EItem\n\n    var body: some View {\n        \n        VStack(alignment:.leading) {\n            HStack {\n                // The .onMove modifier is available on ForEach but not List: use ForEach instead.\n                List(selection: $selectedItem) {\n                    ForEach(listModel.listItems, id: \\.self) { row in        // \\.self is needed to highlight selection\n                        EItemView(item: row)\n                            .contextMenu {          // support ctrl-right-click to delete\n                                Button(action: {\n                                    print(\"select item: \\(selectedItem), item to delete: \\(row)\")\n                                    listModel.deleteListItem(for: row)\n                                }) {\n                                    Text(\"Delete\")\n                                }\n                            }\n                    }\n                    .onMove{indices, offset in      // support reordering\n                        withAnimation {\n                            listModel.moveListItem(at: indices, to: offset)\n                        }\n                    }\n                    \n                    Button(action: {                // support adding new items\n                        listModel.insertListItemAtEnd(item: selectedItem)\n                    }, label: {\n                        Label(\"Add\", systemImage: \"plus\")\n                    })\n                }\n                .onDeleteCommand {                  // support delete keys\n                    print(\"select item: \\(selectedItem.id.uuidString)\")\n                    let idx = listModel.listItems.firstIndex(where: { $0.id == selectedItem.id } )\n                    if idx != nil {\n                        listModel.deleteListItems(at: [idx!])\n                    }\n                }\n                .onChange(of: selectedItem) { oldSelection, newSelection in     // support selection change\n                    listModel.selectListItem(oldSelection: oldSelection, newSelection: newSelection)\n                }\n//                .onChange(of: selectedItem) { newSelection in\n//                    listModel.selectListItem(newSelection: newSelection)\n//                }\n            }\n        }\n    }\n}\n```\n# Example 1\n\nThis example displays two lists:\n\n![ReadMe-app.png](ReadMe-app.png)\n\nA class defining multiple lists is declared as\n\n```\n@Observable\nclass MyLists {\n    var fruits     = EList(listItems: gFruits)\n    var vegetables = EList(listItems: gVegetables)\n}\n```\n\nwhere the initial list contents are defined as\n\n```\nlet gFruits: [Food] = [\n    .init(id: UUID(), isSelected: true , name: \"Apples\"       , image: \"🍎\"),\n    .init(id: UUID(), isSelected: false, name: \"Bananas\"      , image: \"🍌\"),\n    .init(id: UUID(), isSelected: false, name: \"Oranges\"      , image: \"🍊\"),\n    .init(id: UUID(), isSelected: false, name: \"Strawberries\" , image: \"🍓\"),\n    .init(id: UUID(), isSelected: false, name: \"Blueberries\"  , image: \"🫐\"),\n]\n\nlet gVegetables: [Food]  = [\n    .init(id: UUID(), isSelected: true , name: \"Tomatos\"      , image: \"🍅\"),\n    .init(id: UUID(), isSelected: false, name: \"Beans\"        , image: \"🫛\"),\n    .init(id: UUID(), isSelected: false, name: \"Onions\"       , image: \"🧅\"),\n    .init(id: UUID(), isSelected: false, name: \"Peppers\"      , image: \"🌶️\"),\n    .init(id: UUID(), isSelected: false, name: \"Carrots\"      , image: \"🥕\"),\n]\n```\n\nThe `@main` is defined as\n\n```\n@main\nstruct ListUIApp: App {\n    @State var myLists = MyLists()\n\n    var body: some Scene {\n        WindowGroup {\n            ContentView()\n                .environment(myLists)\n        }\n    }\n}\n```\n\nThe startup view is declared to display the lists:\n\n```\nstruct ContentView: View {\n    @Environment(MyLists.self) private var myLists\n\n    var body: some View {\n        VStack(alignment: .leading) {\n            EListView(listModel: myLists.fruits, selectedItem: myLists.fruits.selectedItem!)\n            EListView(listModel: myLists.vegetables, selectedItem: myLists.vegetables.selectedItem!)\n        }\n        .padding()\n    }\n}\n```\n\n# Example 2\n\nThis example adds a new type of list: \n\n![ReadMe-list2.png](ReadMe-list2.png)\n\nDisplayed are two text fields, a category (name) and a varieties string (name2), and an edit button. \n\n```\n@Observable\nclass Category: EItem {\n    private var _name: String                   // back-stored value\n    var name: String {\n        get { return _name }\n        set {\n            _name = newValue\n        }\n    }\n    \n    private var _name2: String                  // back-stored value\n    var name2: String {\n        get { return _name2 }\n        set {\n            _name2 = newValue\n        }\n    }\n    \n    init(id: UUID, isSelected: Bool, name: String, name2: String = \"?\") {\n        self._name = name\n        self._name2 = name2\n        super.init(id: id, isSelected: isSelected)\n    }\n    \n    override func newItem() -\u003e Category {\n        let item = Category(id: UUID(), isSelected: false, name: \"new item\", name2: \"?\")\n        return item\n    }\n}\n```\n\nThe new list is added to `MyLists`:\n\n```\n@Observable\nclass MyLists {\n    var fruits = EList(listItems: gFruits)\n    var vegetables = EList(listItems: gVegetables)\n    var categories = EList(listItems: gCategories)\n}\n```\n\nwhere the initialization is\n\n```\nlet gCategories: [Category]  = [\n    .init(id: UUID(), isSelected: true , name: \"Apples\"       , name2: \"gala, fiji, golden delicious\"),\n    .init(id: UUID(), isSelected: false, name: \"Cherries\"     , name2: \"bing, queen anne\"            ),\n    .init(id: UUID(), isSelected: false, name: \"Grapes\"       , name2: \"red, green\"                  ),\n ]\n```\n\nThe item view is extended to include the new type of list:\n\n```\nstruct EItemView\u003cT\u003e: View where T: EItem {\n    @State var item: T\n    @State private var isEditorPresented = false\n\n    var body: some View {\n        switch item.self {\n        case is Food:\n            FoodView(item: item as! Food)\n        case is Category:\n            CategoryView(item: item as! Category)\n        default:\n            Text(item.id.uuidString)\n        }\n    }\n}\n```\n\nwhere\n\n```\nstruct CategoryView: View {\n    @State var item: Category\n    @State private var isEditorPresented = false\n\n    var body: some View {\n        HStack {\n            Text(item.name)\n                .foregroundColor(item.isSelected ? .white : .blue)\n                .frame(width: 100)\n            TextField(\"\", text: $item.name2, onCommit: {\n                print(item.name2)\n            })\n            Button(action: {\n                isEditorPresented = true\n            }) {\n                Text(\"✍️\")\n            }\n        }\n        .sheet(isPresented: $isEditorPresented) {\n            CategoryEditView(item: item)\n        }\n    }\n}\n\nstruct CategoryEditView: View {\n    @Bindable var item: Category\n    @Environment(\\.dismiss) private var dismiss\n    \n    var body: some View {\n        VStack() {\n            HStack {\n                Text(\"Category: \")\n                TextField(\"Category\", text: $item.name)\n                    .textFieldStyle(.roundedBorder)\n            }\n            HStack {\n                Text(\"Varieites: \")\n                TextField(\"Varieites\", text: $item.name2)\n                    .textFieldStyle(.roundedBorder)\n            }\n\n            Button(\"Close\") {\n                dismiss()\n            }\n            .buttonStyle(.borderedProminent)\n        }\n        .padding()\n    }\n}\n```\n\n# Example 3\n\nThis example adds a new type of list: \n\n![ReadMe-list3.png](ReadMe-list3.png)\n\nAn item is displayed as a name and checkbox. The item's derived class is\n\n```\n@Observable\nclass Side: EItem { \n    private var _name: String                   // back-stored value\n    var name: String {\n        get { return _name }\n        set {\n            _name = newValue\n        }\n    }\n    \n    private var _isChecked: Bool                // back-stored value\n    var isChecked: Bool {\n        get { return _isChecked }\n        set {\n            _isChecked = newValue\n        }\n    }\n    \n    init(id: UUID, isSelected: Bool, name: String, isChecked: Bool = false) {\n        self._name = name\n        self._isChecked = isChecked\n        super.init(id: id, isSelected: isSelected)\n    }\n    \n    override func newItem() -\u003e Side {\n        let item = Side(id: UUID(), isSelected: false, name: \"new side\", isChecked: false)\n        return item\n    }\n}\n```\n\nThe new list is added to `MyLists`:\n\n```\n@Observable\nclass MyLists {\n    var fruits     = EList(listItems: gFruits)\n    var vegetables = EList(listItems: gVegetables)\n    var sides      = EList(listItems: gSides)\n    var categories = EList(listItems: gCategories)\n}\n```\n\nwhere the initialization is\n\n```\nlet gSides: [Side] = [\n    .init(id: UUID(), isSelected: true,  name: \"Potatoes\"    , isChecked: false),\n    .init(id: UUID(), isSelected: false, name: \"Onions\"      , isChecked: false),\n    .init(id: UUID(), isSelected: false, name: \"Corn\"        , isChecked: false),\n    .init(id: UUID(), isSelected: false, name: \"Bread\"       , isChecked: false),\n    .init(id: UUID(), isSelected: false, name: \"Salad\"       , isChecked: false),\n]\n```\n\nThe item view is extended to include the new type of list:\n\n```\nstruct EItemView\u003cT\u003e: View where T: EItem {\n    @State var item: T\n    @State private var isEditorPresented = false\n\n    var body: some View {\n        switch item.self {\n        case is Food:\n            FoodView(item: item as! Food)\n        case is Side:\n            SideView(item: item as! Side)\n        case is Category:\n            CategoryView(item: item as! Category)\n        default:\n            Text(item.id.uuidString)\n        }\n    }\n```\n\nwhere\n\n```\nstruct SideView: View {\n    @State var item: Side\n\n    var body: some View {\n        HStack {\n            TextField(\"\", text: $item.name, onCommit: {\n                print(item.name)\n            })\n            Toggle(isOn: $item.isChecked) \n        }\n    }\n}\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolcat124%2Flistui","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsolcat124%2Flistui","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsolcat124%2Flistui/lists"}