Custom AttributedString and SwiftData

The central feature of my app requires the use of AttributedStrings with multiple custom attributes to store data about various functions related to parts of them. These AttributedString and other data also need to be persisted in SwiftData.

Regarding how to do this, I spoke with an Apple engineer during WWDC24, and he said this was possible with the use of a ValueTransformer. After looking into this, I decided upon this scheme (forward direction shown here): Transform the AttributedString (with its custom attributes) into JSON data and have this interpreted as NSData, which SwiftData can persist.

The value transformer seems to transform the AttributedString to NSData and back without any problems. But any attempts to use this transformer with SwiftData crashes the app.

Your prompt solution to this problem would be greatly appreciated.

Answered by DTS Engineer in 801281022

Thanks for your reminding – I think I focused too much on SwiftData and missed the point about persisting an attributed string (AttributedString) with custom attributes :-(.

Let's now look into the real issue. First, the topic was discussed in the following post, which I think is pretty helpful:

To integrate the technique in your SwiftData model, here are the steps:

  1. Create a custom attribute key, as discussed in the mentioned post.
enum MyCustomAttribute: CodableAttributedStringKey {
    typealias Value = String
    static let name = "myCustomAttribute"
}

extension AttributeScopes {
    struct MyAppAttributes: AttributeScope {
        let myCustomAttribute: MyCustomAttribute
    }
    var myApp: MyAppAttributes.Type { MyAppAttributes.self }
}

extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T { self[T.self] }
}
  1. Create a wrapper type to encode and decode an attributed string.
struct AttributedStringWrapper: Codable {
    let attributedString: AttributedString
    
