Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.
On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.
Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.
On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.
Hi Eddie,
Normally network data model != database (core data) model. You should have a separated data model and from my experience this save you from many problems. Also any networking cache should be done with default http, let the system do it, or custom saving the response on disk.
Keeping the some model data you can do this (using Active Record):
struct Channel: Identifiable, Codable {
let id: String
let name: String
let genre: String
let logo: URL?
static func saveChannels(_ channels: [Self], on: …) async throws { … }
// Factory Methods (set the data source object or protocol)
static func all(on: …) async throws -> [Self] { … }
static func favorites(on: …) async throws -> [Self] { … }
}
Using repository / manager pattern:
struct Channel: Identifiable, Codable {
let id: String
let name: String
let genre: String
let logo: URL?
}
protocol ChannelManager {
static func loadAllChannels() async throws -> [Channel]
static func loadFavoriteChannels() async throws -> [Channel]
static func saveChannels(_ channels: [Channel]) async throws
}
struct NetworkChannelManager: ChannelManager {
static func loadAllChannels() async throws -> [Channel] { … }
static func loadFavoriteChannels() async throws -> [Channel] { … }
static func saveChannels(_ channels: [Channel]) async throws { … }
}
struct LocalChannelManager: ChannelManager {
static func loadAllChannels() async throws -> [Channel] { … }
static func loadFavoriteChannels() async throws -> [Channel] { … }
static func saveChannels(_ channels: [Channel]) async throws { … }
}
Example from Vapor platform:
Uses Active Record pattern for data access where you can set the data source.
// An example of Fluent's query API.
let planets = try await Planet.query(on: database)
.filter(\.$type == .gasGiant)
.sort(\.$name)
.with(\.$star)
.all()
// Fetches all planets.
let planets = try await Planet.query(on: database).all()
Hi @Appeloper,
Thank you very much for opening this amazing thread. I've read almost every post in this thread. I've never thought OOP and Model that deep before.
...
I really learned a lot from this thread. Amazing SwiftUI + MV pattern! Thank you very very much! Please keep rocking!
Software models are ways of expressing a software design, e.g. the Channel struct represents the model of a tv channel, the Program struct represents the model of a tv program, the folder / module / package LiveTV (that contains the Channel and Program structs) represents the model of a live tv system.
As said before, network data model != local database (core data) model. Also SwiftUI has a great integration with core data.
Hi @Appeloper, Im trying to use MV architecture in my project, below is a simple example of my code:
struct PaymentView: View {
@StateObject private var store = PaymentStore()
var body: some View {
NavigationStack {
PaymentCreditorListView()
/* -> PaymentFormView() */
/* -> PaymentUnpaidView() */
/* -> PaymentConfirmationView() */
}
.environmentObject(store)
}
}
class PaymentStore: ObservableObject {
....
@Published var isLoading = false
@Published var popup: Popup?
private let service: PaymentService
init(service: PaymentService = PaymentService()) {
self.service = service
}
func getPaymentCreditors() async {
do {
isLoading = true
let response = try await service.fetchPaymentCreditors()
.....
isLoading = false
} catch {
isLoading = false
popup = .init(error: error)
}
}
func getPaymentForm() async {
do {
isLoading = true
let response = try await service.fetchPaymentForm()
....
isLoading = false
} catch {
isLoading = false
popup = .init(error: error)
}
}
func getPaymentUnpaid() async {
do {
isLoading = true
let response = try await service.fetchPaymentUnpaid()
.....
isLoading = false
} catch {
isLoading = false
popup = .init(error: error)
}
}
}
On each view I use sheet to show popup error because sometimes I need to do something specific for that view (for ex: calling web service or redirection etc...)
.sheet(item: $store.popup) { popup in
PopupView(popup: popup)
}
The only problem I have right now is when one of the endpoints return an error, all the views that use the popups are triggred and I'm getting this warning message in the console "Attempt to present * on * which is already presenting...", same problem for progressLoader, it will fire all the other views.
Did I miss something with this approach ? or is there a better way to do it ?
Hi OuSS_90,
One fast way to fix the problema is to put the alert modifier on NavigationStack and not inside each view:
struct PaymentView: View {
@StateObject private var store = PaymentStore()
var body: some View {
NavigationStack {
PaymentCreditorListView()
/* -> PaymentFormView() */
/* -> PaymentUnpaidView() */
/* -> PaymentConfirmationView() */
}
.sheet(item: $store.popup) { popup in
PopupView(popup: popup)
}
.environmentObject(store)
}
}
But from your PaymentStore I think you are mixing different things or lazy load the store information. I don’t forget that store is about data not view (e.g. there’s an error info not a “popup”). Store should be as possible an data aggregator for some domain.
I don’t know the needs but here’s a example:
class PaymentStore: ObservableObject {
@Published var isLoading = false
@Published var loadError: Error? = nil
private let service: PaymentService
@Published var creditors: [Creditor] = []
@Published var form: PaymentForm? = nil
@Published var unpaid: ? = ? // Don’t know the type
init(service: PaymentService = PaymentService()) {
self.service = service
}
func load() async {
isLoading = true
do {
let creatorsResponse = try await service.fetchPaymentCreditors()
let formResponse = try await service.fetchPaymentForm()
let unpaidResponse = try await service.fetchPaymentUnpaid()
// jsonapi spec
creators = creatorsResponse.data
form = formResponse.data
unpaid = unpaidResponse.data
} catch {
loadError = error
}
isLoading = false
}
}
Or split the PaymentStore into CreditorStore, PaymentForm and UnpaidStore, all depends on use case and how data is handled.
Making a simple social network app from Apple WWDCs CoreData example, in this case the data model is defined in the backend.
The use case, what user can do in the system and the dependencies.
ERD of database and REST API endpoints
Now the data access model (API integration) in Swift. In case the data model defined in app you use the CoreData stack + objects and this is your model. Here you can do Unit / Integration tests.
In this case the data are external and you need a model to load (or aggregate) the data in memory and use it: PostStore and TagStore. In case of local data (using CoreData) you don’t need the stores, use the SwiftUI features.
Thank you @Appeloper for your reply,
store is about data not view (e.g. there’s an error info not a “popup”) : popup is just a struct that handle error & success messages
enum PopupType {
case success
case failure(Int)
case info
case warning
case custom(String)
}
struct Popup: Identifiable {
var id = UUID()
var type: PopupType
var title: String?
var message: String?
var closeText: String?
var confirmText: String?
}
func load() async {
isLoading = true
do {
let creatorsResponse = try await service.fetchPaymentCreditors()
let formResponse = try await service.fetchPaymentForm()
let unpaidResponse = try await service.fetchPaymentUnpaid()
creators = creatorsResponse.data
form = formResponse.data
unpaid = unpaidResponse.data
} catch {
loadError = error
}
isLoading = false
}
I don't want to call all endpoints one after other, because I have three screens :
PaymentCreditorListView --> call fetchPaymentCreditors() and after user choose creditor I need to call fetchPaymentForm() that take creditor as parameter and then push to PaymentFormView (I need to save creditor to use it later in PaymentConfirmationView)
PaymentFormView --> When user press continue I need to call fetchPaymentUnpaid() that take form info as parameter and then push to PaymentUnpaidView() (I need to save form info & unpaid list to use it later in PaymentConfirmationView)
How can I handle this with their popups for each view using PaymentStore ? and if I need to split it as you said, we will not return to MVVM each view has his own store ?? because as soon as we have many endpoint, it become hard to handle each popup without firing others because they share same publisher
Also how can I handle push after endpoint finish if I can't add navigationPath inside store (because you said store have only Data)
Thank you
By design you can only had one alert at the time. Alerts are more for situations like user actions like send / submit, for the loading data cases you should use empty states. See other apps when a network error appear or no data loaded all at.
NavigationStack path is all about data, think data, not views. SwiftUI is data-driven nature.
Sometimes it feels we are doing MVVM but is different. In classic MVVM ViewModel (presentation logic) handle every View logic & state, each View have one ViewModel. The MVVM used in other platforms last years are not the true MVVM… sometimes ViewModel acts like a Store.
The problem I have is how I can handle errors/success for each view, because sometimes after error or success confirmation I need to do something different for some views. it will not work as I want if I add one sheet in root, let me give you an example:
In PaymentFormView I have button that display OTPView, after user enter code I call addToFavorite endpoint that get favoriteName from that view and if the code is wrong addToFavorite throw error, and when user confirm I need to display again OTPView and if it success I display success popup and after confirmation I need to pop to first view.
In PaymentConfirmView I have other scenario, I call submit endpoint and then I display success popup and after confirmation I need to push to other view
As you can see each view have a different staff to do after popup confirmation, If I add one sheet in root, is impossible to do this.
Is it a good idea to move do catch to view instead of store ??
class PaymentStore: ObservableObject {
@Published var creditors: [Creditor] = []
@Published var form: PaymentForm?
@Published var unpaid: PaymentUnpaid?
private let service: PaymentService
init(service: PaymentService = PaymentService()) {
self.service = service
}
func getPaymentCreditors() async throws {
creditors = try await service.fetchPaymentCreditors()
}
func getPaymentForm() async throws {
form = try await service.fetchPaymentForm()
}
func getPaymentUnpaid() async throws {
unpaid = try await service.fetchPaymentUnpaid()
}
}
struct PaymentCreditorListView: View {
@EnvironmentObject private var store: PaymentStore
@State private var idLoading = false
@State private var popup: Popup?
var body: some View {
VStack {
}
.task {
do {
isLoading = true
try await store.fetchPaymentCreditors()
isLoading = false
} catch {
isLoading = false
popup = .init(error: error)
}
.progress($isLoading)
}
}
Hi OuSS_90,
For the case you have a single source of truth and progressive / lazy loading, you can have multiple errors (or popups) in the store. Example:
class PaymentStore: ObservableObject {
@Published var creditors: [Creditor] = []
@Published var form: PaymentForm? = nil
@Published var unpaid: PaymentUnpaid? = nil
@Published var isLoadingCreditors: Bool = false
@Published var isLoadingForm: Bool = false
@Published var isLoadingUnpaid: Bool = false
@Published var creditorsError: Error? = nil // Popup
@Published var formError: Error? = nil // Popup
@Published var unpaidError: Error? = nil // Popup
private let service: PaymentService
init(service: PaymentService = PaymentService()) {
self.service = service
}
func loadCreditors() async {
creditorsError = nil
isLoadingCreditors = true
do {
creditors = try await service.fetchPaymentCreditors()
} catch {
loadCreditorsError = error
}
isLoadingCreditors = false
}
func loadForm() async {
formError = nil
isLoadingForm = true
do {
form = try await service.fetchPaymentForm()
} catch {
loadFormError = error
}
isLoadingForm = false
}
func loadUnpaid() async {
unpaidError = nil
isLoadingUnpaid = true
do {
unpaid = try await service.fetchPaymentUnpaid()
} catch {
loadUnpaidError = error
}
isLoadingUnpaid = false
}
}
Also you can have have an enum for load / error state:
enum State {
case unloaded
case loading
case success
case failure(Error)
}
…
@Published var creditorsState: State = .unloaded
Or some envelop for network data:
struct AsyncData<T> {
var data: T
var isLoading! bool = false
var error: Error? = nil
}
…
@Published var creditors: AsyncData<[Creditor]>= AsyncData(data: [])
I wish real software was as simple as just loading data into an array to be displayed in a list as you seem to love to trivialise.
And what are store entities class Store: ObservableObject
all about?
Lots of @Published
properties and some services, they seem to share a lot with the good old ViewModels, but now the view can have multiple Stores
, and that's better how?
Turns out real software is a bit more complex, eh?
The MVVM used in other platforms (Android, UIKit) last years isn’t the classic MVVM. What I read on most SwiftUI MVVM blog posts, they use the “Store pattern” but call it MVVM.
In a real and big / complex project we have 7 Stores, if we followed the “classic” MVVM we end up with +40 ViewModels. We can use e.g. a ProductStore or CategoryStore in many Views, we can share the UserStore or ShoppingCart with many views in hierarchy. Many people do it but call it “ViewModel” (incorrect name).
How would you do to make a change on book from BookView and have that change reflect all the way up to BookList?
Hi jaja_etx, you can share the BookStore in the hierarchy with @EnvironmentObject or @ObservedObject and update the book.
// Books view
struct BookList: View {
@StateObject private var store = BookStore()
var body: some View {
NavigationStack {
List {
...
}
.task {
await store.load()
}
}
.environmentObject(store)
}
}
// Book detail view
struct BookView: View {
@State var book: Book
@EnvironmentObject private var store: BookStore
var body: some View {
ScrollView {
...
}
}
func toogleIsReaded() {
book.isReaded.toggle() // Update the local state
store.update(book) // Update the book on the store and send update to the web service if needed
}
}
One of the coolest threads I've read. Reminds me of Neo, who escaped from the Matrix.
At the WWDC 2023 presentation, a new Observation framework and wrapper over CoreData, SwiftData, was introduced. After practicing with this new design pattern, I realized what the topic starter meant @Appeloper