Skip to content

Instantly share code, notes, and snippets.

@samdeane
Last active August 14, 2022 22:02
Show Gist options
  • Save samdeane/6486712a8adea62b8cfe671c21e724f5 to your computer and use it in GitHub Desktop.
Save samdeane/6486712a8adea62b8cfe671c21e724f5 to your computer and use it in GitHub Desktop.
A simple pattern for a SwiftUI editable model
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
// 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()
}
}
@samdeane
Copy link
Author

samdeane commented Jun 18, 2022

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