https://github.com/solcat124/listui
Editable lists on macOS using the Observation framework
https://github.com/solcat124/listui
lists listviews macos observable
Last synced: 2 months ago
JSON representation
Editable lists on macOS using the Observation framework
- Host: GitHub
- URL: https://github.com/solcat124/listui
- Owner: solcat124
- Created: 2025-03-27T02:24:53.000Z (7 months ago)
- Default Branch: main
- Last Pushed: 2025-03-27T02:24:55.000Z (7 months ago)
- Last Synced: 2025-06-10T08:54:22.843Z (4 months ago)
- Topics: lists, listviews, macos, observable
- Language: Swift
- Homepage:
- Size: 123 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# About
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:

# 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 selectedThe base item class, `EItem`, is defined as
```
@Observable
class EItem: Identifiable, Hashable {
var id: UUIDprivate 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

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 = falsevar 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: View where T: EItem {
@State var item: T
@State private var isEditorPresented = falsevar 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 listThe 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.. 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: View where T: EItem {
@State var item: T
@State private var isEditorPresented = falsevar 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 = falsevar 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:

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: View where T: EItem {
@State var item: T
@State private var isEditorPresented = falsevar 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: Sidevar body: some View {
HStack {
TextField("", text: $item.name, onCommit: {
print(item.name)
})
Toggle(isOn: $item.isChecked)
}
}
}
```