💾

structをCodableとRawRepresentableに準拠させて@AppStorageで値を保存する

2023/10/03に公開

概要

  • 下記のstructを@AppStorageで扱えるようにしたい。
struct FontData {
    var name: String
    var pointSize: CGFloat
}
  • 逆算すると以下の流れである。
    • @AppStorageで扱うには
    • -> RawRepresentableに準拠が必要
    • -> Codableに準拠が必要

参考

実装

Codableへの準拠

  • まずCodableに準拠させる。
  • structのプロパティが全てCodableの場合でも、Codableに必要なメソッドを自前で実装する必要がある
    • 具体的には値を更新したときに後述のvar rawValue: String内でencodeが無限ループしてしまいクラッシュする

RawRepresentable Conformance Leads to Crash
when you have a Codable and RawRepresentable type, the rawValue is used to encode/decode the value. If that's the case, you're incurring in an endless recursion by encoding/decoding in the rawValue definition.

extension FontData: Codable {
    
    enum CodingKeys: String, CodingKey {
        case name
        case pointSize
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(pointSize, forKey: .pointSize)
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        pointSize = try container.decode(CGFloat.self, forKey: .pointSize)
    }
}

RawRepresentableへの準拠


https://nilcoalescing.com/blog/SaveCustomCodableTypesInAppStorageOrSceneStorage/
Now we need to add RawRepresentable conformance to our custom Codable type. Remember, that AppStorage only supports RawRepresentable where the RawValue's associatedtype is of type Int or String. So the rawValue property has to return an Int or a String.

extension FontData: RawRepresentable {
    init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
              let decoded = try? JSONDecoder().decode(FontData.self, from: data) else {
            return nil
        }
        self = decoded
    }

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

呼び出し例

  • 以上で@AppStorageからFontDataを扱えるようになったので、以下のように呼び出して使用できるようになった。
  • 別の言い方をすると@AppStorageの内部はUserDefautlsであり、RawRepresentableの準拠によりFontData型はStringを経由してUserDefautlsで扱えるようになっている。

image

import SwiftUI

struct ContentView: View {
    
    @AppStorage("font-data")
    var fontData: FontData = .init(name: "Helvetica", pointSize: 14)
    
    var body: some View {
        Form {
            Section {
                HStack {
                    Text("Value")
                    Spacer()
                    Text("\(fontData.name): \(Int(fontData.pointSize))")
                        .monospacedDigit()
                }
                .foregroundColor(.secondary)
            }
            
            Section {
                TextField("Name", text: $fontData.name)
                Slider(value: $fontData.pointSize, in: 1...100) {
                    Text("Size")
                }
                Button("Reset") {
                    let appDomain = Bundle.main.bundleIdentifier
                    UserDefaults.standard.removePersistentDomain(forName: appDomain!)
//                    fontData = .init(name: "Helvetica", pointSize: 14)
                }
                .frame(maxWidth: .infinity, alignment: .trailing)
            } header: {
                Text("Input")
            }
        }
        .frame(width: 400)
        .formStyle(.grouped)
    }
}

#Preview {
    ContentView()
}

Discussion