Stop using MVVM for SwiftUI

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.

Post not yet marked as solved Up vote post of Appeloper Down vote post of Appeloper
85k views
  • Thank you, very helpful. Especially enjoyed the big fat mess as the end 😂

  • 1/N Thanks for writing this post! I have written many articles regarding SwiftUI and MVVM, I have written books and even created courses on SwiftUI and MVVM. But each time I implemented a solution in SwiftUI and MVVM I felt not comfortable. The main reason was that I was always fighting the framework. When you work on a large application using MVVM with SwiftUI and you want to share state then it becomes extremely hard.

  • 2/N The main reason is that you cannot access EnvironmentObject inside the view model. Well, you can pass it using a constructor but then it becomes pain to do it again and again. And most of the time, you end up getting confused that who is managing the state. I ran into this scenario multiple times and I had to take alternate routes to resolve this problem. One thing to note is that React and Flutter (as original poster said) does not use MVVM pattern. React uses context or Redux and Flutter

Replies

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

Let me try to explain what is over engineering looks like for a following problem statement: Problem Statement: Print "Hello World" 5 times.

// Technique 1 :  Can be written by a naive developer, but that is fine, code is functional
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")
print("Hello World")

// Technique 2 :  Better, here developer followed DRY principle ie Don't repeat yourself
for (i=1;i<=5;i=i+1) {
    print("Hello World")
}

// Technique 3 : Much better, along with DRY developer kept in mind for new changes that should be done easily
#define SIZE 5 
for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 4 : over engineering started, well in this case developer don't need to recompile the code when changes are there for SIZE, just need to update number in the config file

int SIZE = ReadSizeFromAConfigurationFile("config")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 5 : too much over engineering, now developer is reading that SIZE from the backend, again this has great flexibility, but you can see too many things have been involved here  

int SIZE = ReadSizeFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("Hello World")
}

// Technique 6 : again too much over engineering, and developer is not following YAGNI principle, that's means requirement was saying print "Hello World", but developer written in a way that he/she can also change the string without any recompilation

int SIZE = ReadSizeFromTheBackend("some_server_url")
String s = ReadStringFromTheBackend("some_server_url")

for (i=1;i<=SIZE;i=i+1) {
    print("\(s)")
}

For naive developers technique 6 can be over engineered... but for someone else it can be functional + maintainable code, also there is no standard definition of over engineering code...

KISS (Keep It Simple) vs SOLID in numbers

(real world project work hours from my team)

Team KISS

  • Development: 120 hours
  • Change #1: 16 hours
  • Change #2: 2 hours

Team SOLID

  • Development: +1500 hours
  • Change #1: 48 hours
  • Change #2: 14 hours

Everything becomes problematic, complex, unmanageable and expensive. (no other team members understand and want to fix / change)

These results are REAL and the company / managers are upset about the SOLID decision. This is very common in many teams and companies. KISS approach is more productive and easy to test, fix, change.

  • This is really nice that Team KISS did the project just in 120hrs (15 working days) and Team SOLID took 1500hrs ie around 6 months

    S:Single responsibility O:Open closed L:Liskov substitution I:Interface segregation D:Dependency Inversion

    I am just wondering which part took that much time, well they must be using S for writing isolated component or modules, also O for adding new features, not sure about L and I, but D also must be there for inverting the dependency. Can you please elaborate?

  • Is a small project, with a small estimation. The SOLID team wasted time with many layers, abstractions, protocols, objects, also refactoring the thinks because is not ok, any change they need to refactoring the code. Also they use MVVM and fight the system in many ways. I can’t show you but the SOLID project was 10x more code files, 20x code. You know, this is very common for a SOLID project!

  • Yes, in small project applying SOLID is a wrong decision... It is like that if you are building a 2BHK house, then you don't need super skilled electrician, plumber, carpenter, and all that... but if you building a big mall then you will need some good skills and some proven principles and patterns... same thing you can apply on small project vs bigger projects...

    In small project you can design your UI in XIB or Storyboard and write everything in a single view controller (simply)

This is really nice that Team KISS did the project just in 120hrs (15 working days) and Team SOLID took 1500hrs ie around 6 months

  1. S:Single responsibility
  2. O:Open closed
  3. L:Liskov substitution
  4. I:Interface segregation
  5. D:Dependency Inversion

