This article will introduce the communication mechanism between @Published and instances of classes that conform to the ObservableObject protocol, and demonstrate how to add the ability to access the properties or methods of the wrapped class instance for other custom property wrapper types through three examples: @MyPublished (a replica of @Published), @PublishedObject (a version of @Published that wraps reference types), and @CloudStorage (similar to @AppStorage, but for NSUbiquitousKeyValueStore).
What is the ability of @Published
@Published is the most commonly used property wrapper in the Combine framework. Properties marked with @Published will notify their subscribers (provided through $
or projectedValue
Publisher) of the upcoming changes when the property is modified.
Don’t be confused by the “ed” suffix in its name, it actually publishes the change before it occurs (willSet).
class Weather {
@Published var temperature: Double
init(temperature: Double) {
self.temperature = temperature
}
}
let weather = Weather(temperature: 20)
let cancellable = weather.$temperature
.sink() {
print ("Temperature now: \($0)")
}
weather.temperature = 25
// Temperature now: 20.0
// Temperature now: 25.0
In classes that conform to the ObservableObject protocol, properties marked with @Published will not only notify subscribers of their own Publisher when they change, but also notify subscribers of the enclosing class instance’s objectWillChange. This feature makes @Published one of the most useful property wrappers in SwiftUI.
class Weather:ObservableObject { // 遵循 ObservableObject
@Published var temperature: Double
init(temperature: Double) {
self.temperature = temperature
}
}
let weather = Weather(temperature: 20)
let cancellable = weather.objectWillChange // 订阅 weather 实例的 obejctWillChange
.sink() { _ in
print ("weather will change")
}
weather.temperature = 25
// weather will change
Speaking solely based on the timing of calling objectWillChange wrapped by the package, the following code behaves the same as the previous code, but in the @Published version, we did not provide an instance wrapped by the @Published, it is implicitly obtained.
class Weather:ObservableObject {
var temperature: Double{ // Not marked with @Published
willSet { // Calls objectWillChange on the class instance before changing
self.objectWillChange.send() // Explicitly references the Weahter instance in code
}
}
init(temperature: Double) {
self.temperature = temperature
}
}
let weather = Weather(temperature: 20)
let cancellable = weather.objectWillChange // Subscribes the weather instance
.sink() { _ in
print ("weather will change")
}
weather.temperature = 25
// weather will change
For a long time, I took for granted the behavior of calling the instance method objectWillChange wrapped by the @Published package, and never seriously thought about how it was implemented. It wasn’t until I discovered that, in addition to @Published, @AppStorage also has the same behavior (see Mastering @AppStorage in SwiftUI) that I realized that perhaps we can make other property wrappers have similar behavior, creating more use cases.
The capability added to other property wrappers in this article, similar to @Published, refers to the ability to access properties or methods of the class instance wrapped by the property wrapper without explicitly setting it.
The Secret of @Published Capability
Finding Answers from Proposals
I wasn’t used to reading proposals from swift-evolution before because excellent bloggers like Paul Hudson would extract and organize new language features in a quick and easy-to-read way whenever Swift released a new feature. However, adding, modifying, or removing a language feature is actually a lengthy process that requires continuous discussion and modification of proposals. Proposals summarize this process into documents for every developer to read and analyze. Therefore, if you want to know the ins and outs of a certain Swift feature in detail, it’s best to carefully read its corresponding proposal document.
In the document about Property Wrappers, there is a special mention of how to refer to the enclosing class instance in a wrapper type - Referencing the enclosing ‘self’ in a wrapper type.
The proposer suggests: by allowing the property wrapper type to provide a static subscript method, automatic acquisition of the enclosing class instance that it wraps can be achieved (without explicit setting).。
// 提案建议的下标方法
public static subscript<OuterSelf>(
instanceSelf: OuterSelf,
wrapped: ReferenceWritableKeyPath<OuterSelf, Value>,
storage: ReferenceWritableKeyPath<OuterSelf, Self>) -> Value
Although the proposal’s Future Directions section mentions this approach, Swift has already included support for it. However, the code in the documentation is not entirely consistent with Swift’s current implementation. Fortunately, someone on stackoverflow provided the correct parameter names for this subscript method:
public static subscript<OuterSelf>(
_enclosingInstance: OuterSelf, // The correct parameter name is _enclosingInstance
wrapped: ReferenceWritableKeyPath<OuterSelf, Value>,
storage: ReferenceWritableKeyPath<OuterSelf, Self>
) -> Value
@Published gains its “special” ability by implementing this subscript method.
How Property Wrappers Work
Considering the numerous variants of the wrapped value in property wrappers, the Swift community has not adopted a standard Swift protocol approach to define the functionality of property wrappers. Instead, developers can customize the property wrapper types by declaring the @propertyWrapper attribute. Similar to the @resultBuilder introduced in the “Mastering Result Builders” article, the compiler first translates the code for user-defined property wrapper types before the final compilation.
struct Demo {
@State var name = "fat"
}
The compiler translates the code above into:
struct Demo {
private var _name = State(wrappedValue: "fat")
var name: String {
get { _name.wrappedValue }
set { _name.wrappedValue = newValue }
}
}
It can be seen that propertyWrapper is not particularly magical, it is just a syntax sugar. The above code also explains why after using property wrappers, it is not possible to declare variables with the same name (prefixed with an underscore).
// It is not possible to declare variables with the same name (prefixed with an underscore) after using property wrappers.
struct Demo {
@State var name = "fat"
var _name:String = "ds" // invalid redeclaration of synthesized property '_name'
}
// '_name' synthesized for property wrapper backing storage
When the property wrapper only provides wrappedValue (like State in the above code), the translated getter and setter will directly use wrappedValue. However, once the property wrapper implements the static subscript method introduced earlier, the translated code will be as follows:
class Test:ObservableObject{
@Published var name = "fat"
}
// Translated to
class Test:ObservableObject{
private var _name = Published(wrappedValue: "fat")
var name:String {
get {
Published[_enclosingInstance: self,
wrapped: \Test.name,
storage: \Test._name]
}
set {
Published[_enclosingInstance: self,
wrapped: \Test.name,
storage: \Test._name] = newValue
}
}
}
When the property wrapper implements the static subscript method and is wrapped by a class, the compiler will use the static subscript method to implement the getter and setter.
The three parameters of the subscript method are:
-
_enclosingInstance
The instance of the class that wraps the current property wrapper
-
wrapped
The KeyPath for the externally calculated property (corresponding to the name KeyPath in the above code)
-
storage
The KeyPath for the internally stored property (corresponding to the _name KeyPath in the above code)
In practical use, we only need to use _enclosingInstance and storage. Although the subscript method provides the wrapped parameter, we currently cannot call it. Reading and writing this value will cause the application to lock up.
From the above introduction, we can draw the following conclusions:
- The “special” ability of @Published is not unique to it and is not related to specific property wrapper types.
- Any property wrapper type that implements this static subscript method can possess the so-called “special” ability discussed in this article.
- Since the subscript parameters wrapped and storage are of type ReferenceWritableKeyPath, the compiler will only translate them into subscript versions of the getter and setter when the property wrapper type is wrapped by a class.
The sample code for this article can be found here.
Learning from Imitation - Creating @MyPublished
Practice is the only criterion for testing truth. In this section, we will replicate @Published to verify the content mentioned earlier.
Since the code is simple, only the following points are made as prompts:
- The type of projectedValue of @Published is
Published.Publisher<Value,Never>
- By wrapping CurrentValueSubject, a custom Publisher can be easily created
- Calling objectWillChange of the wrapper class instance and sending information to the subscriber of projectedValue should be done before changing wrappedValue
@propertyWrapper
public struct MyPublished<Value> {
public var wrappedValue: Value {
willSet { // Before modifying wrappedValue
publisher.subject.send(newValue)
}
}
public var projectedValue: Publisher {
publisher
}
private var publisher: Publisher
public struct Publisher: Combine.Publisher {
public typealias Output = Value
public typealias Failure = Never
var subject: CurrentValueSubject<Value, Never> // PassthroughSubject will lack the call of initial assignment
public func receive<S>(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input {
subject.subscribe(subscriber)
}
init(_ output: Output) {
subject = .init(output)
}
}
public init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
publisher = Publisher(wrappedValue)
}
public static subscript<OuterSelf: ObservableObject>(
_enclosingInstance observed: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> Value {
get {
observed[keyPath: storageKeyPath].wrappedValue
}
set {
if let subject = observed.objectWillChange as? ObservableObjectPublisher {
subject.send() // Before modifying wrappedValue
observed[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
}
Now, @MyPublished has exactly the same functionality and behavior as @Published:
class T: ObservableObject {
@MyPublished var name = "fat" // Replacing MyPublished with Published will yield the same result
init() {}
}
let object = T()
let c1 = object.objectWillChange.sink(receiveValue: {
print("object will changed")
})
let c2 = object.$name.sink{
print("name will get new value \($0)")
}
object.name = "bob"
// name will get new value fat
// object will changed
// name will get new value bob
In the following text, we will demonstrate how to apply this ability to other property wrapper types.
@PublishedObject —— The reference type version of @Published
@Published can only be used to wrap value types. When wrappedValue is a reference type, changing the properties of the wrapped value will not notify the subscribers. For example, in the following code, we will not receive any notifications:
class RefObject {
var count = 0
init() {}
}
class Test: ObservableObject {
@Published var ref = RefObject()
}
let test = Test()
let cancellable = test.objectWillChange.sink{ print("object will change")}
test.ref.count = 100
// no notification will be sent
To solve this problem, we can implement a version of @Published that works with reference types —— @PublishedObject.
Note:
- The wrappedValue of @PublishedObject is a reference type that conforms to the ObservableObject protocol.
- In the property wrapper, objectWillChange is subscribed to wrappedValue, and the specified closure is called whenever wrappedValue changes.
- After the property wrapper is created, the system will immediately call the getter of the static subscript once, and choose to subscribe to wrappedValue and set up the closure at this time.
@propertyWrapper
public struct PublishedObject<Value: ObservableObject> { // wrappedValue requires to conform to ObservableObject
public var wrappedValue: Value
public init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
public static subscript<OuterSelf: ObservableObject>(
_enclosingInstance observed: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> Value where OuterSelf.ObjectWillChangePublisher == ObservableObjectPublisher {
get {
if observed[keyPath: storageKeyPath].cancellable == nil {
// This is executed only once.
observed[keyPath: storageKeyPath].setup(observed)
}
return observed[keyPath: storageKeyPath].wrappedValue
}
set {
observed.objectWillChange.send() // willSet
observed[keyPath: storageKeyPath].wrappedValue = newValue
}
}
private var cancellable: AnyCancellable?
// Subscribe to objectWillChange of wrappedvalue.
// When wrappedValue sends a notification, call the _enclosingInstance's objectWillChange.send().
// Use a closure to weakly reference _enclosingInstance.
private mutating func setup<OuterSelf: ObservableObject>(_ enclosingInstance: OuterSelf) where OuterSelf.ObjectWillChangePublisher == ObservableObjectPublisher {
cancellable = wrappedValue.objectWillChange.sink(receiveValue: { [weak enclosingInstance] _ in
(enclosingInstance?.objectWillChange)?.send()
})
}
}
@PublishedObject provides us with more flexible capabilities to drive SwiftUI views. For example, we can use @PublishedObject like this:
@objc(Event)
public class Event: NSManagedObject {
@NSManaged public var timestamp: Date?
}
class Store: ObservableObject {
@PublishedObject var event = Event(context: container.viewContext)
init() {
event.timestamp = Date().addingTimeInterval(-1000)
}
}
struct DemoView: View {
@StateObject var store = Store()
var body: some View {
VStack {
Text(store.event.timestamp, format: .dateTime)
Button("Now") {
store.event.timestamp = .now
}
}
.frame(width: 300, height: 300)
}
}
@CloudStorage —— CloudKit version of @AppStorage
In the article Mastering @AppStorage in SwiftUI, I introduced that besides @Published, @AppStorage also has the ability to reference a wrapped class instance. Therefore, we can use the following code to manage UserDefaults uniformly in SwiftUI:
class Defaults: ObservableObject {
@AppStorage("name") public var name = "fatbobman"
@AppStorage("age") public var age = 12
}
Tom Lokhorst has written a third-party library similar to @AppStorage called @CloudStorage, which enables updates to SwiftUI views when changes occur in NSUbiquitousKeyValueStore:
struct DemoView: View {
@CloudStorage("readyForAction") var readyForAction: Bool = false
@CloudStorage("numberOfItems") var numberOfItems: Int = 0
var body: some View {
Form {
Toggle("Ready", isOn: $readyForAction)
.toggleStyle(.switch)
TextField("numberOfItems",value: $numberOfItems,format: .number)
}
.frame(width: 400, height: 400)
}
}
We can use the method introduced in this article to add capabilities similar to @Published.
Code highlights:
- Since the work of setting projectValue and _setValue is done in the CloudStorage constructor, the closure sender, which is nil at this time, can only be captured by creating a class instance holder to hold the closure, so that sender can be assigned a value using the subscript method.
- Note that the timing of calling
holder?.sender?()
should be consistent with the willSet behavior.
@propertyWrapper public struct CloudStorage<Value>: DynamicProperty {
private let _setValue: (Value) -> Void
@ObservedObject private var backingObject: CloudStorageBackingObject<Value>
public var projectedValue: Binding<Value>
public var wrappedValue: Value {
get { backingObject.value }
nonmutating set { _setValue(newValue) }
}
public init(keyName key: String, syncGet: @escaping () -> Value, syncSet: @escaping (Value) -> Void) {
let value = syncGet()
let backing = CloudStorageBackingObject(value: value)
self.backingObject = backing
self.projectedValue = Binding(
get: { backing.value },
set: { [weak holder] newValue in
backing.value = newValue
holder?.sender?() // Note the timing of the call
syncSet(newValue)
sync.synchronize()
})
self._setValue = { [weak holder] (newValue: Value) in
backing.value = newValue
holder?.sender?()
syncSet(newValue)
sync.synchronize()
}
sync.setObserver(for: key) { [weak backing] in
backing?.value = syncGet()
}
}
// Because the work of setting projectValue and _setValue is done in the constructor, and the closure sender (which was nil at the time) cannot be captured alone, create a class instance to hold the closure so that it can be configured using the subscript method.
class Holder{
var sender: (() -> Void)?
init(){}
}
var holder = Holder()
public static subscript<OuterSelf: ObservableObject>(
_enclosingInstance observed: OuterSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<OuterSelf, Value>,
storage storageKeyPath: ReferenceWritableKeyPath<OuterSelf, Self>
) -> Value {
get {
// The timing and logic of setting holder is consistent with @PublishedObject.
if observed[keyPath: storageKeyPath].holder.sender == nil {
observed[keyPath: storageKeyPath].holder.sender = { [weak observed] in
(observed?.objectWillChange as? ObservableObjectPublisher)?.send()
}
}
return observed[keyPath: storageKeyPath].wrappedValue
}
set {
if let subject = observed.objectWillChange as? ObservableObjectPublisher {
subject.send()
observed[keyPath: storageKeyPath].wrappedValue = newValue
}
}
}
}
Using the modified code, @AppStorage and @CloudStorage can be managed uniformly for easy use in SwiftUI views:
class Settings:ObservableObject {
@AppStorage("name") var name = "fat"
@AppStorage("age") var age = 5
@CloudStorage("readyForAction") var readyForAction = false
@CloudStorage("speed") var speed: Double = 0
}
struct DemoView: View {
@StateObject var settings = Settings()
var body: some View {
Form {
TextField("Name", text: $settings.name)
TextField("Age", value: $settings.age, format: .number)
Toggle("Ready", isOn: $settings.readyForAction)
.toggleStyle(.switch)
TextField("Speed", value: $settings.speed, format: .number)
Text("Name: \(settings.name)")
Text("Speed: ") + Text(settings.speed, format: .number)
Text("ReadyForAction: ") + Text(settings.readyForAction ? "True" : "False")
}
.frame(width: 400, height: 400)
}
}
Summary
Many things are often seen as black magic when we don’t understand them. But once we break through the magic barrier, we may find that they are not as mysterious as we thought.