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.
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:
- 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] }
}
- 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
}
}
- 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.