🛠

AttributedString に独自属性を追加する

2022/10/02に公開

はじめに

昨年の WWDC21 において NSString/NSMutableString に対する String のように
NSAttributedString/NSMutableAttributedString に対する AttributedString が登場しました

以前こちらの記事を投稿した際はそのまま NSAttributedString を用いていましたが、せっかくなので AttributedString を利用する方法を調べました
https://zenn.dev/swiftty/articles/20220808-textkit2-example

属性の定義

はじめに属性として持たせたいアトリビュートを定義します
今回の場合、縦書き指定の属性とルビ指定の属性を用意します
それぞれ key-value 形式のデータ構造になっていて単純なモデルなら以下のように 2 行ですみます

public enum VerticalGlyphFormAttribute: CodableAttributedStringKey {
    public static var name: String { NSAttributedString.Key.verticalGlyphForm.rawValue }
    public typealias Value = Bool
}
public enum RubyAttribute: CodableAttributedStringKey,
                           MarkdownDecodableAttributedStringKey,
                           ObjectiveCConvertibleAttributedStringKey {
    public static var name: String { kCTRubyAnnotationAttributeName as String }

    public struct Value: Hashable, Codable {
        ...
    }
    ...
}

RubyAttribute は少しハマりどころがあったので解説します

ObjectiveCConvertibleAttributedStringKey

ルビを制御する際は CTRubyAnnotation を生成する必要があります
ただし CTRubyAnnotation は Swift から見ると class CTRubyAnnotation という定義で NSObject を継承していません
ObjectiveCConvertibleAttributedStringKey はこのように定義されていて NSObject 前提の変換プロトコルになっています

public protocol ObjectiveCConvertibleAttributedStringKey : AttributedStringKey {
    associatedtype ObjectiveCValue : NSObject
    static func objectiveCValue(for value: Self.Value) throws -> Self.ObjectiveCValue
    static func value(for object: Self.ObjectiveCValue) throws -> Self.Value
}

objectiveCValue(for:)rubyAnnotation as! NSObject として返そうとしてもキャストに失敗しクラッシュします
ここでしばらく悩んだのですが、ドキュメントにそういった場合の対応が記載されていました

https://developer.apple.com/documentation/foundation/objectivecconvertibleattributedstringkey

Attributed string keys that don’t conform to this protocol cast the value to AnyObject before converting to Objective-C. When converting from Objective-C, the value casts to the key’s Value type. In cases where Swift types bridge automatically to Objective-C types, like String to NSString, this default behavior is adequate. But for unbridged value types, you need to conform to this protocol and provide the conversion methods.

つまり一度 AnyObject にキャストしろということでした
なので rubyAnnotation as AnyObject as! NSObject となります

MarkdownDecodableAttributedStringKey

AttributedString はコードで属性を指定する他にマークダウン記法の拡張で行うことができます
例として あのイーハトーヴォのすきとおった^[風](ruby: 'かぜ') のように ^[](xxx:) 形式の記法を定義することができます
RubyAttribute では以下のようにマークダウンテキストから文字列として値をデコードしています

extension RubyAttribute: MarkdownDecodableAttributedStringKey {
    public static var markdownName: String { "ruby" }

    public static func decodeMarkdown(from decoder: Decoder) throws -> Value {
        let text = try decoder.singleValueContainer().decode(String.self)
        return Value(text: text)
    }
}

AttributeScope の定義

次に AttributedString に関連付けるために AttributeScope を定義します
先程作成したアトリビュートを持たせ、 AttributeDynamicLookup に追加した定義のパターンを追加します

extension AttributeScopes {
    public struct JapaneseAttributes: AttributeScope {
        public let ruby: RubyAttribute
        public let verticalGlyph: VerticalGlyphFormAttribute

        public let foundation: FoundationAttributes
        #if canImport(UIKit)
        public let uiKit: UIKitAttributes
        #endif
    }

    public var japanese: JapaneseAttributes.Type { JapaneseAttributes.self }
}
extension AttributeDynamicLookup {
    public subscript<T: AttributedStringKey>(dynamicMember keyPath: KeyPath<AttributeScopes.JapaneseAttributes, T>) -> T {
        return self[T.self]
    }
}

これにより AttributedString のプロパティのように追加した属性を読み書きできます

var string = AttributedString("風")
string.ruby = .init(text: "かぜ")  // RubyAttribute.Value(text:)
string.verticalGlyph = true

完成形は以下のリポジトリになります
https://github.com/swiftty/JapaneseAttributesKit

おわりに

ここまで AttributedString のカスタマイズについて解説してきましたが、 TextKit2 で利用する場合、デフォルトで提供されているクラス群は NSAttributedString の受け付けのみなので最終的に変換する必要があります
※ 今回の属性に関しても UIKit( = NSAttributedString) でないと効果のない属性なので、必然的ではありますが

try NSAttributedString(attributedString, including: \.japanese)

今回の解説だと AttributedString の利用は少し回りくどい印象になりますが、属性付き文字列をデータモデルとして管理したい場合、 NSAttributedString だと Hashable, Codable 等の対応でやっかいですが、 AttributedString だとシンプルに管理できると思います

Discussion