I am just wondering which part took that much time, well they must be using S for writing isolated component or modules, also O for adding new features, not sure about L and I, but D also must be there for inverting the dependency. Can you please elaborate?

  • Is a small project, with a small estimation. The SOLID team wasted time with many layers, abstractions, protocols, objects, also refactoring the thinks because is not ok, any change they need to refactoring the code. Also they use MVVM and fight the system in many ways. I can’t show you but the SOLID project was 10x more code files, 20x code. You know, this is very common for a SOLID project!

  • I posted some others examples in this topic. Is really easy to understand why SOLID and Clean Architecture becomes a big problem to companies.

    A picture is worth a thousand words. Sorry but anyone, also my dog, can see the difference.

    Example #1 Example #2

  • In my personal opinion and 20 years of experience in software engineering, developers should stop following SOLID and Clean. As a team leader I also don’t recommend any developer with SOLID / Clean / Testability talk in the interviews. They will make complexity and problematic software, all has a cost for the company.

Sorry but my personal opinion is only about developers must stop follow SOLID and “Uncle Bob” bad practices. The fact is, in world, SOLID projects (small, big) become complex, expensive and unmanageable, this is a fact, there are results / metrics that prove it.

You can see the difference on Apple, after Scott Forstall left everything becomes problematic, buggy and delayed. Scott Forstall was not an Agile / CI / CD / SOLID guy and everything platform (and iOS software) release was perfect! Just works! Every developer and iPhone user see the difference. There’s an iOS before Scott and another after. This is a fact!

Also, many don’t know but Agile / “Uncle Bod” approach just killed Microsoft. This is very well known during Windows Longhorn (Vista) development, everything becomes problematic, delayed and we never see some great features. The problems stills next years and they lose the smartphone battle. This is a fact!

Note: I think (and hope!) that Apple is not a fully (or obsessed) Agile / CI / CD / SOLID company, but new developer generation could bringing it on.

  • But I had something else regarding Scott... when he was in Apple iOS 6 release was delayed...

    I've no idea about Microsoft

  • you must be a junior dev. You're dead wrong about SOLID and TDD. Although it's a bit hard to master. Kinda like VIM.

Add a Comment

I love this perspective on MV and SwiftUI. I need more time to digest it to understand how I would fully integrate it with my own apps (all SwiftUI + MVVM). I'm struggling to see how you would recommend incorporating Core Data into an MV-based app using SwiftUI's features like @FetchedRequest. It seems like @FetchedRequest specifically puts the model directly in the view, leading to code bloat in the View. This is further complicated by Core Data requiring developers to use a Class (i.e., the NSManagedObject subclass entity) as the fetched object. Any thoughts? Thanks!

  • I understand you but I think next year (hope!) Core Data will be replaced by something new, modern, Swifty. @FetchedRequest has limitations and we can’t share in view hierarchy. I see it as a local view state for local data. e.g. Mail, Notes, Reminders, Recipes apps you can use an ObservableObject to sync & process data from remote and use @FetchedRequest to display that data. Maybe we need, in the system, an ObservableObject subclass object to store & handle db fetch request.

  • I wrote a small sample app "BudgetApp" using Core Data and MV/MVI pattern. You can find it here:

    https://github.com/azamsharp/BudgetApp

    I ended up creating the fetch request inside the Model (Core Data Model Classes) and that way I was able to reuse them.

Add a Comment

but if you have an app with many users, not all of them are on iOS 15. So you cant use task, async and await on i0S 14!

  • You can use await / async on iOS 13. When it was originally announced, Swift concurrency required at least iOS 15 but with Xcode 13.2 you can deploy to iOS 13.

  • yep, the concurrency stuff bundled inside the app for earlier versions. If it doesn't work, something messed up in a project dylib searching path. Because it is a separate dylib that should be loaded on an app start.

Add a Comment

Thanks for the thread. As a newcomer to SwiftUI, though, I'm not sure how this would actually map in practice. For example, if I had a setup like this:

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

It's the part that goes in those "..." that I'm having some trouble figuring out. I have experience with Android and I know how I'd go about it there: I'd have a Repository which queries the file system (and potentially caches the latest data), a view model which holds view-specific state (for example, maybe just the file items in a specific folder) and handles any sorting, filtering etc..., and a view which asks the view model to refresh the data on certain events, such as onResume. If async is needed, I'd just launch that in a viewModelScope inside the view model.

