ObservableBox - a SwiftUI pattern
I've got a quick SwiftUI data-flow pattern I've been using that I'd like to share. Say we want to show some models in our SwiftUI view that we've downloaded from the network/fetched from disk etc. and then want to edit them locally.
struct Person: Identifiable {
var id: UUID
var name: String
var age: Int
var isCool: Bool
}
We fetch the latest data, allow a user to edit some of the entries, then write it back to the network/disk etc.
import Combine
final class DataSource: ObservableObject {
private let source = PersonDataSource()
@Published var people = [Person]()
func fetch() async {
let people = await source.getPeople()
self.people = people
}
func save() async {
await source.save(people)
}
}
Our source of truth for the data is now in the DataSource
.
We can use this to show a list of all the people that we've fetched.
import SwiftUI
struct ListView: View {
@StateObject var dataSource = DataSource()
var body: some View {
List(dataSource.people) { person in
// ...pass `person` to a NavigationLink'd detail view etc.
}
.task {
await dataSource.fetch()
}
}
}
We've got an issue now though.
How are we going to make sure that the edits to our person
are going to propagate back to the data source (maintaining the single source of truth)?
And what if there are other views that need to know when the value changes right away?
This is where the ObservableBox
comes in.
import Combine
/// A box that reference semantics and an `ObservableObject` to a given model.
/// There will be a value changed event when the internal value changes.
public final class ObservableBox<T>: ObservableObject {
/// The value stored by this box.
/// On changes, an update will occur on the `objectWillChange` for the box.
@Published public var value: T
public init(_ value: T) {
self.value = value
}
}
extension ObservableBox: Identifiable where T: Identifiable {
public var id: some Hashable {
value.id
}
}
extension ObservableBox: Equatable where T: Equatable {
public static func == (lhs: ObservableBox, rhs: ObservableBox) -> Bool {
lhs.value == rhs.value
}
}
extension ObservableBox: Hashable where T: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(value)
}
}
It's a simple box around an internal value type that will publish whenever the value changes.
You may be familiar with the concept of boxing a value in Swift, it's a common practice when we want to add reference semantics to a value type (for example when using MVVM in UIKit for binding data values to view elements).
ObservableBox
is identical to a classic Box
you may be familiar with, except we're adding ObservableObject
as a conformance so it works well natively with Combine and, by extension, SwiftUI.
Why not use Binding?
Binding
is great as it provides read-write access to SwiftUI State
properties from anywhere, providing similar functionality to our ObservableBox
.
Although, this is a view-layer construct defined and owned by SwiftUI.
We're defining something that is decidedly more of a general model-layer construct to use in more places; we don't want to depend on SwiftUI if we use this construct in places other than SwiftUI views.
To help interoperate better with SwiftUI's bindings, let's add a simple extension helper. We'll use a compiler directive to make it clear that this extension depends on SwiftUI and will still compile even if SwiftUI is not available.
#if canImport(SwiftUI)
import SwiftUI
extension ObservableBox {
func binding() -> Binding<T> {
// note: no risk of reference cycle as the ObservableBox does not own
// the Binding object
Binding {
self.value
} set: { newValue in
self.value = newValue
}
}
}
#endif
Boxing the Models
Now, we want to store ObservableBox
objects in our data source instead.
final class DataSource: ObservableObject {
private let source = PersonDataSource()
@Published var people = [ObservableBox<Person>]()
func fetch() async {
let people = await source.getPeople()
self.people = people.map(ObservableBox.init)
}
}
Each element of people
is now a reference type, making it easier to update each element in-place from anywhere no matter what view or other object changes the value.
Any SwiftUI view holding an @ObservedObject
reference to the person will see it update in real-time if the value changes from anywhere.
Because ObservableBox
lives in model layer, it's usable across any other object, not limited to SwiftUI views!