iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚀

Crashing when saving AttributedString using ValueTransformer in SwiftData

に公開

Introduction

This is a story about how I tried to include an AttributedString in a SwiftData @Model and ended up with a crash.

iOS 17 worked smoothly, but on iOS 17.5 it crashed and didn't go well. Apparently, my implementation causes a crash on iOS 17.1 and later.

Reference

.transformable with ValueTransformer failing after Xcode 15.1 update

Environment

  • Xcode 15.4
  • iOS 17.5

Implementation that crashes

Initially, I wrote it like this:

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
    }
}

This is a method of converting AttributedString to NSAttributedString with a ValueTransformer, and then saving it as NSData using NSKeyedArchiver.

While this works fine on iOS 17, it crashes on iOS 17.5 for the following reason:

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

Workaround

Based on .transformable with ValueTransformer failing after Xcode 15.1 update, it seems that for iOS 17.1 and later, it's necessary to match the type of transformedValueClass with the type used in the @Model.

I aligned them to NSAttributedString as follows:

@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
    }
}

The UI implementation looks like this:

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()
            }
        }
    }
}

For now, this is working on both iOS 17 and iOS 17.5 🎉

Conclusion

In the end, I couldn't handle AttributedString directly, but since it can be easily converted from NSAttributedString, it's not so bad...

You could also add a conversion initializer and a computed property to Item like this:

var attributedText: AttributedString {
    return AttributedString(text)
}

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

SwiftData is hard 🙃

If you know of any other better way, please let me know 🙇‍♂️

Discussion