Search code examples
swiftswiftuicombineuserdefaults

Fatal error when publishing UserDefaults with Combine


I try to observe my array of custom objects in my UserDefaults using a Combine publisher.

First my extension:

extension UserDefaults {
    
    var ratedProducts: [Product] {
        get {
            guard let data = UserDefaults.standard.data(forKey: "ratedProducts") else { return [] }
            return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
        }
        set {
            UserDefaults.standard.set(try? PropertyListEncoder().encode(newValue), forKey: "ratedProducts")
        }
    }
}

Then in my View model, within my init() I do:

UserDefaults.standard
                    .publisher(for: \.ratedProducts)
                    .sink { ratedProducts in
                        self.ratedProducts = ratedProducts
                    }
                    .store(in: &subscriptions)

You can see that I basically want to update my @Published property ratedProducts in the sink call.

Now when I run it, I get:

Fatal error: Could not extract a String from KeyPath Swift.ReferenceWritableKeyPath<__C.NSUserDefaults, Swift.Array<RebuyImageRating.Product>>

I think I know that this is because in my extension the ratedProduct property is not marked as @objc, but I cant mark it as such because I need to store a custom type.

Anyone know what to do?

Thanks


Solution

  • As you found out you can not observe your custom types directly, but you could add a possibility to observe the data change and decode that data to your custom type in your View model:

    extension UserDefaults{
        // Make it private(set) so you cannot use this from the outside and set arbitary data by accident
        @objc dynamic private(set) var observableRatedProductsData: Data? {
                get {
                    UserDefaults.standard.data(forKey: "ratedProducts")
                }
                set { UserDefaults.standard.set(newValue, forKey: "ratedProducts") }
            }
        
        var ratedProducts: [Product]{
            get{
                guard let data = UserDefaults.standard.data(forKey: "ratedProducts") else { return [] }
                return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
            } set{
                // set the custom objects array through your observable data property.
                observableRatedProductsData = try? PropertyListEncoder().encode(newValue)
            }
        }
    }
    

    and the observer in your init:

    UserDefaults.standard.publisher(for: \.observableRatedProductsData)
                .map{ data -> [Product] in
                    // check data and decode it
                    guard let data = data else { return [] }
                    return (try? PropertyListDecoder().decode([Product].self, from: data)) ?? []
                }
                .receive(on: RunLoop.main) // Needed if changes come from background
                .assign(to: &$ratedProducts) // assign it directly