I'm not sure how to map this to SwiftUI with the model described in this post.

For example, if I did:

struct MyApp: App {
    @State var allItems = FileItem.all
    @State var favouriteItems = FileItem.favourites

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(allItems) 
                MyListView(favouriteItems) 
            }
        }
    }
}
struct MyListView {
    @Binding var items: [FileItem]

    // Imagine there's a list here with buttons, and I can toggle the favourite status by tapping on the button for that item.

So what exactly should be happening if I call FileItem.setAsFavourite? Should it be adding a copy of the struct to the favourites array?

Would it make more sense to have things like "isFavourite" be instance vars, and then a collection like "favourites" would be a computed property? If so, would favourites still be a @State or otherwise how would the second list know that the value of the computed property changed? Would @State var favouriteItems = FileItem.favourites still work in that case?

How could collection updates be propagated? Would that look something like this:


async func syncCollectionWithFs() {
  // ... query FS
   var collection = query()
   MainActor.run { 
        FileItem.all = collection 
        FileItem.favourites =// Assume I'd map against some favourites DB to see what was marked as a favourite.
    }
}

But then how does this actually get propagated to the @States? By assigning the contents as a @State, didn't I already make a copy and updating the struct static vars isn't necessarily going to be published? Is this where ObservableObject would make more sense? I'm curious what the use case of the static funcs & vars on the structs are and how they are intended to be used.

How would this change if we make the collections async sequences or the functions async?

I think I'd know how I'd solve these questions if I was doing something more similar to Android with ViewModels and ObservableObjects, so these might seem like really basic questions, but as I said I'm coming from another universe, and I'm curious. ;) Thank you.

Hi, first if you are making a framework / library (stateless in many cases) your FileItem is perfect.

Active Record Pattern

struct FileItem {
    var name: String
    var size: Int64

    static var all: [FileItem] = { ... }
    static var favourites: [FileItem] = { ... }

    func setAsFavourite(isFavourite: Bool) { ... }
}

Repository Pattern

struct FileItem {
    var name: String
    var size: Int64
}

class FileItemRepository {
    func getAll() -> [FileItem] { ... }
    func getFavourites() -> [FileItem] { ... }

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}

If you are making an App (stateful) you need a state. Think about single source of truth(s) and use ObservableObject for external state, you can have one or many state objects. All depends on your app needs but Keep It Simple. You can keep your FileItem with tasks or not, depends.

Example #1 (assuming favorite is a file attribute or a reference)

struct FileItem {
    var name: String
    var size: Int64

    func setAsFavourite(isFavourite: Bool) { ... }
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    var favourites: [FileItem] { … } // filter favourites from all

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files, manage states (loading, error) if needed
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.1 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var all: [FileItem] = []
    @Published var favourites: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files and favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    @StateObject var store = FileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(.all)
                MyListView(.favourites)
            }
            .environmentObject(store)
            .task { await store.load() }
        }
    }
}

Example #2.2 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []
    
    enum Source {
        case all
        case favourites
    }

    var source: Source

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    func load() async { … } // load all files or favourites files, manage states (loading, error) if needed

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(FileStore(.all))
                MyListView(FileStore(.favourites))
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = FileStore(.all)
    @StateObject var favouriteFileStore = FileStore(.favourites)

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Example #2.3 (assuming favorite is another file)

struct FileItem {
    var name: String
    var size: Int64
}
class FileStore: ObservableObject {
    @Published var files: [FileItem] = []

    var isLoading: Bool = false // if needed
    var error: Error? = nil // if needed

    open func load() async { … } // to subclass

    func setFile(_ file: FileItem, asFavourite: Bool) { ... }
}
class AllFileStore: FileStore {
    open func load() async { … } // load all files, manage states (loading, error) if needed
}
class FavouritesFileStore: FileStore {
    open func load() async { … } // load favourites files, manage states (loading, error) if needed
}
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView(AllFileStore())
                MyListView(FavouriteFileStore())
            }
        }
    }
}

…or…

struct MyApp: App {
    @StateObject var allFileStore = AllFileStore()
    @StateObject var favouriteFileStore = FavouriteFileStore()

    var body: some Scene {
        WindowGroup {
            NavigationView {
                MyListView()
                    .environmentObject(allFileStore)
                MyListView()
                    .environmentObject(favouriteFileStore)
            }
        }
    }
}

