WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Cook up a rich text experience in SwiftUI with AttributedString

Code-along: Cook up a rich text experience in SwiftUI with AttributedString

元の動画を見る

ハイライト

レシピエディタ App を題材にした実践的な code-along を、3 段階に分けて解説します。


主要内容

Max が作っているのはレシピエディタ App です。左側にレシピ一覧、中央に TextEditor、右側に材料表という構成になっています。痛点は明確で、レシピ中のキーワード(材料名)をひと目で分かるようにしたいのですが、TextEditor の状態の型は String なので、プレーンテキストしか保持できません。太字、文字サイズ、文字色、Genmoji、いずれも実現できません。

WWDC25 では SwiftUI の TextEditor に AttributedString サポートが追加されました。@Binding var text の型を String から AttributedString に変えるだけで、エディタは即座に bold、italic、underline、strikethrough、カスタムフォント、文字サイズ、前景色、背景色、文字間隔(kerning)、行間、ベースラインオフセット、Genmoji、行高さ、整列、書字方向に対応します。これらの属性はすべて、SwiftUI の非編集状態の Text と一致しています。TextEditor で編集した内容はそのまま Text で表示でき、変換処理は一切不要です。

この session は code-along 形式で、3 段階で進みます。第 1 段階は String を AttributedString にアップグレードします。第 2 段階はカスタムコントロールを作り、ユーザーが「butter」を選択して + ボタンを押すと、それを材料として登録できるようにします。第 3 段階では、独自のテキストフォーマット規則を定義し、TextEditor で許可する属性を制限します。


詳細

第 1 段階: TextEditor のアップグレード

スタート地点はもっとも素朴な TextEditor です(01:15)。

import SwiftUI

struct RecipeEditor: View {
    @Binding var text: String

    var body: some View {
        TextEditor(text: $text)
    }
}

ポイント:

  • textString なので、プレーンテキストしか格納できません。
  • TextEditor のデフォルトのツールバーには、リッチテキスト用のコントロールが表示されません。

型をひとつだけ変更します(01:45)。

@Binding var text: AttributedString

ポイント:

  • 型を AttributedString にすると、システム UI に bold、italic などのフォーマットボタンが自動的に現れます。
  • Font と Color はセマンティックな属性なので、Dark Mode と Dynamic Type に自動対応します。
  • キーボードの Genmoji もそのまま使えます。

第 2 段階: カスタムコントロールの構築

目標は、テキストを選択した後に PreferenceKey 経由で suggestion を inspector に渡すことです。最初の試みはコンパイルエラーになります(05:36)。

struct RecipeEditor: View {
    @Binding var text: AttributedString
    @State private var selection = AttributedTextSelection()

    var body: some View {
        TextEditor(text: $text, selection: $selection)
            .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
    }

    private var newIngredientSuggestion: IngredientSuggestion {
        let name = text[selection.indices(in: text)] // build error
        return IngredientSuggestion(suggestedName: AttributedString())
    }
}

ポイント:

  • selection の型は AttributedTextSelection です。
  • selection.indices(in: text) が返すのは AttributedTextSelection.Indices で、これは enum になっており、2 番目の case では RangeSet で値を保持しています。
  • この戻り値をそのまま text の subscript に渡すと型が合わず、コンパイルが通りません。

なぜ単一の Range ではなく RangeSet なのでしょうか。Jeremy はヘブライ語に英語を混ぜた例(09:24)で説明しています。双方向(bidirectional)テキストでは、ユーザーから視覚的に連続して選択されている範囲が、ストレージ上では複数の不連続な区間にまたがる可能性があります。そのため、SwiftUI は selection を RangeSet で表現しているのです。

修正版では、selection を直接 subscript に渡します(11:40)。

private var newIngredientSuggestion: IngredientSuggestion {
    let name = text[selection]
    return IngredientSuggestion(suggestedName: AttributedString(name))
}

ポイント:

  • SwiftUI が selection で直接スライスできる subscript を提供しています。
  • text[selection]AttributedSubstring を返し、これは不連続になる可能性があります。
  • AttributedString(name) で新しい AttributedString として実体化し、inspector に渡しています。

続いて、カスタム属性 IngredientAttribute を定義します(12:32)。

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = Ingredient.ID
    static let name = "SampleRecipeEditor.IngredientAttribute"
}

extension AttributeScopes {
    struct CustomAttributes: AttributeScope {
        let ingredient: IngredientAttribute
    }
}

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

ポイント:

  • CodableAttributedStringKey により、属性を Codable でシリアライズできるようになります。
  • ValueIngredient.ID で、テキストと材料表のエントリを関連付けています。
  • AttributeScope でカスタム属性を SwiftUI の属性体系に組み込みます。
  • AttributeDynamicLookup の subscript により、text[range].ingredient = id のように直接書けるようになります。
  • ボタンを押してすべての「butter」を材料としてマークするとき、text を書き換えながらカーソル位置を壊さないようにする必要があります(20:22)。
onApply: { ingredientId in
    let ranges = RangeSet(text.characters.ranges(of: name.characters))
    text.transform(updating: &selection) { text in
        text[ranges].ingredient = ingredientId
    }
}

