Search code examples
iosswiftswiftuiappstorage

App crashes when setting custom Struct value in AppStorage


I have a custom struct I want to store in AppStorage:

struct Budget: Codable, RawRepresentable {

    enum CodingKeys: String, CodingKey {
        case total, spent
    }

    var total: Double
    var spent: Double

    init(total: Double = 5000.0, spent: Double = 3000.0) {
        self.total = total
        self.spent = spent
    }

    init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let result = try? JSONDecoder().decode(Budget.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
    }

}

I then have the following view:

struct DemoView: View {

    @AppStorage(UserDefaults.StorageKeys.budget.rawValue) var budget = Budget()

    var body: some View {
        Button("Update") {
            budget.total = 10
        }
    }
}

When I tap the button the app crashes with Thread 1: EXC_BAD_ACCESS on guard let data = try? JSONEncoder().encode(self) for rawValue in Budget. What am I doing wrong here?


Solution

  • You are running into infinite recursion. This is because types that conform to both Encodable and RawRepresentable automatically get this encode(to:) implementation (source), which encodes the raw value. This means that when you call JSONEncoder().encode, it would try to call the getter of rawValue, which calls JSONEncoder().encode, forming infinite recursion.

    To solve this, you can implement encode(to:) explicitly:

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(total, forKey: .total)
        try container.encode(spent, forKey: .spent)
    }
    

    Note that you should also implement init(from:) explicitly, because you also get a init(from:) implementation (source) that tries to decode your JSON as a single JSON string, which you certainly do not want.

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        total = try container.decode(Double.self, forKey: .total)
        spent = try container.decode(Double.self, forKey: .spent)
    }