Tips:

  • Don’t think ViewModel, think state (view independent) that is part of your model
  • You can have one or many ObservableObjects (external state)
  • Use ObservableObjects when needed and don’t forget view local state
  • EnvironmentObject is your best friend!
  • Keep It Simple

In my 3 professional / client SwiftUI apps I made, I learned that EnvironmentObject is critical for many situations. Also, many things become problematic (or impossible) if I think about ViewModels.

  • Thanks very much for this, this helps a lot. In my example, I was considering that “Favourite” would be an attribute on the file itself, but not necessarily read from the file system (it could be coming from UserDefaults or somewhere else).

    Question 1: In your example 1, favourites is not a published var. Is it considered to be a computed property in this case? It's not published because any changes to the favourites would also change “all” which would then cause SwiftUI to recompose the scene?

  • Question 2: In the “active record” pattern, could a struct like “FileItem” ever have a reference to a Store (for example if we had an setAsFavourite method)? Or is this an anti-pattern? If it needs a reference to the store, we’d just put the method on the Store itself? Is active record meant to be used more with libraries and not apps? I'm just wondering how it would work putting methods on the struct when the struct might need references to other subsystems in the app.

  • var favourites is a computed property, in this case to reflect the changes you can put the (or an) setFavourite in the store and after attribute / reference change call objectWillChange.send(). You shouldn’t have a reference of store from struct. Yes active record, repository, … pattern are more for libs or an data access model layer of your app if needed. In declarative UI we need a state.

Add a Comment

thanks for sharing this! Lately I was also starting to notice that MVVM is kinda forced into SwiftUI and ends up making a lot of boilerplate

anyway I need time to digest all of this since I’m a huge advocate of MVVM in UIKit

in the meantime can I ask you the link for the Apple slides? (I ve probably missed this session)

PART 1

I've been looking at learning more and implementing the Active Record pattern, and had some more questions + some gotchas that I noticed.

If I understand the pattern correctly, Active Record is used for capturing global state via statics, and locals via functions. This state isn't tied to a specific view or workflow.

  1. To hold local view state, we need to use a @State or @StateObject. For example, this could be the results of a Product.all call.
  2. If the call is async, we need to also load it in a .task modifier.
  3. We also need some way of subscribing to updates or otherwise refreshing the data. For example, we could update the @State or @StateObject after calling an update function.

The problems I ran into mostly revolved around 3 above. Using the same example as before where we have files on a file system and we want to mark some of them as favourites:

struct File: Identifiable, Equatable, Hashable {
    var id: UUID = UUID()
    var name: String
    var date: Date
    var size: Int64
}

struct Folder: Identifiable {
    var id: UUID = UUID()
    var name: String
    var files: [File]
}

If I use an observable object to hold all of the state, I end up with this:

class FilesStore: ObservableObject {
    var all: [File] {
        return folders.flatMap { $0.files }
    }

    @Published var favorites: Set<File> = []

    @Published var folders: [Folder] = [
        Folder(name: "Classes", files: [
            File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
            File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
            File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
        ]),
        Folder(name: "Notes", files: [])
    ]

    func isFavorite(_ file: File) -> Bool {
        return favorites.contains(file)
    }

    func toggleFavorite(_ file: File) {
        if (favorites.contains(file)) {
            favorites.remove(file)
        } else {
            favorites.insert(file)
        }
    }
}

If I use these @Published vars directly in the view, then everything just "works" because all updates via @Published vars are propagated directly so everything stays in sync.

Here are the corresponding views:

struct ContentView: View {
    @StateObject var filesStore = FilesStore()
    
    var body: some View {
        NavigationView {
            FolderListView()
            FileListView(folderName: "All Files", files: filesStore.all)
        }
        .environmentObject(filesStore)
    }
}

struct FolderListView: View {
    @EnvironmentObject var filesStore: FilesStore
    
    var body: some View {
        let favorites = filesStore.favorites
        
        List {
            Section {
                FolderListRow(folderName: "All Files", files: filesStore.all)
                if (!favorites.isEmpty) {
                    FolderListRow(folderName: "Favorites", files: Array(favorites))
                }
            }
            
            Section("My folders") {
                ForEach(filesStore.folders) { folder in
                    FolderListRow(folderName: folder.name, files: folder.files)
                }
            }
        }
        .navigationTitle("Folders")
        .listStyle(.insetGrouped)
    }
}

