🚀

ValueTransformerを使ってAttributedStringを保存するとクラッシュする(SwiftData)

2024/06/27に公開

はじめに

SwiftData@ModelAttributedString 持たせようとゴニョゴニョしたらクラッシュした話です。

iOS17 ならすんなりいけたんですが iOS 17.5 だとクラッシュしてなかなかうまくいきませんでした。どうやら私の書き方だと iOS 17.1 以降でクラッシュするようです。

参考

.transformable with ValueTransformer failing after Xcode 15.1 update

環境

  • Xcode 15.4
  • iOS 17.5

クラッシュする書き方

最初は下記のように書いていました。

import SwiftUI
import SwiftData

@main
struct HogeApp: App {

    init() {
        AttributedStringTransformer.register()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Item.self)
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var context
    @Query var items: [Item]

    var body: some View {
        VStack {
            List(items, id: \.id) { item in
                Text(item.text)
            }
            Button("add") {
                var string = AttributedString("hoge")
                string.font = UIFont.systemFont(ofSize: CGFloat((14...80).randomElement()!))
                string.foregroundColor = [UIColor.red, UIColor.blue, UIColor.green, UIColor.orange].randomElement()!
                context.insert(Item(text: string))
                try? context.save()
            }
        }
    }
}

@objc(AttributedStringTransformer)
class AttributedStringTransformer: ValueTransformer {

    static let name = NSValueTransformerName(rawValue: String(describing: AttributedStringTransformer.self))

    public static func register() {
        let transformer = AttributedStringTransformer()
        ValueTransformer.setValueTransformer(transformer, forName: name)
    }

    override class func transformedValueClass() -> AnyClass {
        return NSAttributedString.self
    }

    override class func allowsReverseTransformation() -> Bool {
        return true
    }

    override func transformedValue(_ value: Any?) -> Any? {
        guard let attributedString = value as? AttributedString,
              let data = try? NSKeyedArchiver.archivedData(withRootObject: NSAttributedString(attributedString), requiringSecureCoding: false) else {
            return nil
        }
        return data as NSData
    }

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let stringData = value as? NSData,
              let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: stringData as Data) else {
            return nil
        }
        return AttributedString(attributedString)
    }
}

@Model class Item {

    @Attribute(.unique) var id: String
    @Attribute(.transformable(by: AttributedStringTransformer.name.rawValue)) var text: AttributedString

    init(id: String = UUID().uuidString, text: AttributedString) {
        self.id = id
        self.text = text
    }
}

ValueTransformerAttributedStringNSAttributedString に変換してから NSKeyedArchiverNSData にして保存するやり方です。

こちらは iOS 17 だと問題なく動作するのですが iOS 17.5 は以下の理由でクラッシュします。

'Unacceptable type of value for attribute: property = "text"; desired type = NSAttributedString; given type = __SwiftValue; value = hoge

回避策

.transformable with ValueTransformer failing after Xcode 15.1 updateを見た感じ iOS 17.1 以降はtransformedValueClass@Model の型を揃える必要がありそうです。

下記のように NSAttributedString で揃えました。

@objc(AttributedStringTransformer)
class AttributedStringTransformer: ValueTransformer {

    static let name = NSValueTransformerName(rawValue: String(describing: AttributedStringTransformer.self))

    public static func register() {
        let transformer = AttributedStringTransformer()
        ValueTransformer.setValueTransformer(transformer, forName: name)
    }

    override class func transformedValueClass() -> AnyClass {
        return NSAttributedString.self
    }

    override class func allowsReverseTransformation() -> Bool {
        return true
    }

    override func transformedValue(_ value: Any?) -> Any? {
        guard let attributedString = value as? NSAttributedString,
              let data = try NSKeyedArchiver.archivedData(withRootObject: attributedString, requiringSecureCoding: false) else {
            return nil
        }    
        return data as NSData
    }

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let stringData = value as? NSData,
              let attributedString = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSAttributedString.self, from: stringData as Data) else {
            return nil
        }
        return attributedString
    }
}

@Model class Item {

    @Attribute(.unique) var id: String
    @Attribute(.transformable(by: AttributedStringTransformer.name.rawValue)) var text: NSAttributedString

    init(id: String = UUID().uuidString, text: NSAttributedString) {
        self.id = id
        self.text = text
    }
}

画面表示はこんな感じです。

struct ContentView: View {
    @Environment(\.modelContext) private var context
    @Query var items: [Item]

    var body: some View {
        VStack {
            List(items, id: \.id) { item in
                Text(AttributedString(item.text))
            }
            Button("add") {
                var string = AttributedString("hoge")
                string.font = UIFont.systemFont(ofSize: CGFloat((14...80).randomElement()!))
                string.foregroundColor = [UIColor.red, UIColor.blue, UIColor.green, UIColor.orange].randomElement()!
                let item = Item(text: NSAttributedString(string))
                context.insert(item)
                try? context.save()
            }
        }
    }
}

とりあえずこれで iOS 17 と iOS 17.5 両方で動くようになりました🎉

おわりに

結局直接 AttributedString を扱うことはできませんでしたが NSAttributedString なら一発で変換できるのでまあ。。。

こんな感じで Item に変換用のイニシャライザとコンピューテッドプロパティはやしてもいいですし。

var attributedText: AttributedString {
    return AttributedString(text)
}

init(id: String = UUID().uuidString, text: AttributedString) {
    self.id = id
    self.text = NSAttributedString(text)
}

SwiftData むずかしい🙃

なにか他にいい方法あったら教えてください🙇‍♂️

Discussion