ハイライト
レシピエディタ 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)
}
}
ポイント:
textはStringなので、プレーンテキストしか格納できません。- 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 でシリアライズできるようになります。ValueはIngredient.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 のレンダリング前に属性を書き換えます。constrainはinoutです。現在の 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 = false と invalidationConditions = [.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 にまとめてから一度に代入します。
関連セッション
- Embracing Swift concurrency — Swift 並行モデルのコアコンセプト。SwiftUI の状態更新と組み合わせて、selection の非同期挙動を理解するのに役立ちます。
- Explore Swift and Java interoperability — Swift と Java を同じコードベースで混在させる方法。
- Improve memory usage and performance with Swift — Swift コードのパフォーマンスとメモリ最適化のテクニック。AttributedString で大規模ドキュメントを扱う場面で参考になります。
- Optimize SwiftUI performance with Instruments — 新しい SwiftUI Instrument。TextEditor のリッチテキストシナリオでレンダリングのボトルネックを診断するために使えます。
コメント
GitHub Issues · utterances