struct FolderListRow: View {
    let folderName: String
    let files: [File]
        
    var body: some View {
        NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
            HStack {
                Text(folderName)
                Spacer()
                Text(files.count.formatted())
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct FileListView: View {
    @EnvironmentObject var filesStore: FilesStore
    
    let folderName: String
    let files: [File]
    
    var body: some View {
        List(files) { file in
            let isFavorite = filesStore.isFavorite(file)
            
            VStack() {
                HStack {
                    Text(file.name)
                    Spacer()
                    if isFavorite {
                        Image(systemName: "heart.fill")
                            .foregroundColor(.red)
                            .font(.caption2)
                    }
                }
            }
            .swipeActions(edge: .leading) {
                Button {
                    filesStore.toggleFavorite(file)
                } label: {
                    Image(systemName: isFavorite ? "heart.slash" : "heart")
                }
                .tint(isFavorite ? .gray : .red)
            }
        }
        .animation(.default, value: files)
        .listStyle(.plain)
        .navigationTitle(folderName)
    }
}

With the Active Record pattern, I remove FilesStore and reorganized the code as follows:

// Stores

class FilesystemStore {
    static var shared = FilesystemStore()
    
    var folders: [Folder] = [
        Folder(name: "Classes", files: [
            File(name: "File 5.txt", date: Date(timeIntervalSinceNow: -300000), size: 8234567),
            File(name: "File 6.txt", date: Date(timeIntervalSinceNow: -290000), size: 4890123),
            File(name: "File 7.txt", date: Date(timeIntervalSinceNow: -280000), size: 11234567),
        ]),
        Folder(name: "Notes", files: [])
    ]
}

class FavoritesStore {
    static var shared = FavoritesStore()
    
    var favorites: Set<File> = []
    
    func isFavorite(_ file: File) -> Bool {
        return favorites.contains(file)
    }
    
    func toggleFavorite(_ file: File) {
        if (favorites.contains(file)) {
            favorites.remove(file)
        } else {
            favorites.insert(file)
        }
    }
}

// Active record -- contents

extension Folder {
    static var all: [Folder] {
        return FilesystemStore.shared.folders
    }
}

extension File {
    static var all: [File] {
        return Folder.all.flatMap { $0.files }
    }
}

// Active record -- favorites

extension File {
    static var favorites: Set<File> {
        FavoritesStore.shared.favorites
    }
        
    static let favoriteUpdates = PassthroughSubject<Set<File>, Never>()
    
    func isFavorite() -> Bool {
        return FavoritesStore.shared.isFavorite(self)
    }
    
    func toggleFavorite() {
        FavoritesStore.shared.toggleFavorite(self)
        File.favoriteUpdates.send(File.favorites)
    }
}

The problem I ran into with this is that the view is now reaching directly into the model to do things like toggle if a file is a favorite or not. Because those properties are being set directly, we now need a way to update the view state to reflect the change. I handled that by using Combine to publish updates (I'm sure it's possible with AsyncStream too, like StoreKit 2 is doing, but I didn't figure out how to do this).

Continued in part 2 below...

PART 2

The second problem is that now the view also won't update unless I'm sure to also add a reference to the @StateObject to that view, so it knows that it needs to update. For example, when using the Active Record pattern, I can call file.isFavorite() instead of filesStore.isFavorite(file) to know if a file is a favorite or not. Because I'm calling the method directly on the file instead of going through the StateObject, I can bypass the @Published var and thus if I'm not careful, I'll miss updates since SwiftUI no longer knows to update this view.

Here is the view code when using Active Record:

struct ContentView: View {
    @StateObject var favoritesObject = MyFavoritesObject()
    var body: some View {
        NavigationView {
            FolderListView()
            FileListView(folderName: "All Files", files: File.all)
        }
        .environmentObject(favoritesObject)
    }
}

struct FolderListView: View {
    @EnvironmentObject var favoritesObject: MyFavoritesObject
    
    var body: some View {
        let favorites = favoritesObject.favorites
        
        List {
            Section {
                FolderListRow(folderName: "All Files", files: File.all)
                if (!favorites.isEmpty) {
                    FolderListRow(folderName: "Favorites", files: Array(favorites))
                }
            }
            
            Section("My folders") {
                ForEach(Folder.all) { folder in
                    FolderListRow(folderName: folder.name, files: folder.files)
                }
            }
        }
        .navigationTitle("Folders")
        .listStyle(.insetGrouped)
    }
}

struct FolderListRow: View {
    let folderName: String
    let files: [File]
        
    var body: some View {
        NavigationLink(destination: FileListView(folderName: folderName, files: files)) {
            HStack {
                Text(folderName)
                Spacer()
                Text(files.count.formatted())
                    .foregroundStyle(.secondary)
            }
        }
    }
}

struct FileListView: View {
    // Needed to get favorite updates
    @EnvironmentObject var favoritesObject: MyFavoritesObject
    
    let folderName: String
    let files: [File]
    
    var body: some View {
        List(files) { file in
            let isFavorite = file.isFavorite()
            
            VStack() {
                HStack {
                    Text(file.name)
                    Spacer()
                    if isFavorite {
                        Image(systemName: "heart.fill")
                            .foregroundColor(.red)
                            .font(.caption2)
                    }
                }
            }
            .swipeActions(edge: .leading) {
                Button {
                    file.toggleFavorite()
                } label: {
                    Image(systemName: isFavorite ? "heart.slash" : "heart")
                }
                .tint(isFavorite ? .gray : .red)
            }
        }
        .animation(.default, value: files)
        .listStyle(.plain)
        .navigationTitle(folderName)
    }
}

So the differences are basically that we can call methods like File.all or file.toggleFavorite() instead of passing through FilesStore. The problem is with the flow of data as mentioned above.

Even with the Combine mechanism, I also have to be sure that the view has a reference to the state object via @EnvironmentObject, otherwise it still won't update since it's reading file.isFavorite() directly.

Of course this example is somewhat contrived, and even when using the state objects, "real" production code would have to query the file system async and then update the published vars. The difference is that I can handle that internally and update the @Published vars as needed, while with Active Record, I need to be sure to have some mechanism to propagate those updates so that the view is still updated, with Combine or a similar mechanism.

Another alternative would be to call a load() method manually on the view or state object every time a mutating function is called, but that seems cumbersome.

I'm most likely missing something or not fully understanding the patterns, so hoping that you or someone else can illuminate this further. It just looks like from my POV, you still need an intermediate StateObject when using Active Record. I also find the code easier to understand and reason about when doing everything through a StateObject instead of also having functions and static collection vars.

This was a fun exercise to go through and I definitely learned something. Hoping to continue the discussions. :)

For an Application you can use active record for that. This works great for a lib, data access, data model.

struct File: Identifiable, Equatable, Hashable {
    var id: UUID = UUID()
    var name: String
    var date: Date
    var size: Int64
    var isFavourite: Bool // change attribute or reference on set / didSet

    func save()
    func delete()

    static var allFiles: [File] { … }
    static var onlyFavouriteFiles: [File] { … }
}

struct Folder: Identifiable {
    var id: UUID = UUID()
    var name: String
    var files: [File] // or computed property that fetch (on demand) files from this folder

    static var folders: [Folder] { … }
}

But in SwiftUI (declarative view layer) you can also need a state(s). You can have a FileStore, FolderStore, FileManagement, … that is part of your model. Assuming that we use system FileManager and you load the items sync when needed.

class FileManagement: ObservableObject {
    var folders: [Folder] = Folder.folders

    func createFile(…) { … } // do changes, call objectWillChange.send()
    func deleteFile(…) { … } // do changes, call objectWillChange.send()
    func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
struct MyApp: App {
    @StateObject var fileManagement = FileManagement()

    @State var selectedFolder: Folder? = nil
    @State var selectedFile: File? = nil

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList($selectedFolder)
            } content: {
                if let folder = selectedFolder {
                    FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
                } else {
                    Text(“Select a folder”)
                }
            } detail: {
                if let file = selectedFile {
                    FileView(file: file) // details, attributes, preview, …
                } else {
                    Text(“Select a file”)
                }
            }
            .environmentObject(fileManagement)
        }
    }
}

Note: For changes use FileManagment methods, not File / Folder methods (FileManagement will call them). Also you can just do the operations in FileManagement and remove File / Folder methods.

