Last active
August 14, 2022 22:02
-
-
Save samdeane/6486712a8adea62b8cfe671c21e724f5 to your computer and use it in GitHub Desktop.
A simple pattern for a SwiftUI editable model
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- | |
// Created by Sam Deane on 17/06/22. | |
// All code (c) 2022 - present day, Elegant Chaos Limited. | |
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- | |
import SwiftUI | |
// MARK: Application | |
@main | |
struct TestApp: App { | |
let model = Model() | |
var body: some Scene { | |
WindowGroup { | |
ContentView() | |
.environmentObject(model) | |
} | |
} | |
} | |
// MARK: Model Controller | |
class Model: ObservableObject { | |
/// The thing the model stores. | |
struct Item: Identifiable, Equatable { | |
let id: UUID | |
var name: String | |
init(name: String) { | |
self.id = UUID() | |
self.name = name | |
} | |
} | |
/// Index of items that we're storing | |
let index = Index() | |
/// Ask the model to update an item | |
func update(_ item: Item) { | |
if let entry = index.entries[item.id] { | |
// we fish its entry out of the index and update that with a new value... | |
entry.item = item | |
} else { | |
// ...or make a new entry if there wasn't one | |
index.entries[item.id] = Index.Entry(item) | |
} | |
} | |
} | |
// MARK: Model View Support | |
extension Model { | |
/// Observable collection of items. | |
/// Navigation views can observe this, so that they update when entries are added/removed. | |
class Index: ObservableObject { | |
@Published var entries: [Item.ID:Entry] | |
init() { | |
self.entries = Self.testData | |
} | |
/// Return a sorted list of entries for an index view to display | |
func sortedEntries(ascending: Bool) -> [Entry] { | |
let sorted = entries.values.sorted { $0.item.name < $1.item.name } | |
return ascending ? sorted : sorted.reversed() | |
} | |
/// Some test entries to play with | |
static var testData: [Item.ID:Entry] { | |
let testItems = ["foo", "bar"].map({ Item(name: $0)}) | |
let testEntries = testItems.map({ ($0.id, Entry($0)) }) | |
return .init(uniqueKeysWithValues: testEntries) | |
} | |
/// An individual entry in the index. | |
/// Detail and label views can observe an index entry, so it updates just when that entry is updated. | |
class Entry: ObservableObject, Identifiable { | |
@Published var item: Model.Item | |
var id: UUID { item.id } | |
init(_ item: Item) { | |
self.item = item | |
} | |
} | |
} | |
} | |
// MARK: Views | |
/// The root view. | |
struct ContentView: View { | |
@EnvironmentObject var model: Model | |
var body: some View { | |
NavigationView { | |
IndexView(model.index) | |
} | |
} | |
} | |
/// Displays a list of model items. | |
struct IndexView: View { | |
@ObservedObject var index: Model.Index | |
@State var sortAscending = false | |
init(_ index: Model.Index) { | |
self.index = index | |
} | |
var body: some View { | |
List(index.sortedEntries(ascending: sortAscending)) { entry in | |
NavigationLink(destination: DetailView(entry)) { | |
LabelView(entry) | |
} | |
} | |
} | |
} | |
/// Displays a label for an individual item. | |
/// Automatically updates if that item changes. | |
struct LabelView: View { | |
@ObservedObject var entry: Model.Index.Entry | |
init(_ entry: Model.Index.Entry) { | |
self.entry = entry | |
} | |
var body: some View { | |
Label(entry.item.name, systemImage: "tag") | |
} | |
} | |
/// Displays a detailed view of an individual item. | |
/// Automatically updates if that item changes. | |
/// Takes a local copy of the item which can be bound to UI for editing purposes. | |
/// Has a button to explicitly commit any edits | |
/// (this could instead be done when the user pops or leaves the view) | |
struct DetailView: View { | |
@EnvironmentObject var model: Model | |
@Environment(\.presentationMode) var presentationMode | |
@ObservedObject var entry: Model.Index.Entry | |
@State var editableItem: Model.Item | |
init(_ entry: Model.Index.Entry) { | |
self.entry = entry | |
self._editableItem = .init(initialValue: entry.item) | |
} | |
var body: some View { | |
VStack { | |
LazyVGrid(columns: [.init(.fixed(64), alignment: .trailing), .init(.flexible(), alignment: .leading)]) { | |
Text("Id:") | |
Text(editableItem.id.uuidString) | |
.lineLimit(1) | |
Text("Name:") | |
TextField("name", text: $editableItem.name) | |
} | |
Spacer() | |
Button(action: handleCommit) { | |
Text("Commit Change") | |
} | |
.disabled(entry.item == editableItem) | |
} | |
.padding() | |
} | |
/// Ask the model to update the item, then pop the view. | |
func handleCommit() { | |
model.update(editableItem) | |
presentationMode.wrappedValue.dismiss() | |
} | |
} |
Obvious functionality missing from this example:
- adding/removing new items; you'd do this by adding methods to the model controller, which would manage updating the index, then wiring those up to add/remove buttons
- instead of a commit button, you could auto-commit edits when popping the editor view
- or you could show a popover asking if the user wants to cancel the edit when popping without first committing
- you could add a revert button which resets any edits
- for more complex models you could generalise the index to work with different kinds of model objects
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
There are lots of ways to do this particular dance - the code above is one approach.
The key points:
@EnvironmentObject
@ObservedObject
; when the index changes, that view will be refreshed@ObservedObject
; when the model item is updated, the view will be refreshedSome benefits: