An alternative to @ObservedObject for SwiftUI
Why Use ObservedObject
@ObservedObject
(and its partner
ObservableObject
)
is used in SwiftUI to allow us to encapsulate more significant logic
than the simple state changes that @State
is built for. In essence,
@State
is great if you’re changing a single value from something
happening in the UI, but if you have an object that is changing or
more complex logic, using a class that implements ObservableObject
is the way to go.
With ObservableObject
, you need to publish (@Published
annotation
on) at least one property which will create a combine publisher in
your object that your view will subscribe to. (@ObservedObject
is
what wires this together, but you could do it directly with
.onRecieve(yourObject.objectWillChange)
in your view.)
This works great if there’s something in your class that you want to directly surface to the view and cause an update on that view whenever it changes.
The Problem
However, if you don’t have something that your SwiftUI view is
directly relying on, you don’t have a property to put @Published
onto.
I ran into this issue with a class that was collecting some information from a wrapped UIKit view, but my SwiftUI view only cared about a count. My model looked like
import Foundation
class SelectedThings {
private struct ThingWithViewInfo {
let thing: Thing
let indexPath: IndexPath
}
private var myThings: Set<ThingWithviewinfo> = Set()
public func addThing(_ thing: Thing, at indexPath: IndexPath) {
myThings.insert(ThingWithViewInfo(thing: thing, indexPath: indexPath))
}
public var things : [Thing] { myThings.map(\.thing) }
}
(storing an indexpath like this doesn’t seem to be a great idea, but that’s for another day.)
and then my view that just wants to show the count to the user:
import SwiftUI
struct Content: View {
let selectedThings: SelectedThings
var body: some View {
Text("\(selectedThings.things.count) things selected")
}
}
My initial reaction was to only pass the count to the view and then
mark things
with @Published
in my class. However, you can’t
publish a computed property, and because my struct was private and I
saw no great reason to expose it, publishing myThings
was also not
an option.
Manual Combine to The Rescue
I mentioned above that under the hood, @Published
and
@ObservedObject
are wiring up a Combine publisher and subscriber to
trigger the view to recalculate. Setting up a publisher and subscriber
is something I’d done before, and I realized that I already knew a way
to force the view to recalculate: by updating a @State
variable.
Putting those together, I added a publisher to my class, and a new
@State
variable and onRecieve
call to my view. When my private
variable is updated, we cause the publisher to emit a value which the
onRecieve
gets and uses to update the state of the view, causing our
view to update itself.
The class now looks like
import Foundation
class SelectedThings {
private struct ThingWithViewInfo {
let thing: Thing
let indexPath: IndexPath
}
private var myThings: Set<ThingWithviewinfo> = Set()
public var thingsChangedPublisher: PassthroughSubject<Void, Never> = PassthroughSubject()
public func addThing(_ thing: Thing, at indexPath: IndexPath) {
myThings.insert(ThingWithViewInfo(thing: thing, indexPath: indexPath))
thingsChangedPublisher.send()
}
public var things : [Thing] { myThings.map(\.thing) }
}
and the view:
import SwiftUI
struct Content: View {
let selectedThings: SelectedThings
@State private var thingsCount = 0
var body: some View {
Text("\(thingsCount) things selected")
.onRecieve(selectedThings.thingsChangedPublisher) {
self.thingsCount = self.selectedThings.things.count
}
}
}