  • The file system management is not a great example for state / store. Typically we work in local (disk) and not in memory, we use the FileManager (and a “FileWatcher”) and work with URLs and Data + Attributes dictionary. But ok to have a struct representation of folder and file, or doing a virtual file system.

  • In this example, if I understand correctly, it would be better to not use Active Record or if we do, to make sure mutations happen through the StateObject and not by calling functions directly on the active record? That lines up with what I observed so seems to make sense!

Add a Comment
// Do the operations in disk
class FileManagement: ObservableObject {
    var folders: [Folder] = Folder.folders

    func createFile(…) { … } // do changes, call objectWillChange.send()
    func deleteFile(…) { … } // do changes, call objectWillChange.send()
    func setFileAsFavourite(…) { … } // do changes, call objectWillChange.send()
}
// Do the operations in memory
class FileManagement: ObservableObject { // or class FolderStore: ObservableObject {
    @Published var folders: [Folder] = []

    func loadFolders() { folders = Folder.folders } // or func load() { folders = Folder.folders }

    func createFile(…) { … } // change the folders property hierarchy
    func deleteFile(…) { … } // change the folders property hierarchy
    func setFileAsFavourite(…) { … } // change the folders property hierarchy
}

Also you can just use only the File and Folder active record with a FileWatcher (notify file system changes):

struct File: Identifiable, Equatable, Hashable {
    var id: URL { url }
    var url: URL
    var name: String
    var date: Date
    var size: Int64
    var isFavourite: Bool // change attribute or reference on set / didSet