    enum MyCodingKey: CodingKey {
        case attributedStringKey
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: MyCodingKey.self)
        try container.encode(attributedString, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    init(from decoder: any Decoder) throws {
        let rootContainer = try decoder.container(keyedBy: MyCodingKey.self)
        attributedString = try rootContainer.decode(AttributedString.self, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    init(_ attributedString: AttributedString) {
        self.attributedString = attributedString
    }
}
  1. Add an attributed string to your SwiftData model, as discussed in my previous reply.
@Model class MyModel {
    var _attributedStringWrapperData: Data
    var attributedString: AttributedString {
        get {
            do {
                let attributedStringWrapper = try JSONDecoder().decode(AttributedStringWrapper.self, from: _attributedStringWrapperData)
                return attributedStringWrapper.attributedString
            } catch {
                print("Failed to decode AttributedString: \(error)")
                return AttributedString("Failed to decode AttributedString: \(error)")
            }
        }
        set {
            do {
                let attributedStringWrapper = AttributedStringWrapper(newValue)
                _attributedStringWrapperData = try JSONEncoder().encode(attributedStringWrapper)
            } catch {
                print("Failed to encode AttributedString: \(error)")
            }
        }
    }
    
    init(attributedString: AttributedString) {
        let attributedStringWrapper = AttributedStringWrapper(attributedString)
        _attributedStringWrapperData = try! JSONEncoder().encode(attributedStringWrapper)
    }
}

With that, you can create (and save) a model in the following way:

attributedString = AttributedString(inputText)
attributedString.myCustomAttribute = "red"
let newModel = MyModel(attributedString: attributedString)
modelContext.insert(newModel)
try? modelContext.save()

Alternatively, given that you need a wrapper type (step #2) anyway, you can make AttributedStringWrapper a class (rather than a struct) and write a transformer for it. My previous reply shows the details about how to write a transformer.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

AttributedString is a Codable struct, and should be able to be used as a SwiftData attribute type directly. However, I do notice that adding an AttributedString attribute to a SwiftData model leads to the following fatal error at runtime, which I believe is worth a feedback report:

SwiftData/SchemaProperty.swift:379: Fatal error: Class property within Persisted Struct/Enum is not supported: Guts

Before AttributedString is better supported in SwiftData, you can consider the following options:

  1. Convert AttributedString to Data and persist the Data type instead. Provide a computed property for convenient access to AttributedString, if needed.

  2. Persist NSAttributedString instead using a Transformable attribute. This is appropriate if you are using NSAttributedString in your presentation layer.

Note that you can't create a Transformable attribute for AttributedString directly because ValueTransformer requires the transformed value class (transformedValueClass) be a class, while AttributedString is a struct.

For option 1, here is a code example:

@Model class MyModel {
    var attributedStringData: Data
    var attributedString: AttributedString {
        get {
            // Consider error handling here.
            return try! JSONDecoder().decode(AttributedString.self, from: attributedStringData)
        }
        set {
            // Consider error handling here.
            attributedStringData = try! JSONEncoder().encode(newValue)
        }
    }
    ...
}

For option 2, you first create a value transformer (ValueTransformer) to convert NSAttributedString to Data and vice versa, as shown below:

@objc(NSAttributedStringTransformer)
class NSAttributedStringTransformer: NSSecureUnarchiveFromDataTransformer {
    override class func allowsReverseTransformation() -> Bool {
        return true
    }

    override class var allowedTopLevelClasses: [AnyClass] {
        return [NSAttributedString.self]
    }

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

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let attributedString = value as? NSAttributedString else {
            return nil
        }
        return attributedString.toNSData()
    }

    override func transformedValue(_ value: Any?) -> Any? {
        guard let data = value as? NSData else {
            return nil
        }
        return data.toAttributedString()
    }
}

private extension NSData {
    func toAttributedString() -> NSAttributedString? {
        let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
            .documentType: NSAttributedString.DocumentType.rtf,
            .characterEncoding: String.Encoding.utf8
        ]
        return try? NSAttributedString(data: Data(referencing: self),
                                       options: options,
                                       documentAttributes: nil)
    }
}

private extension NSAttributedString {
    func toNSData() -> NSData? {
        let options: [NSAttributedString.DocumentAttributeKey: Any] = [
            .documentType: NSAttributedString.DocumentType.rtf,
            .characterEncoding: String.Encoding.utf8
        ]
        let range = NSRange(location: 0, length: length)
        guard let data = try? data(from: range, documentAttributes: options) else {
            return nil
        }
        return NSData(data: data)
    }
}

extension NSValueTransformerName {
    static let nsAttributedStringTransformer = NSValueTransformerName(rawValue: "NSAttributedStringTransformer")
}

Remember to register the value transformer before using it. For example:

@main
struct MyApp: App {
    init() {
        ValueTransformer.setValueTransformer(NSAttributedStringTransformer(), forName: .nsAttributedStringTransformer)
    }
    ...
}

You can now declare and use a transformable attribute in your model class:

@Model class MyModel {
    @Attribute(.transformable(by: NSAttributedStringTransformer.self))
    var attributedString: NSAttributedString
    ...
}

If you need to stick with AttributedString but don't like option 1, probably consider wrapping AttributedString with a custom class and writing a transformer for the class. How to write a transformer is shown above.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Hi Ziqiao,

In my inquiry, I had emphasized that this problem is about persisting AttributedStrings with custom attributes. Merely persisting strings with standard attributes (at least of the NSAttributedString type) is not a problem.

In any case, I tried implementing your first solution, and as you can see from the results in my test app (code pieces, below), even standard attributes are not persisted with this approach!

Frankly, I don't need to store standard attributes, anyway. Accordingly, if you could focus your efforts on the problem of persisting specifically custom attributes, I would greatly appreciate it.

Thanks!

P.S. Why isn't there a way of uploading the app itself, instead of having to chop it up. I imagine it makes testing things on your end a bit less efficient.

StringPersistenceApp.swift

import SwiftUI

@main
struct StringPersistenceApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: PersistedString.self)
    }
}

Model.swift

import SwiftData

@Model
class PersistedString {
    var attributedStringData: Data
    var attributedString: AttributedString {
        get {
            do {
                return try JSONDecoder().decode(AttributedString.self, from: attributedStringData)
            } catch {
                print("Failed to decode AttributedString: \(error)")
                return AttributedString("Failed to decode AttributedString: \(error)")
            }
        }
        set {
            do {
                attributedStringData = try JSONEncoder().encode(newValue)
            } catch {
                print("Failed to encode AttributedString: \(error)")
            }
        }
    }
    
    init(attributedString: AttributedString) {
        self.attributedStringData = try! JSONEncoder().encode(attributedString)
    }
}

ContentView.swift

