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
}
}
}
Post
Replies
Boosts
Views
Activity
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.
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()
}