ObservableBox - a SwiftUI pattern

📆 Published 2022-03-06 ⏰ 3m read

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!