I was playing around a bit with the new AttributedString and a few questions came up. I saw this other forum question "JSON encoding of AttributedString with custom attributes", but I did not completely understand the answer and how I would need to use it.
I created my custom attribute where I just want to store additional text like this:
enum AdditionalTextAttribute: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
typealias Value = AttributedString
static let name = "additionalText"
}
I then extended the AttributeScopes like this:
extension AttributeScopes {
struct MyAppAttributes: AttributeScope {
let additionalText: AdditionalTextAttribute
let swiftUI: SwiftUIAttributes
}
var myApp: MyAppAttributes.Type { MyAppAttributes.self }
}
and I also implemented the AttributeDynamicLookup like this:
extension AttributeDynamicLookup {
subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T { self[T.self] }
}
So next I created my AttributedString and added some attributes to it:
var attStr = AttributedString("Hello, here is some text.")
let range1 = attStr.range(of: "Hello")!
let range2 = attStr.range(of: "text")!
attStr[range1].additionalText = AttributedString("Hi")
attStr[range2].foregroundColor = .blue
attStr[range2].font = .caption2
Next I tried to create some JSON from my string and took a look at it like this:
let jsonData = try JSONEncoder().encode(attStr)
print(String(data: jsonData, encoding: .utf8) ?? "no data")
//print result: ["Hello",{},", here is some ",{},"text",{"SwiftUI.ForegroundColor":{},"SwiftUI.Font":{}},".",{}]
I guess it makes sense, that both SwiftUI.ForegroundColor and SwiftUI.Font are empty, because they both do not conform to Codable protocol.
My first question would be: Why does my additionalText attribute not show up here?
I next tried to extend Color to make it codable like this:
extension Color: Codable {
enum CodingKeys: CodingKey {
case red, green, blue, alpha
case desc
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
guard let cgColor = self.cgColor,
let components = cgColor.components else {
if description.isEmpty { throw CodingErrors.encoding }
try container.encode(description, forKey: .desc)
return
}
try container.encode(components[0], forKey: .red)
try container.encode(components[1], forKey: .green)
try container.encode(components[2], forKey: .blue)
try container.encode(components[3], forKey: .alpha)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let description = try container.decodeIfPresent(String.self, forKey: .desc) {
if description == "blue" {
self = Color.blue
return
}
throw CodingErrors.decoding
}
let red = try container.decode(CGFloat.self, forKey: .red)
let green = try container.decode(CGFloat.self, forKey: .green)
let blue = try container.decode(CGFloat.self, forKey: .blue)
let alpha = try container.decode(CGFloat.self, forKey: .alpha)
self.init(CGColor(red: red, green: green, blue: blue, alpha: alpha))
}
}
But it looks like even though Color is now codable, the encoding function does not get called when I try to put my attributed string into the JSONEncoder.
So my next question is: Does it just not work? Or do I also miss something here?
Coming to my last question: If JSONEncoder does not work, how would I store an AttributedString to disk?
You generally have the right approach here, but with a few small details missing leading to the confusion you're seeing.
First, on your custom attribute: the attribute is defined correctly. However, AttributedString conforms to Codable, but this default encoding implementation will only encode attributes defined in the SDK (as a convenience for use cases where you have no custom attributes). By calling JSONEncoder().encode(attrStr), you are using AttributedString's Codable implementation which only encodes SDK attributes and not your custom attribute. To encode custom attributes, you need to provide your custom attribute scope at encoding/decoding time using CodableWithConfiguration. You can do this by storing your AttributedString with custom attributes in a Codable struct and providing the scope in one of two ways:
Option 1: The @CodableConfiguration Property Wrapper
struct MyModel : Codable {
@CodableConfiguration(from: \.myApp) var attrStr: AttributedString = AttributedString()
}
let model = MyModel(attrStr: attrStr)
let json = try JSONEncoder().encode(model)
Option 2: A custom encode implementation:
struct MyModel : Codable {
var attrStr: AttributedString = AttributedString()
// ... coding keys, decode function, etc.
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: MyCodingKey.self)
container.encode(attrStr, forKey: .attrStrKey, configuration: AttributeScopes.MyAppAttributes.self)
}
}
let model = MyModel(attrStr: attrStr)
let json = try JSONEncoder().encode(model)
Providing your scope via the @CodableConfiguration property wrapper or the configuration: parameter of the encode function provides AttributedString with the necessary information to encode your custom attributes.
Next, the SwiftUI attributes: you are correct that these attributes are not currently Codable. SwiftUI.Color and SwiftUI.Font are not Codable, however that is not the only reason why these attributes are not encoded when encoding an attributed string. In order for an attribute to be encoded, the AttributedStringKey-conforming type needs to conform to CodableAttributedStringKey (and AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute and AttributeScopes.SwiftUIAttributes.FontAttribute do not conform to CodableAttributedStringKey because they are not currently designed to be encoded). So that is why even making Color and Font Codable would not cause the attributes to be encoded.