Swift Digest

AttributedString の不連続な範囲に対する操作

AttributedString Discontiguous Operations

Proposal
SF-0014
Authors
Jeremy Schonfeld
Review Manager
Tina Liu
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

AttributedString は属性付きテキストを表す Foundation の型で、.characters / .unicodeScalars / .utf8 / .utf16 / .runs といった複数のビューを通して内容を扱います。一方で、Swift 標準ライブラリには SE-0270 で導入された RangeSet があり、コレクションに対して連続しない複数の範囲をまとめて指定できる仕組みが用意されています。AttributedString の各ビューはコレクションなので、ビュー単体では RangeSet ベースの API(indices(of:) など)を利用できますが、AttributedString 自身は Collection に適合していないため、本体に対する RangeSet ベースの操作は提供されてきませんでした。

これが特に問題になるのが、UI 上の選択範囲を AttributedString 上の範囲としてモデル化したい場合です。複数箇所をまとめて選択している UI はもちろん、見た目には連続した 1 つの選択でも、左から右へ書く言語(LTR)と右から左へ書く言語(RTL)が混在するテキストでは論理的なテキストストレージ上では複数の不連続な範囲に分かれることがあります。こうした「不連続な範囲の集合」を素直に扱う API が AttributedString 本体になく、属性をまとめて書き換えたい場合も、不変の runs ビューでは属性の読み取りしかできず、複数の範囲に対する属性の更新を 1 つの式で書けない状態でした。

要するに、StringArray などの標準コレクションが RangeSet を介して享受している「不連続な範囲をまとめて読む・書き換える」体験が、AttributedString には欠けており、リッチテキストのモデル表現としての完成度を下げていました。

02 どのように解決されるのか

AttributedStringRangeSet<AttributedString.Index> を受け取るサブスクリプトと、不連続な範囲を表す新しい型 DiscontiguousAttributedSubstring が追加されます。FoundationPreview 6.2 以降で利用できます。

var text = AttributedString("Hello, world!")

// "l" の位置すべてを表す RangeSet を取得(既存 API)
let indicesOfL: RangeSet<AttributedString.Index> = text.characters.indices(of: "l")

// 不連続な範囲に対してまとめて属性を設定
text[indicesOfL].foregroundColor = .blue

print(text[indicesOfL]) // "lll { SwiftUI.ForegroundColor = ... }"

// 不連続な範囲の attribute run を走査
for (range, color) in text[indicesOfL].runs[\.foregroundColor] {
    // ...
}

text.characters.indices(of:) のように、RangeSet を返す API はビューを通して既に使えていました。今回の変更で、その RangeSetAttributedString 本体や AttributedStringProtocol のサブスクリプトに直接渡せるようになり、不連続な範囲に対する属性の読み取り・更新を 1 つの式で書けるようになります。

DiscontiguousAttributedSubstring

text[someRangeSet] の戻り値となるのが、新しく追加される DiscontiguousAttributedSubstring です。連続した範囲に対する AttributedSubstring の不連続版にあたる位置づけで、AttributedStringAttributeMutation に適合し、属性の取得・設定・マージ・置換と、属性キーパスによるサブスクリプト(.foregroundColor のような書き方)を提供します。

@available(FoundationPreview 6.2, *)
public struct DiscontiguousAttributedSubstring: AttributedStringAttributeMutation,
    CustomStringConvertible, Sendable, Hashable {
    public var base: AttributedString { get }

    // 範囲によるさらなるスライス
    public subscript(bounds: Range<AttributedString.Index>) -> DiscontiguousAttributedSubstring { get }
    public subscript(bounds: RangeSet<AttributedString.Index>) -> DiscontiguousAttributedSubstring { get }

    // 各種ビュー
    public var characters: DiscontiguousSlice<AttributedString.CharacterView> { get }
    public var unicodeScalars: DiscontiguousSlice<AttributedString.UnicodeScalarView> { get }
    public var utf8: DiscontiguousSlice<AttributedString.UTF8View> { get }
    public var utf16: DiscontiguousSlice<AttributedString.UTF16View> { get }
    public var runs: AttributedString.Runs { get }
}

characters / unicodeScalars / utf8 / utf16 は標準ライブラリの DiscontiguousSlice として返るため、不連続な部分のテキスト本体やコードユニットを順に走査できます。runs は通常の AttributedString.Runs ですが、対象範囲に応じた attribute run のみを返します。

DiscontiguousAttributedSubstringAttributedStringProtocol には適合していません。これは、AttributedStringProtocol のサブスクリプトが連続した AttributedSubstring を返すよう要求しているのに対し、不連続な部分文字列をさらに範囲でスライスした結果は再び不連続になり得るためです。代わりに、AttributedStringAttributeMutation 経由で属性まわりの主要な操作だけが利用できる設計になっています。

AttributedString 本体への追加 API

AttributedString には RangeSet を受け取るサブスクリプトと、不連続な範囲をまとめて削除する removeSubranges(_:) が追加されます。DiscontiguousAttributedSubstring から AttributedString を作るイニシャライザも用意されます。

@available(FoundationPreview 6.2, *)
extension AttributedString {
    public init(_ substring: DiscontiguousAttributedSubstring)

    public subscript(_ indices: RangeSet<AttributedString.Index>) -> DiscontiguousAttributedSubstring {
        get
        set
    }

    public mutating func removeSubranges(_ subranges: RangeSet<Index>)
}

@available(FoundationPreview 6.2, *)
extension AttributedStringProtocol {
    public subscript(_ indices: RangeSet<AttributedString.Index>) -> DiscontiguousAttributedSubstring { get }
}

AttributedStringProtocol 側の追加は読み取り専用で、AttributedSubstring などプロトコル経由でも不連続スライスを取り出せます。

サブスクリプトに set があるため、text[rangeSetA] = otherText[rangeSetB] のように不連続な部分文字列ごと差し替えることもできます。両者の不連続な範囲の数や長さが揃っていない場合の挙動は、replaceSubranges 相当(対応する範囲を 1 対 1 で置換していくセマンティクス)に揃えられています。

AttributedString.Runs.IndexStrideable 適合の deprecation

attribute run のインデックスである AttributedString.Runs.Index はこれまで Strideable に適合しており、runs.index + 1 のような数値オフセットでの計算が可能でした。しかし、不連続なスライスでは「あるインデックスから 1 つ進んだインデックス」を計算する際に、同じ run が複数の不連続な区間に分かれて登場する可能性があり、コレクションを介さずに正しく求めることができません。

そこで、Strideable への適合は今後 deprecate されます。インデックスを進めたい場合は、AttributedString.Runs が提供する index(_:offsetBy:) などのコレクション API を使ってください。Strideable 適合自体は残るため、既存コードがすぐに壊れることはなく、コンパイラからの警告に従って移行する形になります。

03 今後の見通し

将来の構想として、RangeSet ベースの API のさらなる拡充が挙げられています。実現を約束するものではありません。

たとえば、既存の range(of:) に対応する不連続版として、部分文字列が登場するすべての範囲を返す ranges(of: some StringProtocol) のような API が考えられます。本提案では AttributedString を他のコレクション型と同等の水準まで引き上げることに焦点を当てているため、こうした追加 API は今回の範囲外とされています。String 側にも同種の RangeSet API がまだ整備されていないため、StringAttributedString の双方を見据えて、RangeSet との相互運用を今後さらに広げていく余地があるとされています。