Tristan Diependael
Tristan Diependael

Reputation: 623

How to conform an ObservableObject to the Codable protocols?

In SwiftUI beta 5, Apple introduced the @Published annotation. This annotation is currently blocking this class from conforming to the Codable protocols.

How can I conform to these protocols so I can encode and decode this class to JSON? You can ignore the image property for now.

class Meal: ObservableObject, Identifiable, Codable {

    enum CodingKeys: String, CodingKey {
        case id
        case name
        case ingredients
        case numberOfPeople
    }

    var id = Globals.generateRandomId()
    @Published var name: String = "" { didSet { isInputValid() } }
    @Published var image = Image("addImage")
    @Published var ingredients: [Ingredient] = [] { didSet { isInputValid() } }
    @Published var numberOfPeople: Int = 2
    @Published var validInput = false

    func isInputValid() {
        if name != "" && ingredients.count > 0 {
            validInput = true
        }
    }
}

Upvotes: 17

Views: 5438

Answers (4)

user652038
user652038

Reputation:

As for Decodable, we're all answering with the same thing here. Initialize the Published with a decoded Value.

extension Published: Decodable where Value: Decodable {
  public init(from decoder: Decoder) throws {
    self.init(initialValue: try .init(from: decoder))
  }
}

On to Encodable

Unlike your average property wrapper, Published does not employ wrappedValue. Instead, accessing a Published value triggers a static subscript, which allows it to call objectWillChange on the ObservableObject when set.

Behind the scenes, your Meal.validInput, for example, relies on this code:

Published[
  _enclosingInstance: self,
  wrapped: \.validInput,
  storage: \._validInput
]

_enclosingInstance is necessary for publishing changes, when set, but all it does for get is specify how to access the Published, using this:

_enclosingInstance[keyPath: storageKeyPath]

wrapped is useless for Published.

You always need to supply the subscript with a class instance, but this "_enclosingInstance" does not need to be an ObservableObject.

As such, you can store the Published via another object, and encode its stored value like this:

public extension Published {
  /// The stored value of a `Published`.
  /// - Note: Only useful when not having access to the enclosing class instance.
  var value: Value { Storage(self).value }

  private final class Storage {
    init(_ published: Published) {
      self.published = published
    }

    var value: Value {
      Published[
        _enclosingInstance: self,
        wrapped: \.never,
        storage: \.published
      ]
    }

    /// Will never be called, but is necessary to provide a `KeyPath<Value>` for the static subscript.
    private var never: Value {
      get { fatalError() }
      set { fatalError() }
    }

    /// "`var`" only because the static subscript requires a `WritableKeyPath`.
    /// It will never be mutated.
    private var published: Published<Value>
  }
}

extension Published: Encodable where Value: Encodable {
  public func encode(to encoder: Encoder) throws {
    try value.encode(to: encoder)
  }
}

Alternatively, you could use this for the entirety of the body of Storage. It's just not as clear about documenting how it works.

@Published private(set) var value: Value

init(_ published: Published) {
  _value = published
}

Storage will not keep a reference to the ObservableObject, so it's only suitable for capturing values—which is all Encodable needs. Why Apple has not provided us with a built-in solution after all this time, I have no idea.

Upvotes: 7

Steven Sorial
Steven Sorial

Reputation: 151

A more efficient variant without Mirror

Published+Value.swift

private class PublishedWrapper<T> {
    @Published private(set) var value: T

    init(_ value: Published<T>) {
        _value = value
    }
}

extension Published {
    var unofficialValue: Value {
        PublishedWrapper(self).value
    }
}

Published+Codable.swift

extension Published: Decodable where Value: Decodable {
    public init(from decoder: Decoder) throws {
        self.init(wrappedValue: try .init(from: decoder))
    }
}

extension Published: Encodable where Value: Encodable {
    public func encode(to encoder: Encoder) throws {
        try unofficialValue.encode(to: encoder)
    }
}

Upvotes: 7

Confused Vorlon
Confused Vorlon

Reputation: 10466

After much hacking around, I managed to add Codable directly to @Published

Note I had to update this for iOS14. This illustrates the danger of digging around in undocumented types...

Just add the code below in a file and your @Published variables will be automatically Codable (provided they are based on a Codable type)

more info here https://blog.hobbyistsoftware.com/2020/01/adding-codeable-to-published/

code here:

import Foundation
import SwiftUI

extension Published:Decodable where Value:Decodable {
    public init(from decoder: Decoder) throws {
        let decoded = try Value(from:decoder)
        self = Published(initialValue:decoded)
    }
}

 extension Published:Encodable where Value:Decodable {

    private var valueChild:Any? {
        let mirror = Mirror(reflecting: self)
        if let valueChild = mirror.descendant("value") {
            return valueChild
        }
        
        //iOS 14 does things differently...
        if let valueChild = mirror.descendant("storage","value") {
            return valueChild
        }
        
        //iOS 14 does this too...
        if let valueChild = mirror.descendant("storage","publisher","subject","currentValue") {
            return valueChild
        }

        return nil
    }
   
    public func encode(to encoder: Encoder) throws {
        
        guard let valueChild = valueChild else {
            fatalError("Mirror Mirror on the wall - why no value y'all : \(self)")
        }
        
        if let value = valueChild.value as? Encodable {
            do {
                try value.encode(to: encoder)
                return
            } catch let error {
                assertionFailure("Failed encoding: \(self) - \(error)")
            }
        }
        else {
            assertionFailure("Decodable Value not decodable. Odd \(self)")
        }
    }
}

Upvotes: 11

kontiki
kontiki

Reputation: 40639

Add the init() and encode() methods to your class:

required init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)

    id = try values.decode(Int.self, forKey: .id)
    name = try values.decode(String.self, forKey: .name)
    ingredients = try values.decode([Ingredient].self, forKey: .ingredients)
    numberOfPeople = try values.decode(Int.self, forKey: .numberOfPeople)
}

func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(id, forKey: .id)
    try container.encode(name, forKey: .name)
    try container.encode(ingredients, forKey: .ingredients)
    try container.encode(numberOfPeople, forKey: .numberOfPeople)
}

Upvotes: 15

Related Questions