ios – How to use Repository Pattern with SwiftData?


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:

  1. Make a separate class full of static helper functions (seems bad)
  2. 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)
  3. 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?

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img