I feel like I can sort of understand why what I'm doing isn't working but I'm still trying to wrap my head around Combine and SwiftUI so any help here would be welcome.
Consider this example:
Single view app that stores some strings in UserDefaults, and uses those strings to display some Text labels. There are three buttons, one to update the title, and one each to update the two UserDefaults-stored strings to a random string.
The view is a dumb renderer view and the title string is stored directly in an ObservableObject view model. The view model has a published property that holds a reference to a UserSettings class that implements property wrappers to store the user defined strings to UserDefaults.
Observations:
• Tapping "Set A New Title" correctly updates the view to show the new value
• Tapping either of the "Set User Value" buttons does change the value internally, however the view does not refresh. If "Set A New Title" is tapped after one of these buttons, the new values are shown when the view body rebuilds for the title change.
View:
import SwiftUI
struct ContentView: View {
@ObservedObject var model = ViewModel()
var body: some View {
VStack {
Text(model.title).font(.largeTitle)
Form {
Section {
Text(model.settings.UserValue1)
Text(model.settings.UserValue2)
}
Section {
Button(action: {
self.model.title = "Updated Title"
}) { Text("Set A New Title") }
Button(action: {
self.model.settings.UserValue1 = "\(Int.random(in: 1...100))"
}) { Text("Set User Value 1 to Random Integer") }
Button(action: {
self.model.settings.UserValue2 = "\(Int.random(in: 1...100))"
}) { Text("Set User Value 2 to Random Integer") }
}
Section {
Button(action: {
self.model.settings.UserValue1 = "Initial Value One"
self.model.settings.UserValue2 = "Initial Value Two"
self.model.title = "Initial Title"
}) { Text("Reset All") }
}
}
}
}
}
ViewModel:
import Combine
class ViewModel: ObservableObject {
@Published var title = "Initial Title"
@Published var settings = UserSettings()
}
UserSettings model:
import Foundation
import Combine
@propertyWrapper struct DefaultsWritable<T> {
let key: String
let value: T
init(key: String, initialValue: T) {
self.key = key
self.value = initialValue
}
var wrappedValue: T {
get { return UserDefaults.standard.object(forKey: key) as? T ?? value }
set { return UserDefaults.standard.set(newValue, forKey: key) }
}
}
final class UserSettings: NSObject, ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
@DefaultsWritable(key: "UserValue", initialValue: "Initial Value One") var UserValue1: String {
willSet {
objectWillChange.send()
}
}
@DefaultsWritable(key: "UserBeacon2", initialValue: "Initial Value Two") var UserValue2: String {
willSet {
objectWillChange.send()
}
}
}
When I put a breakpoint on willSet { objectWillChange.send() }
in UserSettings I see that the objectWillChange message is going to the publisher when I would expect it to so that tells me that the issue is likely that the view or the view model is not properly subscribing to it. I know that if I had UserSettings as an @ObservedObject
on the view this would work, but I feel like this should be done in the view model with Combine.
What am I missing here? I'm sure it's really obvious...