import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext: ModelContext
    @Query private var persistedString: [PersistedString]
    
    @State private var inputText: String = ""
    @State private var attributedString: AttributedString = AttributedString("")
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Type something with the word 'red' in it.")
            
            TextField("Enter text", text: $inputText)
                .padding()
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            Button("Save and Colorize") {
                attributedString = AttributedString(inputText)
                
                if let range = attributedString.range(of: "red") {
                    attributedString[range].foregroundColor = .red
                }
                
                let newPersistedString = PersistedString(attributedString: attributedString)
                modelContext.insert(newPersistedString)
                try? modelContext.save()
            }
            .padding()
            .buttonStyle(.borderedProminent)
            
            if let lastPersistedString = persistedString.last {
                Text("Attributed string:")
                    .font(.headline)
                
                Text(attributedString)
                    .padding()
                    .background(Color(UIColor.secondarySystemBackground))
                    .cornerRadius(8)
                
                Text("Attributed string's description:")
                    .font(.headline)
                
                Text(attributedString.description)
                    .background(Color(UIColor.secondarySystemBackground))
                    .cornerRadius(8)
                
                Text("Last persisted attributed string:")
                    .font(.headline)
                
                Text(lastPersistedString.attributedString)
                    .background(Color(UIColor.secondarySystemBackground))
                    .cornerRadius(8)
                
                Text("Last persisted attributed string's description:")
                    .font(.headline)
                
                Text(lastPersistedString.attributedString.description)
                    .background(Color(UIColor.secondarySystemBackground))
                    .cornerRadius(8)
                
            } else {
                Text("No string persisted yet.")
                    .font(.subheadline)
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Thanks for your reminding – I think I focused too much on SwiftData and missed the point about persisting an attributed string (AttributedString) with custom attributes :-(.

Let's now look into the real issue. First, the topic was discussed in the following post, which I think is pretty helpful:

To integrate the technique in your SwiftData model, here are the steps:

  1. Create a custom attribute key, as discussed in the mentioned post.
enum MyCustomAttribute: CodableAttributedStringKey {
    typealias Value = String
    static let name = "myCustomAttribute"
}

extension AttributeScopes {
    struct MyAppAttributes: AttributeScope {
        let myCustomAttribute: MyCustomAttribute
    }
    var myApp: MyAppAttributes.Type { MyAppAttributes.self }
}

extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T { self[T.self] }
}
  1. Create a wrapper type to encode and decode an attributed string.
struct AttributedStringWrapper: Codable {
    let attributedString: AttributedString
    
    enum MyCodingKey: CodingKey {
        case attributedStringKey
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: MyCodingKey.self)
        try container.encode(attributedString, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    init(from decoder: any Decoder) throws {
        let rootContainer = try decoder.container(keyedBy: MyCodingKey.self)
        attributedString = try rootContainer.decode(AttributedString.self, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    init(_ attributedString: AttributedString) {
        self.attributedString = attributedString
    }
}
  1. Add an attributed string to your SwiftData model, as discussed in my previous reply.
@Model class MyModel {
    var _attributedStringWrapperData: Data
    var attributedString: AttributedString {
        get {
            do {
                let attributedStringWrapper = try JSONDecoder().decode(AttributedStringWrapper.self, from: _attributedStringWrapperData)
                return attributedStringWrapper.attributedString
            } catch {
                print("Failed to decode AttributedString: \(error)")
                return AttributedString("Failed to decode AttributedString: \(error)")
            }
        }
        set {
            do {
                let attributedStringWrapper = AttributedStringWrapper(newValue)
                _attributedStringWrapperData = try JSONEncoder().encode(attributedStringWrapper)
            } catch {
                print("Failed to encode AttributedString: \(error)")
            }
        }
    }
    
    init(attributedString: AttributedString) {
        let attributedStringWrapper = AttributedStringWrapper(attributedString)
        _attributedStringWrapperData = try! JSONEncoder().encode(attributedStringWrapper)
    }
}

With that, you can create (and save) a model in the following way:

attributedString = AttributedString(inputText)
attributedString.myCustomAttribute = "red"
let newModel = MyModel(attributedString: attributedString)
modelContext.insert(newModel)
try? modelContext.save()

Alternatively, given that you need a wrapper type (step #2) anyway, you can make AttributedStringWrapper a class (rather than a struct) and write a transformer for it. My previous reply shows the details about how to write a transformer.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thank you for the updated reply.

I tried implementing your code, as written, but it crashed. And unfortunately, the error messages were as enigmatic to me as before I contacted Apple Support.

In any case, I'm also uncertain about what you meant in your concluding paragraph (beginning with "Alternatively"). My app needs to be able to access, retrieve, and modify the attributed string and its custom attributes frequently, and store the updated string on the fly. Wouldn't it then be imperative that I use a value transformer for this? I thought that was the reason the WWDC24 Apple engineer steered me in the direction of using a ValueTransformer.

If you could provide code that integrates each of the steps needed for this, I would greatly appreciate it. I normally would try troubleshooting myself (I love problem solving), but the problems I've encountered are beyond my abilities. It's also a lot easier to learn code from a simple, working example....

Thanks for your help.

Accepted Answer

Hi Ziqiao,

Thank you for the extra help of providing me working code that implements the wrapping of an AttributedString with custom attributes and persists it in SwiftData. Since I was also interested in further streamlining this conversion, per your instructions, I converted the attributed string wrapper into a class and (after puzzling for a while about how exactly to do this) added a ValueTransformer to the process.

For the benefit of others, the relevant code is below. If you (or anyone else who reads this) see anything wrong or something that could be improved, please let me (and the world) know.

Code:

import SwiftUI

@main
struct ApplesSolutionApp: App {
    init() {
        ValueTransformer.setValueTransformer(AttributedStringWrapperTransformer(), forName: NSValueTransformerName("AttributedStringWrapperTransformer"))
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: MyModel.self)
    }
}
import Foundation
import SwiftData

@Model class MyModel {
    var _attributedStringWrapperData: Data
    
    var attributedString: AttributedString {
        get {
            guard let transformer = ValueTransformer(forName: NSValueTransformerName("AttributedStringWrapperTransformer")),
                  let wrapper = transformer.reverseTransformedValue(_attributedStringWrapperData) as? AttributedStringWrapper else {
                print("Failed to decode using transformer")
                return AttributedString("Failed to decode")
            }
            return wrapper.attributedString
        }
        set {
            guard let transformer = ValueTransformer(forName: NSValueTransformerName("AttributedStringWrapperTransformer")),
                  let data = transformer.transformedValue(AttributedStringWrapper(newValue)) as? Data else {
                print("Failed to encode using transformer")
                return
            }
            _attributedStringWrapperData = data
        }
    }
    
    init(attributedString: AttributedString) {
        let wrapper = AttributedStringWrapper(attributedString)
        if let transformer = ValueTransformer(forName: NSValueTransformerName("AttributedStringWrapperTransformer")),
           let data = transformer.transformedValue(wrapper) as? Data {
            _attributedStringWrapperData = data
        } else {
            fatalError("Failed to encode initial value using transformer")
        }
    }
}
import Foundation

class AttributedStringWrapper: Codable {
    let attributedString: AttributedString
    
    enum MyCodingKey: CodingKey {
        case attributedStringKey
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: MyCodingKey.self)
        try container.encode(attributedString, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    required init(from decoder: any Decoder) throws {
        let rootContainer = try decoder.container(keyedBy: MyCodingKey.self)
        attributedString = try rootContainer.decode(AttributedString.self, forKey: .attributedStringKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
    
    init(_ attributedString: AttributedString) {
        self.attributedString = attributedString
    }
}
import Foundation

class AttributedStringWrapperTransformer: ValueTransformer {

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

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

    override func transformedValue(_ value: Any?) -> Any? {
        guard let wrapper = value as? AttributedStringWrapper else { return nil }
        do {
            let jsonData = try JSONEncoder().encode(wrapper)
            return jsonData as NSData
        } catch {
            print("Encoding failed: \(error.localizedDescription)")
            return nil
        }
    }

    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let data = value as? NSData else { return nil }
        do {
            let wrapper = try JSONDecoder().decode(AttributedStringWrapper.self, from: data as Data)
            return wrapper
        } catch {
            print("Decoding failed: \(error.localizedDescription)")
            return nil
        }
    }
}
Custom AttributedString and SwiftData
 
 
Q