    func save()
    func delete()

    static var allFiles: [File] { … }
    static var onlyFavouriteFiles: [File] { … }
}

struct Folder: Identifiable {
    var id: URL { url }
    var url: URL
    var name: String
    var files: [File] // or computed property that fetch (on demand) files from this folder

    static var folders: [Folder] { … }
}
struct MyApp: App {
    @StateObject var fileWatcher = FileWatcher() // Notify file system changes

    @State var selectedFolder: Folder? = nil
    @State var selectedFile: File? = nil

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList($selectedFolder, folders: Folder.folders)
            } content: {
                if let folder = selectedFolder {
                    FileList($selectedFile, files: folder.files) // or FileList($selectedFile, folder: folder)
                } else {
                    Text(“Select a folder”)
                }
            } detail: {
                if let file = selectedFile {
                    FileView(file: file) // details, attributes, preview, …
                } else {
                    Text(“Select a file”)
                }
            }
        }
    }
}
  • You can find on the Medium an article about that “Detecting changes to a folder in iOS using Swift” also using SwiftUI.

  • When you say call call objectWillChange.send(), is this replacing @Published? I.e: We call call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders?In Example 2, we're depending on the FileWatcher to invalidate the view right? I.e.l FileWatcher should be sending an objectWillChange.send(), so we get all the correct values inside the view body when all of the properties are re-queried?
  • objectWillChange.send() is the core of ObservableObject notification, @Published is an convenience and use it.

Add a Comment

Also, you can use FileManagement as the only SSOT. Note: File and Folder structs can be simple structs, not an active records, you do all the things in FileManagement.

class FileManagement: ObservableObject {
    var folders: [Folder] { Folder.folders }
    @Published var selectedFolder: Folder? = nil

    var files: [File] { selectedFolder?.files ?? [] }
    @Published var selectedFile: File? = nil

    func startMonitoringChanges() // call objectWillChange.send() on changes
    func stopMonitoringChanges()

    // Folder and file operations here if needed / wanted
}
struct MyApp: App {
    @StateObject var fileManagement = FileManagement()

    var body: some Scene {
        WindowGroup {
            // Three-column
            NavigationSplitView {
                FolderList()
            } content: {
                FileList()
            } detail: {
                FileView() // details, attributes, preview, …
            }
            .environmentObject(fileManagement)
            .onAppear(perform: fileManagement.startMonitoringChanges)
        }
    }
}

Remember: Don’t worry about “reloading” folders and files, SwiftUI will check the differences and only update what changes. This is the reason why we should use Identifiable protocol.

Demistify SwiftUI - WWDC 2021 Session

  • State -> model / business / use case(s) / functionality / life-cycle object, view independent. ViewModel -> middle layer, data / state / functionality for a specific view, view dependent.

  • Our example (FileManagement object) use case (view / platform independent): Client / user needs an app to view folders, view all files from a selected folder and view file details, also can change some file attributes, create / delete files. Yes you can split “FileManagement” (FileStore, FolderStore, local selects) if needed or makes sense. Making apps has never been so easy!

Add a Comment