ポイント:

  • text.characters.ranges(of:) でマッチするすべての位置を取得します。
  • すべての ranges を 1 つの RangeSet にまとめ、subscript に一括で渡すことで、for ループ内で text を繰り返し書き換えるのを避けています。
  • transform(updating:) はクロージャ内で text を書き換えると同時に、フレームワークが selection の Index を自動更新してくれます。selection を更新せずに text を直接書き換えると、カーソルがリセットされてしまいます。

第 3 段階: テキストフォーマットの定義

AttributedTextFormattingDefinition プロトコルで、TextEditor に許可する属性を宣言します(22:03)。

struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
    struct Scope: AttributeScope {
        let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
        let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
        let ingredient: IngredientAttribute
    }

    var body: some AttributedTextFormattingDefinition<Scope> {

    }
}

ポイント:

  • Scope には TextEditor が受け入れる属性の集合を列挙します。それ以外の属性は破棄されます。
  • .attributedTextFormattingDefinition(RecipeFormattingDefinition()) modifier を介して TextEditor に取り付けます。

さらに値の制約を 1 つ追加します。「材料としてマークされたテキストだけが緑色になれる」というルールです(23:50)。

struct IngredientsAreGreen: AttributedTextValueConstraint {
    typealias Scope = RecipeFormattingDefinition.Scope
    typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute

    func constrain(_ container: inout Attributes) {
        if container.ingredient != nil {
            container.foregroundColor = .green
        } else {
            container.foregroundColor = nil
        }
    }
}

ポイント:

  • AttributedTextValueConstraint は SwiftUI のレンダリング前に属性を書き換えます。
  • constraininout です。現在の run に ingredient 属性があるかをチェックし、ある場合は強制的に緑に塗ります。
  • IngredientsAreGreen()body に書き込むだけで、制約が自動的に有効になります。

最後にカスタム属性自身に制約を加えます(32:46)。

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = Ingredient.ID
    static let name = "SampleRecipeEditor.IngredientAttribute"
    static let inheritedByAddedText: Bool = false
    static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
}

ポイント:

  • inheritedByAddedText = false: 既存の材料マークの直後に新しい文字を入力しても、その文字は材料としてマークされません。
  • invalidationConditions = [.textChanged]: ベースとなるテキストが変化したときにこの属性を自動的に削除し、マークと実際のテキストがズレるのを防ぎます。
  • runBoundaries = .paragraph を指定すれば、属性を段落単位で揃えることもできます。

重要ポイント

1. String フィールドを AttributedString にアップグレードする

なぜやる価値があるか: TextEditor は 1 行のコード変更で、bold、文字サイズ、色、Genmoji、Dark Mode、Dynamic Type をすべて手に入れられます。 始め方: text が String になっている TextEditor(text: $text) をすべて洗い出し、AttributedString に変えた場合のストレージとシリアライズへの影響をまず評価します(AttributedString 自体は Codable です)。その上で置き換えていきます。

2. カスタム属性で「セマンティックなマーキング」を行う

なぜやる価値があるか: ノート App のタグ、リッチテキストエディタのリンク、Markdown レンダラのコードブロック、CRM メモ内の @mention など、「テキスト内にオブジェクト参照を埋め込む」ユースケースは数多くあります。これまでは正規表現や文字列の連結で実装するしかなく、壊れやすく保守しにくいものでした。AttributedString のカスタム属性は、こうした要件のためのネイティブな解です。 始め方: CodableAttributedStringKey を定義し、Value を参照したいオブジェクトの ID 型に設定します。inheritedByAddedText = falseinvalidationConditions = [.textChanged] を必ず付け、新しく入力された文字がマークを引き継いでしまうのを防ぎ、テキスト変更後にマークと文字がズレないようにします。

3. AttributedTextFormattingDefinition でエディタの見た目を固定する

なぜやる価値があるか: デフォルトの TextEditor はあらゆるリッチテキスト属性を受け入れます。ユーザーは文字サイズを 200 にしたり、背景を真っ赤にしたりできてしまいます。App 全体で視覚的な一貫性を保ちたい場合(たとえばノートをエクスポートしてメールで表示するケース)、許可する属性を制限する必要があります。 始め方: AttributeScope を宣言してサポートしたい属性だけを並べ、AttributedTextValueConstraint で属性をまたいだ制約(「見出しの文字サイズは 18 以上」「コードブロックは等幅フォントである」など)を表現します。

4. AttributedString を変更するときは transform(updating:) を優先する

なぜやる価値があるか: text を直接書き換えると、既存の Range や selection の index が無効になり、カーソルが飛んだり選択範囲が壊れたりします。手動でオフセットを計算する方法は、双方向テキストや emoji が絡む場面ではほぼ確実にバグります。 始め方: すべての text[range] = ...text.transform(updating: &selection) { text in ... } で包み、index のドリフト計算をフレームワークに任せます。複数箇所を一括で書き換えるときは、まず RangeSet にまとめてから一度に代入します。


関連セッション

コメント

GitHub Issues · utterances