nicksarno
nicksarno

Reputation: 4245

Store custom data type in @AppStorage with optional initializer?

I'm trying to store a custom data type into AppStorage. To do so, the model conforms to RawRepresentable (followed this tutorial). It's working fine, but when I initialize the @AppStorage variable, it requires an initial UserModel value. I want to make the variable optional, so it can be nil if the user is signed out. Is this possible?

Within a class / view, I can init like this:

@AppStorage("user_model") private(set) var user: UserModel = UserModel(id: "", name: "", email: "")

But I want to init like this:

@AppStorage("user_model") private(set) var user: UserModel?

Model:

struct UserModel: Codable {
        
    let id: String
    let name: String
    let email: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
    }
    
    init(from decoder: Decoder) throws {        
        let values = try decoder.container(keyedBy: CodingKeys.self)
        
        do {
           id = try String(values.decode(Int.self, forKey: .id))
        } catch DecodingError.typeMismatch {
           id = try String(values.decode(String.self, forKey: .id))
        }
        self.name = try values.decode(String.self, forKey: .name)
        self.email = try values.decode(String.self, forKey: .email)
    }
    
    init(id: String, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
    
}

// MARK: RAW REPRESENTABLE

extension UserModel: RawRepresentable {
    
    // RawRepresentable allows a UserModel to be store in AppStorage directly.
    
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
            let result = try? JSONDecoder().decode(UserModel.self, from: data)
        else {
            return nil
        }
        self = result
    }

    var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
            let result = String(data: data, encoding: .utf8)
        else {
            return "[]"
        }
        return result
    }
    
    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(email, forKey: .email)
    }
    
}

Upvotes: 12

Views: 5210

Answers (3)

Rob Napier
Rob Napier

Reputation: 299345

Both of the answers here are somewhat dangerous. They conform a type the author does not control (Optional) to a protocol (RawRepresentable) that the author does not control. This is a cause of very difficult to solve bugs. There can only be one conformance of a type to a protocol in the whole system. If any other part of the system (for example, a package you import or something internal to Apple) also creates this conformance, the system will no longer build, or worse, could lead to undefined behavior that the compiler won't catch. See Redundant Conformance for an example of how this fails in confusing ways.

First, note that AppStorage (which is just UserDefaults) is not meant to store large, complex data. It is generally meant to be a convenient plact to store relatively small bits of data. Even so, it is helpful to be able to store custom types there. A general solution is to split the property from its storage:

@AppStorage("user_model") private var userStorage: Data = Data()
private(set) var user: UserModel {
    get {
        (try? JSONDecoder().decode(UserModel.self, from: userStorage))
        ?? UserModel(id: "", name: "", email: "")
    }
    set { userStorage = (try? JSONEncoder().encode(newValue)) ?? Data() }
}

You may find it useful to create your own protocol to make the code easier to follow:

protocol AppStorable: Codable {
    // So there's a default value when nothing is in app storage
    init()
}

extension AppStorable {
    static func readFromAppStorage(_ data: AppStorage<Data>) -> Self {
        (try? JSONDecoder().decode(Self.self, from: data.wrappedValue)) ?? Self.init()
    }
    static func writeToAppStorage(_ data: AppStorage<Data>, newValue: Self) {
        data.wrappedValue = (try? JSONEncoder().encode(newValue)) ?? Data()
    }
}
...

    @AppStorage("user_model") private var userStorage: Data = Data()
    private(set) var user: UserModel {
        get { UserModel.readFromAppStorage(_userStorage) }
        set { UserModel.writeToAppStorage(_userStorage, newValue: newValue) }
    }

This approach doesn't run the risk of collisions with other code trying to do the same thing.

Upvotes: 3

markiv
markiv

Reputation: 1664

A possible generic approach for any optional Codable:

extension Optional: RawRepresentable where Wrapped: Codable {
    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self) else {
            return "{}"
        }
        return String(decoding: data, as: UTF8.self)
    }

    public init?(rawValue: String) {
        guard let value = try? JSONDecoder().decode(Self.self, from: Data(rawValue.utf8)) else {
            return nil
        }
        self = value
    }
}

With that in place, any Codable can now be persisted in app storage:

@AppStorage("user_model") var user: UserModel? = nil

Upvotes: 12

pawello2222
pawello2222

Reputation: 54516

The code below works because you added a conformance UserModel: RawRepresentable:

@AppStorage("user_model") private(set) var user: UserModel = UserModel(id: "", name: "", email: "")

You need to do the same for UserModel? if you want the following to work:

@AppStorage("user_model") private(set) var user: UserModel? = nil

Here is a possible solution:

extension Optional: RawRepresentable where Wrapped == UserModel {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode(UserModel.self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
              let result = String(data: data, encoding: .utf8)
        else {
            return "[]"
        }
        return result
    }

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

Note: I reused the implementation you already had for UserModel: RawRepresentable - it might need some corrections for this case.

Also because you conform Optional: RawRepresentable you need to make UserModel public as well.

Upvotes: 6

Related Questions