Storing AttributedString

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?

Answered by Frameworks Engineer in 701549022

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.

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.

Thanks for the fast answer, but I think I'm still missing something.

I now created my own struct like this:

struct MyModel: Codable, Hashable {
    @CodableConfiguration(from: \.myApp) var attrStr: AttributedString = AttributedString()
}

And continued with:

enum MyModelTextAttribute: CodableAttributedStringKey {
    typealias Value = MyModel
    static let name = "myModelTextAttribute"
}

extension AttributeScopes {
    struct MyAppAttributes: AttributeScope {
        let myModelTextAttribute: MyModelTextAttribute

        let swiftUI: SwiftUIAttributes
    }
    var myApp: MyAppAttributes.Type { MyAppAttributes.self }
}

extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T { self[T.self] }
}

The JSON decoding of the model works fine:

let model = MyModel(attrStr: AttributedString("Hi"))
let json = try JSONEncoder().encode(model)
print(String(data: json, encoding: .utf8) ?? "no data")
// prints: {"attrStr":"Hi"}

So I created an attributed string and set the attributes:

var attStr = AttributedString("Hello, here is some text.")
let range1 = attStr.range(of: "Hello")!
let range2 = attStr.range(of: "text")!
attStr[range1].myModelTextAttribute = model
attStr[range2].foregroundColor = .blue

Printing the attributed string with print(attStr) looks also fine:

Hello {
	myModelTextAttribute = MyModel(_attrStr: Foundation.CodableConfiguration<Foundation.AttributedString, (extension in __lldb_expr_87):Foundation.AttributeScopes.MyAppAttributes>(wrappedValue: Hi {
}))
}
, here is some  {
}
text {
	SwiftUI.ForegroundColor = blue
}
. {
}

But if I now try to convert it to JSON, my custom attribute is missing:

let jsonData = try JSONEncoder().encode(attStr)
print(String(data: jsonData, encoding: .utf8) ?? "no data")
// prints: ["Hello",{},", here is some ",{},"text",{"SwiftUI.ForegroundColor":{}},".",{}]

I also tried option 2 with a custom encode function in MyModel instead, but got the same result. Here is also my implementation for that:

struct MyModel: Codable, Hashable {
    var attrStr: AttributedString = AttributedString()

    enum MyCodingKey: CodingKey {
        case attrStrKey
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: MyCodingKey.self)
        try container.encode(attrStr, forKey: .attrStrKey, configuration: AttributeScopes.MyAppAttributes.self)
    }
}
Accepted Answer

I see now what I did wrong. I need to JSON encode the wrapper struct which holds the attributed string, so this can then use my custom attributes.

Here is my complete example. Maybe it also helps someone else:

import SwiftUI

extension AttributeScopes {
    var myApp: MyAppAttributes.Type { MyAppAttributes.self }

    struct MyAppAttributes: AttributeScope {
        let alternativeGreeting: AlternativeGreetingTextAttribute

        let swiftUI: SwiftUIAttributes
    }
}

extension AttributeDynamicLookup {
    subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.MyAppAttributes, T>) -> T { self[T.self] }
}

extension AttributeScopes.MyAppAttributes {
    enum AlternativeGreetingTextAttribute: CodableAttributedStringKey {
        typealias Value = String
        static let name = "alternativeGreeting"
    }
}

// Wrapper struct to also encode custom attributes
struct CodableType: Codable {
    @CodableConfiguration(from: \.myApp)
    var attrStr = AttributedString()
}

var attStr = AttributedString("Hello Max.")
attStr[attStr.range(of: "Hello")!].alternativeGreeting = "Hi"
attStr[attStr.range(of: "Max")!].personNameComponent = .givenName


// Decode to JSON, but use wrapper struct to recognize custom attributes
let jsonData = try JSONEncoder().encode(CodableType(attrStr: attStr))
print(String(data: jsonData, encoding: .utf8) ?? "no data")

print("-------")

// From JSON back to AttributedString
print(try JSONDecoder().decode(CodableType.self, from: jsonData).attrStr)

Thank you both so much! This helped a lot. Just to add, you can also encode without using a container like this:

JSONEncoder().encode(
    myAttributedString, 
    configuration: AttributeScopes.MyAppAttributes.self
) 
Storing AttributedString
 
 
Q