Imagine the following minimal SwiftUI app demo that uses SwiftData:
App:
import SwiftUI
import SwiftData
@main
struct SwiftData_Model_Repo_TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: Room.self)
}
}
}
SwiftData Models:
@Model
final class Room {
var name: String
var area: Double
var isSelected:Bool
init(name: String, area: Double, isSelected: Bool) {
self.name = name
self.area = area
self.isSelected = isSelected
}
}
ContentView:
// for random String generation
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
struct ContentView: View {
@Query private var rooms: [Room]
@Environment(\.modelContext) var modelContext
@AppStorage("sliderValue") var sliderVal:Double = 0.5
var selectedRooms:[Room] {
var result:[Room] = []
for room in rooms {
if room.isSelected {
result.append(room)
}
}
return result
}
// this is a function of BOTH user input (slider) AND selectedRooms
var totalHouseSize:Double {
var totalArea = 0.0
for room in selectedRooms {
totalArea += room.area
}
return (totalArea * sliderVal)
}
var body: some View {
Spacer()
Text("Add a room").onTapGesture {
let randomString = String((0..<4).map{ _ in letters.randomElement()! })
let newRoom = Room(name: "Room \(randomString)", area: Double.random(in: 800...3000), isSelected: false)
modelContext.insert(newRoom)
}
List{
ForEach(rooms, id: \.self) { room in
HStack{
Text(room.name)
Text("\(Int(room.area))")
Spacer()
Circle()
.fill(room.isSelected ? Color.black : Color.white)
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 3)
)
.onTapGesture {
withAnimation{
room.isSelected.toggle()
}
}
}
}
}
Spacer()
Text("house size multiplier: x \(sliderVal)")
Slider(value: $sliderVal, in: 1...100)
Spacer()
Text("total house size will be: \(totalHouseSize)")
}
}
The “rooms”/”house” scenario in this example code is inconsequential/convoluted/simplistic for brevity.
The important takeaways are:
- We have a set of fundamental objects that we are keeping persistent using SwiftData
- There is potentially very complex logic associated with this data that is used to formulate views
- This logic is a function of ALL these “independent variables”:
- user input via two-way bindings (these values are also persistent)
- the set of selected data objects
- properties within those individual data items
So, as you can see, in this example, like all SwiftData examples I have seen, we “query” the data objects directly from within a view… and any “helper functions” we write also must exist inside that view. This gets messier the more complex things become.
Our options for refactoring seem to be:
- Make a separate class full of static helper functions (seems bad)
- Make a separate struct that is initialized using all the independent variables involved, and just re-instantiate it every time the view is refreshed due to a state change (seems bad)
- Attempt the MVVM pattern in SwiftUI, which is generally frowned upon these days, with only semi-workable techniques (seems not great)
But what if we want a singular “Main Repo Class” like this one from the SwiftUI Landmarks tutorial:
@Observable
class ModelData {
var landmarks: [Landmark] = load("landmarkData.json")
var hikes: [Hike] = load("hikeData.json")
var profile = Profile.default
var features: [Landmark] {
landmarks.filter { $0.isFeatured }
}
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarks,
by: { $0.category.rawValue }
)
}
}
This does not use SwiftData because it wasn’t released yet, I believe. I think this is known as the “repository pattern?” Anyway, it has the benefit of a single entry point to our “repository” and it encapsulates the associated logic. If anyone knows the proper software design term for this, please comment.
But like I said, I have not seen any SwiftData samples where there is a “single instance entry point” to the data like this.
I have managed to rustle up the following working refactor:
App:
import SwiftUI
import SwiftData
@main
struct SwiftData_Model_Repo_TestApp: App {
var body: some Scene {
WindowGroup {
TopLevelWrapperView()
.modelContainer(for: ModelRootInstance.self)
}
}
}
SwiftData Models:
[room model is the same]
@Model
final class ModelRootInstance {
// this is our basic data repo
var rooms:[Room]
var sliderVal:Double
var selectedRooms:[Room] {
var result:[Room] = []
for room in rooms {
if room.isSelected {
result.append(room)
}
}
return result
}
// this is a function of BOTH user input (slider) AND selectedRooms
var totalHouseSize:Double {
var totalArea = 0.0
for room in selectedRooms {
totalArea += room.area
}
return (totalArea * sliderVal)
}
init(rooms: [Room], sliderVal: Double) {
self.rooms = rooms
self.sliderVal = sliderVal
}
}
TopLevelWrapper:
struct TopLevelWrapperView: View {
@Query private var repo: [ModelRootInstance]
@Environment(\.modelContext) var modelContext
var body: some View {
VStack{
if !repo.isEmpty {
ContentView(repo: repo.first!)
} else {
Color.red
}
}.onAppear(perform: {
if repo.isEmpty {
let _blah = ModelRootInstance(rooms: [], sliderVal: 1)
modelContext.insert(_blah)
}
})
}
}
ContentView:
// for random String generation
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
struct ContentView: View {
@Bindable var repo: ModelRootInstance
var body: some View {
Spacer()
Text("Add a room").onTapGesture {
print("yup")
let randomString = String((0..<4).map{ _ in letters.randomElement()! })
let newRoom = Room(name: "Room \(randomString)", area: Double.random(in: 800...3000), isSelected: false)
repo.rooms.append(newRoom)
print("\(repo.rooms.count)")
}
List{
ForEach(repo.rooms, id: \.self) { room in
HStack{
Text(room.name)
Text("\(Int(room.area))")
Spacer()
Circle()
.fill(room.isSelected ? Color.black : Color.white)
.frame(width: 50, height: 50)
.overlay(
Circle()
.stroke(Color.black, lineWidth: 3)
)
.onTapGesture {
withAnimation{
room.isSelected.toggle()
}
}
}
}
}
Spacer()
Text("house size multiplier: x \(repo.sliderVal)")
Slider(value: $repo.sliderVal, in: 1...100)
Spacer()
Text("total house size will be: \(repo.totalHouseSize)")
}
}
What has changed in the refactor:
- creates a new @Model Swiftdata class that serves as the “Main Repo Class.”
- all other “swift data” model instances are a property of this class
- conditionally initializes the single instance of this class if necessary and inserts it into a new “TopLevelWrapper” view that sits between app and ContentView and exists only for this purpose. This is necessary because you can’t (apparently) access SwiftData modelContext outside of a view.
- the sliderValue is no longer implemented with app storage but as a property of the repo class
- all logic is in the repo class
- we no longer need modelContext and act directly on repo.rooms
I’m not sure if this refactor is acceptable/workable or god forbid even genious… or if it’s just a terribly stupid anti-pattern.
To avoid being accused of asking multiple questions, I’ll put it as a flowchart like this:
Is there some known reason why this should not even be done/attempted? If not, have I provided the current defacto approach? If not, then what is the best way to do it?