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.