Search code examples
swiftcombine

Mimic Swift Combine @Published to create @PublishedAppStorage


I'm trying to mimic the Combine @Published property wrapper. My end goal is to create a new custom property wrapper (e.g. @PublishedAppStorage) of @Published with a nested @AppStorage. So I've started just by trying to mimic the @Published.

My problem that it crashes when accessing the original value from within the sink block with the error: Thread 1: Simultaneous accesses to 0x600000103328, but modification requires exclusive access

I've spent days trying to find a way.

Here is my custom @DMPublished:

@propertyWrapper
struct DMPublished<Value> {
    private let subject:CurrentValueSubject<Value, Never>
    
    init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue
        self.subject = CurrentValueSubject(wrappedValue)
    }
    
    var wrappedValue: Value {
        willSet {
            subject.send(newValue)
        }
    }

    var projectedValue: AnyPublisher<Value, Never> {
        subject.eraseToAnyPublisher()
    }
}

The ObservableObject defining my properties:

import Combine

public class DMDefaults: ObservableObject {
    
    static public let shared = DMDefaults()
    private init(){}
    
    @Published public var corePublishedString = "dd"
    @DMPublished public var customPublishedString = "DD"

}

And here is my test function:

public func testSink()
{
    let gdmDefaults = DMDefaults.shared
    gdmDefaults.corePublishedString = "ee"; gdmDefaults.customPublishedString = "EE"
    
    gdmDefaults.corePublishedString = "ff"; gdmDefaults.customPublishedString = "FF"

    let coreSub = gdmDefaults.$corePublishedString.sink { (newVal) in
        print("coreSub: oldVal=\(gdmDefaults.corePublishedString) ; newVal=\(newVal)")
    }
    let custSub = gdmDefaults.$customPublishedString.sink { (newVal) in
        print("custSub: oldVal=\(gdmDefaults.customPublishedString) ; newVal=\(newVal)") // **Crashing here**
    }
    
    gdmDefaults.corePublishedString = "gg"; gdmDefaults.customPublishedString = "GG"

}

Will appreciate any help here... thanks...


Solution

  • Thanks for the direction from David, I've managed to get it working. I'm posting here my final Property Wrapper in case anyone finds it helpful.

    import SwiftUI
    import Combine
    
    @propertyWrapper
    public struct PublishedAppStorage<Value> {
        
        // Based on: https://github.com/OpenCombine/OpenCombine/blob/master/Sources/OpenCombine/Published.swift
        
        @AppStorage
        private var storedValue: Value
        
        private var publisher: Publisher?
        internal var objectWillChange: ObservableObjectPublisher?
        
        /// A publisher for properties marked with the `@Published` attribute.
        public struct Publisher: Combine.Publisher {
            
            public typealias Output = Value
            
            public typealias Failure = Never
            
            public func receive<Downstream: Subscriber>(subscriber: Downstream)
            where Downstream.Input == Value, Downstream.Failure == Never
            {
                subject.subscribe(subscriber)
            }
            
            fileprivate let subject: Combine.CurrentValueSubject<Value, Never>
            
            fileprivate init(_ output: Output) {
                subject = .init(output)
            }
        }
    
        public var projectedValue: Publisher {
            mutating get {
                if let publisher = publisher {
                    return publisher
                }
                let publisher = Publisher(storedValue)
                self.publisher = publisher
                return publisher
            }
        }
        
        @available(*, unavailable, message: """
                   @Published is only available on properties of classes
                   """)
        public var wrappedValue: Value {
            get { fatalError() }
            set { fatalError() }
        }
        
        public static subscript<EnclosingSelf: ObservableObject>(
            _enclosingInstance object: EnclosingSelf,
            wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
            storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, PublishedAppStorage<Value>>
        ) -> Value {
            get {
                return object[keyPath: storageKeyPath].storedValue
            }
            set {
                // https://stackoverflow.com/a/59067605/14314783
                (object.objectWillChange as? ObservableObjectPublisher)?.send()
                object[keyPath: storageKeyPath].publisher?.subject.send(newValue)
                object[keyPath: storageKeyPath].storedValue = newValue
            }
        }
        
        // MARK: - Initializers
    
        // RawRepresentable
        init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) where Value : RawRepresentable, Value.RawValue == String {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
        
        // String
        init(wrappedValue: String, _ key: String, store: UserDefaults? = nil) where Value == String {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
    
        // Data
        init(wrappedValue: Data, _ key: String, store: UserDefaults? = nil) where Value == Data {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
        
        // Int
        init(wrappedValue: Int, _ key: String, store: UserDefaults? = nil) where Value == Int {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
        
        // URL
        init(wrappedValue: URL, _ key: String, store: UserDefaults? = nil) where Value == URL {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
        
        // Double
        init(wrappedValue: Double, _ key: String, store: UserDefaults? = nil) where Value == Double {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
    
        // Bool
        init(wrappedValue: Bool, _ key: String, store: UserDefaults? = nil) where Value == Bool {
            self._storedValue = AppStorage(wrappedValue: wrappedValue, key, store: store)
        }
    
    }
    

    And this how it is used:

    public class MyDefaults: ObservableObject
    {
        @PublishedAppStorage("useVerboseLog")
        public var useVerboseLog: Bool = true
    }