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...
Topic:
UI Frameworks
SubTopic:
SwiftUI
Tags: