Skip to content

Instantly share code, notes, and snippets.

@samdeane
Last active August 14, 2022 22:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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

There are lots of ways to do this particular dance - the code above is one approach.

The key points:

  • inject a model controller using the environment, any view that needs it can obtain it with @EnvironmentObject
  • the model vends an "index" object; this can be passed to any view that needs it, which can observe it with @ObservedObject; when the index changes, that view will be refreshed
  • the index vends "index entry" objects - one for each model item
  • an entry object can be passed to any view that shows details of the item, which can observe it with @ObservedObject; when the model item is updated, the view will be refreshed
  • any view that edits a model object can take a local copy of it from the index entry it was passed, and can bind to properties on that entry
  • when the user commits edits (either explicitly with a button, or implicitly by popping the navigation stack), the model controller can be asked to update the corresponding item
  • the model controller does this by updating the index entry, which in turn will refresh any views displaying the item

Some benefits:

  • the actual model items can be simple structs and code for loading/saving or other logic can work with them directly
  • using the environment for the model controller decouples it from the views and allows something else to be injected for testing
  • passing index and entry objects into the views that use them decouple them from the model controller and allow something else to be injected for testing/preview
  • the model controller mediates things by vending items and receive update requests, but does not directly need to be observed, so views won't refresh unnecessarily just because they are using it
  • the index and entry objects could be thought of as view-model; making them objects allows them to be observable, which allows efficient updating of just the views using them
  • splitting the index and entries into separate observable objects means that navigation view(s) won't need a full refresh if only the details of an item has updated, and item details views won't need an refresh unless their particular object has been updated

@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