Post

Replies

Boosts

Views

Activity

Reply to Stop using MVVM for SwiftUI
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. 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. If the call is async, we need to also load it in a .task modifier. 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...
Sep ’22
Reply to Stop using MVVM for SwiftUI
Thanks very much for these posts. I'll collect all my questions here since the comments are limited: In your first example, would it 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? In the second example, When you say call objectWillChange.send(), is this replacing @Published? I.e: We call objectWillChange.send(), then SwiftUI will redraw the view, and automatically get the new contents when it re-reads Folder.folders? In the second part of that same post, we're depending on the FileWatcher to invalidate the view right? I.e. 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? If we're not planning on re-using components but are developing everything in the same app via SwiftUI, does Active Record have any advantages, or is it better to handle everything through StateObjects? Does Active Record make more sense when developing a "general" component outside of the SwiftUI / combine framework then? I.e. like how StoreKit2 was designed? Finally, when implementing Active Record, since we're using static vars, does it make sense to call into singletons the way I did it, via the shared static vars? Or otherwise how is this generally done? How does the Active Record actually access the objects that it needs to via those static vars? If you want to swap implementations, for testing purposes or maybe because you actually need different implementations at runtime, how is this usually handled? SwiftUI is really interesting and I'm finding it much easier to develop out new features. Learning about these patterns is super helpful, so thanks for taking the time to engage!
Sep ’22
Reply to Stop using MVVM for SwiftUI
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. :)
Sep ’22
Reply to Stop using MVVM for SwiftUI
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.
Sep ’22