View in English

  • メニューを開く メニューを閉じる
  • Apple Developer
検索
検索を終了
  • Apple Developer
  • ニュース
  • 見つける
  • デザイン
  • 開発
  • 配信
  • サポート
  • アカウント
次の内容に検索結果を絞り込む

クイックリンク

5 クイックリンク

ビデオ

メニューを開く メニューを閉じる
  • コレクション
  • トピック
  • すべてのビデオ
  • 利用方法

WWDC25に戻る

  • 概要
  • トランスクリプト
  • コード
  • Code Along:SwiftUIのAttributedStringを使って優れたテキスト体験を実現

    SwiftUIのTextEditor APIとAttributedStringを使用して、優れたテキスト体験を構築する方法を学びましょう。高度なテキスト編集を実現して、エディタのコンテンツを操作するカスタムコントロールを作成し、利用可能なフォーマットオプションをカスタマイズする方法を紹介します。AttributedStringの高度な機能を利用して、最適なテキスト編集体験を構築する方法をご確認いただけます。

    関連する章

    • 0:00 - イントロダクション
    • 1:15 - TextEditorとAttributedString
    • 5:36 - カスタムコントロールの構築
    • 22:02 - テキストフォーマットの定義
    • 34:08 - 次のステップ

    リソース

    • AttributedTextFormatting
    • AttributedTextSelection
    • Building rich SwiftUI text experiences
    • Character
      • HDビデオ
      • SDビデオ

    関連ビデオ

    WWDC25

    • アプリの多言語体験の向上

    WWDC22

    • Get it right (to left)(右から左方向への文字体)

    WWDC21

    • Foundationの新機能
  • このビデオを検索

    こんにちは SwiftUIチームの エンジニアのMaxです Swift標準ライブラリチームの エンジニアのJeremyです 今回は SwiftUIの機能と AttributedStringを使用して リッチテキスト編集機能を構築する 方法についてご紹介します Jeremyの助けを借りながら SwiftUIのリッチテキスト機能の 要点について詳しくご紹介します 最初に TextEditorをアップグレードして AttributedStringを使用した リッチテキストをサポートします 次に カスタムコントロールを構築して エディタに独自の機能を追加します 最後に 独自のテキスト書式の 定義を作成することで 最終的に コンテンツが見栄えよく 表示されるエディタが完成します

    さて エンジニアとして働く私ですが 家に帰ると...

    完璧なクロワッサンを作る パン焼き職人になります 最近 レシピの試行錯誤を 記録するために ちょっとした レシピエディタを開発しています

    左側にレシピの一覧があり 中央にはレシピを編集する テキストエディタ 右側には材料の一覧を表示する インスペクタがあります レシピテキストの重要な箇所が 調理中に一目でわかるように その箇所を目立たせようと思います そのために リッチテキストをサポートするよう エディタをアップグレードします

    こちらのエディタビューはSwiftUIの TextEditor APIで実装したものです text状態の型が Stringになっていますので 現在サポートされているのは プレーンテキストのみです Stringを AttributedStringに変更します

    これだけでビューの機能が 劇的に向上します

    これでリッチテキストに対応できましたので システムUIを使用して 文字の太さを切り替えることができます フォントの大きさなどの その他の書式も 何でも適用できます

    キーボードを使って ジェン文字を挿入することもできます

    SwiftUIのFont属性とColor属性は セマンティックな性質を持つため 新しいTextEditorはダークモードと ダイナミックタイプもサポートします

    TextEditorは 太字、斜体、 下線、取り消し線、カスタムフォント、 ポイントサイズ、 前景色、背景色、 カーニング、 トラッキング、ベースラインオフセット、 ジェン文字に加えて 重要な段落スタイル設定にも対応しました 行間、テキスト配置、さらに 基本となる書字方向についても SwiftUIの独立したAttributedString属性 として設定することができます

    このような属性はすべてSwiftUIの 編集不可テキストで一貫して保持されるため TextEditorの内容を そのまま 後のTextで表示できます Textと同様にTextEditorは 任意のAttributedStringKeyに対して 環境から計算されたデフォルト値を nil値に置換できます ここでJeremyに相談なのですが... 私は今までAttributedStringを利用して アプリを作ってきましたが ここで1回復習して 自分の知識が確かかどうか 確認してから コントロールの作成に移りたいんです これからクロワッサンの生地も 仕込まないといけないので 私がその作業をしている間に 復習をやってくれませんか もちろんいいですよ あなたがクロワッサンの生地を 仕込んでいる間に リッチテキストエディタの開発に役立つ AttributedStringの基本について 簡単にご説明します 簡単に言えば AttributedStringには 一連の文字と 一連の属性実行が含まれます たとえば このAttributedStringに 含まれるテキストは「Hello! Who’s ready to get cooking?」です また 3つの属性実行も含まれます 1つ目の実行ではフォントのみを適用し 2つ目の実行で前景色を追加し 3つ目の実行では フォント属性のみを適用します

    AttributedString型は アプリ全体で使用される 他のSwift型と共存させることができます これは値型であり 標準ライブラリのStringと同様に UTF-8エンコードを使用して コンテンツを格納します さらにAttributedStringは Equatable、Hashable、Codable、 Sendableなどの一般的なSwiftプロトコルの ほとんどに準拠します Apple SDKには 先ほどMaxが紹介していたような さまざまな属性が事前定義されており 属性スコープにグループ化されています さらにAttributedStringはアプリ内で 定義されたカスタム属性もサポートするため UIに合わせてカスタマイズできます

    では先ほどの数行のコードを使って AttributedStringを作成してみます まず 前半のテキストを含む AttributedStringを作成します 次に「cooking」という文字列を作成し 前景色をオレンジ色に設定して それをテキストに追加します そして末尾に疑問符を付けて 文章を完成させます 最後に テキスト全体のフォントを largeTitleフォントに設定します これで この文章をデバイスのUIに 表示できるようになりました

    AttributedStringの基本的な 作成方法と 独自のカスタム属性 そして 属性スコープの作成と使用については WWDC 2021の「What’s new in Foundation」セッションをご覧ください

    生地の仕込みが終わったようですね レシピアプリでのAttributedStringの 使用法を詳しく教えてもらえますか? もちろんです そのために話す内容を 「練って」いたんです 私はテキストエディタを アプリの他の機能と 連携させるために コントロールを 改善したいと思っていました 右側のインスペクタに材料を追加するための ボタンを作りたいのですが 材料の名前を改めて 入力し直す手間は省きたいと思っていました たとえばレシピ内の「butter」 という単語を選択して ボタンを1回押すだけで それを材料としてマークできるように したいのです

    このインスペクタには リストに加える新しい材料を テキストエディタからサジェストするための 設定キーがすでに定義されています

    preferenceビューモディファイアに 渡された値は ビュー内の上の階層に浮かび上がり レシピエディタを使用するビューから 「NewIngredientPreferenceKey」 という名前で読み取ることができます

    ではビューのbodyでこの値の 計算済みプロパティを定義しましょう

    ここではサジェスト対象の名前を AttributedStringと 指定するだけです もちろん エディタ内のテキスト全体を サジェストするのではなく 現在選択中のテキストを サジェストする必要があります たとえば「butter」などです TextEditorはオプションのselection引数を 介して 選択内容を伝えます

    これをAttributedTextSelection型の ローカル状態にバインドします

    これでビューに必要なコンテキストを すべて準備できましたので 材料サジェストを計算する プロパティに戻りましょう

    次に 選択中のテキストを 取得する必要があります

    選択に対して このindices関数を使った結果を 添え字としてテキストに設定してみます

    うーん これは 正しい型ではないようです

    戻り値は AttributedTextSelection.Indicesです 調べてみましょう

    なるほど これは面白い 私は1つしか選択していませんが Indices型の2つ目のcaseが 一連の範囲で示されています

    Jeremy 私がクロワッサンの生地を まとめる間に どうしてこうなるのか 解説してもらえますか おやおや こっちは美味しいクロワッサンを想像して 考えをまとめられなくなりそうです でも ご心配なく このAPIで使われるはずのRange型が 使われない理由を説明します このAPIで 単独のRangeではなく RangeSetが使用される 理由を説明するため AttributedStringの 選択について見てみましょう 複数の範囲がどのように 選択範囲を形成するのかを説明し コード内でRangeSetを 使用する方法の例を紹介します 皆さんもAttributedStringなどの コレクションAPIで 単独の範囲を 使用した経験があると思います 単独の範囲を使うと AttributedStringの一部をスライスし その1つのチャンクに対して アクションを実行できます たとえばAttributedStringが 提供するAPIを使用して テキストの一部に対して 1つの属性を 簡単に適用することができます range(of:) APIを使用して AttributedString内の「cooking」という テキスト範囲を検索します 次に この添え字演算子を使用して その範囲でAttributedStringを スライスし「cooking」という テキスト全体をオレンジ色に設定します

    ただしAttributedStringを ただ1つの範囲でスライスしても 全言語に対応するテキストエディタで 選択を表現するには 不十分です たとえばこのレシピアプリを使って 私が休日に作るスフガニヤの レシピを保存するとしたら レシピにはヘブライ語が含まれます このレシピに「Put the Sufganiyot in the pan」と書いてあるとします 手順の部分は英語ですが この伝統食の名前は ヘブライ文字で書かれています テキストエディタで「Sufganiyot」の 単語の一部と 「in」という単語を 1つの選択範囲で選択してみましょう しかしこれはAttributedStringで 複数の範囲として扱われます 英語は左から右に書く言語であるため エディタの表示では文章が 左側から右側に向かって配置されます しかしヘブライ語のSufganiyotは それとは反対方向に配置されます ヘブライ語は右から左に書く言語だからです このテキストの双方向的な性質は 画面上のレイアウトに影響を与えますが AttributedStringはそれと関係なく すべてのテキストを同じ並び順で格納します 文字の方向が違うことから 私が選択した範囲が 「Sufganiyot」という単語の最初の部分と 「in」という単語に分かれて ヘブライ語テキストの後半は除外されます これが SwiftUIテキスト選択タイプが 1つの範囲でなく 複数の範囲を使っている理由です

    双方向テキストに対応するための アプリのローカライズ方法については WWDC 2022の「Get it right (to left)」 セッションと 今年の「Enhance your app’s multilingual experience」セッションをご覧ください

    このようなタイプの選択をサポートするため AttributedStringはRangeSetを使用した スライスをサポートしています 先ほどMaxが選択APIの説明で 指摘していた型です AttributedStringを 1つの範囲でスライスするのと同じように RangeSetでスライスすると 不連続な部分文字列を生成できます この例ではキャラクタビューで .indices(where:)関数を使用して テキスト内のすべての大文字を 検出するRangeSetを作成しています このスライスの前景色を 青に設定すると すべての大文字が青色になりますが その他の文字は変更されません SwiftUIには同様の機能を持つ添え字もあり 選択を含むAttributedStringを 直接スライスできます Max クロワッサンの生地は 綺麗にまとまりましたか? 選択を受け取る添え字APIなら あなたのコードのビルドエラーを 解決してくれると思いますよ では試してみましょう テキストに選択範囲を 添え字として直接設定し

    不連続なAttributedSubstringを 新しいAttributedStringに変換します

    いい感じですね これをデバイスで実行して 「butter」という単語を選択すると SwiftUIが自動的に newIngredientSuggestionプロパティを 呼び出して新しい値を計算し その値がアプリの 他の部分に浮かび上がります その後 インスペクタの材料リストの末尾に このサジェストが追加されます それを1回タップするだけで 材料リストに追加できます エディタにこのような機能を加えることで 洗練された操作感を生み出すことができます

    この機能を追加できたことは満足ですが Jeremyが教えてくれたことを活かせば さらに上を目指せそうです テキスト上の材料名も 見栄えよく表示してみたいと思います そのためにまず必要なのは 特定のテキスト範囲を材料名として マークするカスタム属性です これを新しいファイルで定義しましょう

    このValue属性が 材料の参照用IDになります 次にRecipeEditorファイルに戻って IngredientSuggestionを 計算するプロパティを見てみます

    IngredientSuggestionで クロージャを2番目の引数として指定します

    このクロージャは プラスボタンを押したときに呼び出され 材料がリストに追加されます このクロージャを使って エディタのテキストを変更し Ingredient属性を使ってnameの 出現箇所をマークします そして新しく作成された材料のIDを クロージャに渡します

    次に サジェストされた材料名の テキスト内での出現箇所をすべて 特定する必要があります

    それにはAttributedStringのキャラクタ ビューで ranges(of:)を呼び出します

    これで範囲が得られますので あとは各範囲でingredient属性の値を 更新するだけです

    ここでは前に定義した IngredientAttributeの 短い名前を使用します

    早速試してみましょう

    新しいものは何もありません 結局 このカスタム属性には 書式設定が関連付けられていないからです では「yeast」を選択して プラスボタンを押してみましょう

    おっと これはおかしい 上にあったカーソルが 下に移動しています もう一度試してみます

    「salt」を選択して

    プラスボタンをクリックします カーソルが一番下にジャンプしますね 私はクロワッサンの生地を 伸ばさなきゃいけないので デバッグする暇がありません 選択がリセットされる 原因はわかりますか? これは確かに このアプリを使う人にとって 望ましい動作ではないですね あなたは生地を伸ばす作業を始めてください 私がこの予期しない動作を 調べてみましょう

    問題の原因を特定して その修正方法を説明するために リッチテキストエディタで使用される 範囲とテキスト選択を 形成するAttributedStringインデックスに ついて詳しく説明します

    AttributedString.Indexは テキスト内の1つの場所を表します ハイパフォーマンスな 設計をサポートするために AttributedStringではコンテンツが ツリー構造で格納され インデックスによって ツリー内のパスが格納されます このインデックスはSwiftUIの テキスト選択を構成する要素ですので アプリの予想外のテキスト選択動作は このツリー内でのAttributedString インデックスの動作によって生じます AttributedStringインデックスを扱う際は 2つの重要な点に留意する必要があります 第1に AttributedStringを変更すると そのすべてのインデックスが無効になります 変更の範囲外にあるインデックスも同じです 消費期限が過ぎた材料では 美味しい料理を作れません おわかりだと思いますが AttributedStringで古いインデックスを 使用することはそれと同じです 第2に インデックスは必ずその作成元の AttributedStringと組み合わせて 使用する必要があります

    では 先ほど作成した AttributedStringのサンプルを使って インデックスの機能をご説明しましょう 先ほど説明したようにAttributedStringは コンテンツをツリー構造に格納します この図は そのツリーを単純化して示したものです ツリーの使用によりパフォーマンスが向上し テキストを変更したときに大量のデータを コピーする必要がなくなります

    AttributedString.Indexは参照先の 場所までのツリー内のパスを格納することで テキストを参照します AttributedStringは格納されたパスによって インデックスから特定のテキストの 位置をすぐに特定できますが それはつまりAttributedString ツリー全体のレイアウト情報も インデックスに含まれていることを 意味します AttributedStringを変更するとツリーの レイアウトが調整される場合があります それによって 以前に記録されたパスは すべて無効になります そのインデックスの参照先が テキスト内にまだ存在していても同じです

    さらに 2つのAttributedStringのテキストと 属性コンテンツが同じ場合でも ツリーのレイアウトが異なることから インデックスの相互互換性が ない場合もあります

    インデックスを使用してツリーをたどり 情報を探すには AttributedStringの1つのビュー内で そのインデックスを使用する必要があります インデックスは特定のAttributedStringに 固有のものですが その文字列の 任意のビューで使用できます Foundationには テキストコンテンツの文字 つまり 書記素クラスタ、 各文字を構成する個々のUnicodeスカラ、 そして文字列の

    属性実行に対するビューが用意されています

    キャラクタビューとUnicodeスカラビューの 違いについて詳しくは Swiftキャラクタタイプに関するAppleの デベロッパ向けドキュメントをご覧ください

    その他 NSStringなど Swiftキャラクタタイプを 使用しない 文字列のような型を扱う場合は より低いレベルのコンテンツを 利用してもよいでしょう さらにAttributedStringではテキストの UTF-8スカラとUTF-16スカラに 対するビューを得られるようになりました この2つのビューも 既存のすべてのビューと 同じインデックスを共有します

    ここまで インデックスと 選択について詳しく説明してきました ここでMaxがレシピアプリで 遭遇した問題を検討してみましょう IngredientSuggestionの onApplyクロージャは 属性付き文字列を変更しますが 選択のインデックスは 更新しません SwiftUIはこのインデックスが 無効になったことを検出し アプリのクラッシュを避けるために 選択範囲を テキスト末尾に移動します これを修正するには AttributedString APIを使用してテキスト変更時に インデックスと選択を更新します

    では このレシピアプリと同じ問題を持つ 簡単なコード例を使って説明しましょう 最初に テキスト内の「cooking」という 単語の範囲を特定します 次に「cooking」の範囲の前景色を オレンジ色に設定します さらにレシピっぽさを高めるため 文字列に 「chef」という単語を追加します

    テキストを変更すると AttributedStringの ツリーのレイアウトが変わる可能性があります

    文字列の変更後にはcookingRange変数の 使用が無効になり それを行うと アプリがクラッシュすることもあります AttributedStringではその代わりに 範囲または範囲の配列を受け取る transform関数を使用することができ さらに指定のAttributedStringを その場で変更するクロージャも使用できます

    クロージャの末尾では 指定した範囲が transform関数により 新しいインデックスで更新されるため 最終的なAttributedStringでは 範囲を正しく使用できます AttributedString内ではテキストが ずれる可能性がありますが 範囲は依然として 同じセマンティック位置を示します この場合は「cooking」になります SwiftUIでは 範囲ではなく選択を更新する 同様の関数も 用意されています

    きれいな形のクロワッサン生地が できあがりましたね アプリの作業に戻る準備ができたら この新しい変換関数を使ってみませんか コードを形作るのに役立つと思います ありがとう まさに私が求めていたもののようです さっそくコードに組み込んでみましょう

    まず こうやって範囲をループすべきでは ありませんでした 最後の範囲に到達するまでに テキストが何度も変更されますし インデックスは古くなってしまいます この問題は 最初にRangeをRangeSetに 変更することで完全に回避できます

    次にこれをスライスし ループを削除します

    こうすると すべてが1回で変更されるため 変更のたびに残りの範囲を 更新する必要がなくなります

    次に 変更したい範囲の横に カーソルの位置を表す 選択が配置されています これは常に 変換後のテキストと一致させる 必要があります それにはAttributedStringでSwiftUIの transform(updating:)オーバーロードを 使用します

    これで テキストが変更されると同時に 選択が更新されるようになります

    実際の動作を見てみましょう 「milk」を選択すると それがリストに表示されます 追加した後も 私の選択はそのまま残ります 確認のためにキーボードの 「command + B」を押してみます 「milk」が太字に変わりました うまくいったようです

    これでレシピのテキストに すべての情報が揃ったので 材料名に色を付けて 強調してみようと思います ありがたいことにTextEditorには そのための機能があります AttributedTextFormattingDefinition プロトコルです カスタムテキスト書式設定の 定義に使用するのは テキストエディタが応答する AttributedStringKeysと その値です AttributedTextFormattingDefinitionに 応じた型は すでにここで宣言されています

    デフォルトでは SwiftUIAttributesスコープが使用され 属性の値に対する制約はありません

    このレシピエディタのスコープでは

    前景色、ジェン文字、 カスタム材料属性のみを 許可することにします

    RecipeEditorに戻って attributedTextFormattingDefinition 修飾子を使用して 私のカスタム定義をSwiftUIの TextEditorに渡します

    この変更により TextEditorで 任意の材料、ジェン文字、前景色を 使用できるようになります

    他のすべての属性には デフォルト値が適用されますが 環境を変更することで エディタ全体のデフォルト値を 変更できることに注意してください 今回の変更で すでにいくつかの重要な変更が システム書式設定UIに加えられています 行揃え、行間、フォントプロパティを 変更するためのコントロールが 表示されなくなります 各機能に対応するAttributedStringKeysが スコープに含まれないからです ただしカラーコントロールを使えば テキストに任意の色を適用できます 無意味な色を 適用することも可能です

    おっと「milk」が消えてしまいました

    材料だけを緑色でハイライトして それ以外はすべて デフォルトの色を使うことにします このロジックの実装には SwiftUIの AttributedTextValueConstraint プロトコルを使用できます

    RecipeFormattingDefinition ファイルに戻って この制約を宣言します

    AttributedTextValueConstraint プロトコルに準拠するために それが属している AttributedTextFormattingDefinitionの スコープを指定し 次に制約対象のAttributedStringKeyを 指定します 今回はForegroundColorAttributeです 属性を制約する実際のロジックは constrain関数に組み込みます この関数では 有効と思われる前景色の値を AttributeKeyに設定します

    このロジックはingredient属性が 設定されているかどうかに依存します

    設定されている場合は前景色を緑にし

    そうでない場合はnilにします

    この場合 TextEditorは デフォルトの色を置き換える必要があります

    制約を定義できましたので あとは AttributedTextFormattingDefinitionの bodyにこれを追加するだけです

    その他の点については SwiftUIがすべて処理してくれます TextEditorは自動的に 定義とその制約を テキストの各所に適用して 画面に表示します

    これですべての材料の 文字色が緑になりました

    面白いことに 書式設定定義のスコープには 前景色が含まれているのに TextEditorのカラーコントロールは 無効になっています これはIngredientsAreGreen制約が 追加されているからです これでテキストの前景色は ingredient属性で マークされているかどうかによって 決まることになります TextEditorは自動的に AttributedTextValueConstraintsを調べて 現在の選択に対する変更が 有効かどうかを判断します たとえば「milk」の前景色を 再び白に設定しようとしても その後でIngredientsAreGreen制約を 実行すると 前景色がまた緑に戻ります そのため TextEditorはそれが無効な変更であると 認識して コントロールを無効にするのです 値の制約はエディタに 貼り付けるテキストにも適用されます 「command + C」で材料をコピーして 「command + V」でペーストしても カスタムingredient属性は そのまま保持されます CodableAttributedStringKeyを使用すると 別のアプリのTextEditor間でも これが機能します ただし双方のアプリの AttributedTextFormattingDefinitionで この属性が指定されている必要があります

    いい感じになりましたが まだ改善すべき点がいくつかあります 「milk」の末尾に カーソルを置いて 文字を削除したり入力を続けたりすると 通常のテキストのように扱われます これでは単なる緑色のテキストのようで 特定の材料名のように見えません この違和感をなくすために 材料名の後に入力するときには ingredient属性が拡大されないようにします そして材料名自体に変更を加えたときには 単語全体の前景色を 一気にリセットするようにします

    Jeremy あとでクロワッサンを1個 余分にあげるから この実装を手伝ってもらえませんか? 1個で足りるかどうかわからないけど 2、3個ならいいですよ お手伝いしましょう クロワッサンをオーブンに入れている間 この問題の解決に役立つ APIについてご説明します Maxが説明したように 書式設定定義の制約を利用すると 各TextEditorで表示可能な 属性と特定の値を 限定することができます レシピエディタに関するこの新しい 問題を解決するには AttributedStringKeyプロトコルの 追加のAPIを使用して 任意のAttributedStringを 変更する際の 属性値の変更方法を制限します 属性で制約を宣言すると AttributedStringによって常に 各属性とテキストコンテンツの 一貫性が確保されるため 単純で高速なコードを使用して 予期しない状態を回避できます 属性に対してこのようなAPIを 使用する場合について いくつかの例を挙げて説明します まず 値がAttributedStringの 他のコンテンツと関連付けられている属性 例えばスペルチェック属性等について いくつか例を挙げて説明します

    このスペルチェック属性は 「ready」という単語のスペルミスを 赤い破線の下線によって示しています テキストにスペルチェックを実行したら スペルチェック属性が 検証済みのテキストだけに 適用されていることを 確認する必要があります でも テキストエディタで入力を続けると デフォルトで 既存テキストの属性がすべて 挿入されたテキストに継承されます これはスペルチェック属性の 望ましい挙動ではありません これを修正するため AttributedStringKeyに 新しいプロパティを追加します AttributedStringKey型に対して値がfalseの inheritedByAddedTextプロパティを 宣言することで 追加したテキストが この属性値を継承しなくなります

    これで文字列に 新しいテキストを追加してみると このテキストにはスペルチェック属性が 含まれません 新しいテキストはまだスペルチェックを 実行していないからです 残念なことに この属性には また別の問題も見つかりました スペルミスとしてマークされた単語の途中に テキストを追加すると 追加したテキストの下で 赤い破線が不自然に途切れてしまいます この単語がまだスペルミスかどうかは 未チェックなので 望ましい処理は この単語からこの属性を削除して UIに古い情報が 表示されないようにすることです

    この問題を解決するために invalidationConditionsプロパティを AttributedStringKey型に追加します このプロパティは テキストからこの属性の実行を 削除するべき状況を宣言します AttributedStringは テキストの変更に関する条件と 特定の属性の変更に関する条件を 指定します 属性キーは任意の数の条件に応じて 無効化することができます この場合 この属性実行の テキストが変更されるたび この属性を削除する必要があるので 「textChanged」値を使用します

    これで属性実行の中に テキストを挿入してみると 実行全体で属性が無効化されるため UIの一貫性が損なわれることを 回避できます この2つのAPIは Maxのアプリでingredient属性を 有効に保つのに役立つかもしれません ではMaxがオーブンに生地を入れている間に もう1つのカテゴリの属性を紹介します この属性にはテキストのどのセクションでも 一貫した値が必要です たとえば 段落の揃え方向の属性です

    揃え方向はテキストの段落ごとに 適用できますが 段落内の1つの単語だけに 他と異なる揃え方向を 使用することはできません AttributedStringの変更中に この要件を適用するには AttributedStringKey型に対して runBoundariesプロパティを宣言します Foundationでは段落の端または 指定キャラクタの端に実行境界を 限定することができます この場合はこの属性が 段落境界で制約されるよう定義することで 段落の最初から最後まで 一貫した値を維持するように要求します

    これで この状況は起こらなくなります 段落内の1単語だけに 左揃えの値を適用すると AttributedStringが自動的に 段落全体に属性を 拡大します さらにalignment属性を列挙した場合 2つの連続する段落に 同じ属性値が含まれていても AttributedStringは 個々の段落を列挙します 他の実行境界も同じように動作します AttributedStringは 境界から境界へと値を拡大して 列挙された実行が 実行境界ごとにブレークされるようにします

    クロワッサンのいい香りが漂ってきました あとは焼けるのを待つだけなら このようなAPIを使って アプリの書式設定定義を補完し 望ましいカスタム属性の動作を 実現してみませんか それはまさに私が求めていた隠し味です クロワッサンは焼き上がりを待つだけなので 早速試してみましょう

    このカスタムIngredientAttributeに オプションの inheritedByAddedText要件を実装し 値をfalseにすることで 材料名の後に文字を入力しても 拡大されなくなります

    次に textChangedを含む invalidationConditionsを実装すると 材料名の文字を削除したときに その材料名が 認識されなくなります

    早速試してみましょう 「milk」の末尾に「y」を付けると 「y」は緑色ではなくなり 「milk」から1文字を削除すると 単語全体のingredient属性が 一度に削除されます AttributedTextFormattingDefinitionに 従って ForegroundColorAttributeは引き続き カスタムingredient属性の 動作に完全に従います

    ありがとうJeremy アプリが本当によくなりました いえいえ では 約束のクロワッサンは... ご心配なく もうすぐできあがります オーブンを見張っててくれませんか Lucaが持って行くんじゃないかと 少し心配なので Lucaの噂は聞いたことがありますよ ウィジェットとクロワッサンに 目がない男とか 私が見張っておきます では 私もオーブンを見に行く前に 最後のヒントをいくつかご紹介します このアプリは サンプルとしてダウンロードできますので SwiftUIの転送可能ラッパーを使用して ロスレスのドラッグアンドドロップや RTFDへのエクスポートを行ったり SwiftデータでAttributedStringを 維持したりする方法を確認できます AttributedStringはSwiftのオープンソース Foundationプロジェクトの一部です この実装をGitHubで探して その進化に貢献したり Swiftフォーラムのコミュニティで 質問したりもできます 新しいTextEditorでは アプリでのジェン文字入力が これまでになく 簡単になりましたので ぜひお試しください 皆さんがこのAPIを使用して アプリのテキスト編集機能を どのようにアップグレードされるか とても楽しみです 少しの工夫で大きく変わりますよ

    うん これはおいしい いや ここは「リッチ」ですよ

    • 1:15 - TextEditor and String

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: String
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 1:45 - TextEditor and AttributedString

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 4:43 - AttributedString Basics

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get "
      )
      
      var cooking = AttributedString("cooking")
      cooking.foregroundColor = .orange
      text += cooking
      
      text += AttributedString("?")
      
      text.font = .largeTitle
    • 5:36 - Build custom controls: Basics (initial attempt)

      import SwiftUI
      
      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())
          }
      }
    • 8:53 - Slicing AttributedString with a Range

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard let cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      text[cookingRange].foregroundColor = .orange
    • 10:50 - Slicing AttributedString with a RangeSet

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      let uppercaseRanges = text.characters
        .indices(where: \.isUppercase)
      
      text[uppercaseRanges].foregroundColor = .blue
    • 11:40 - Build custom controls: Basics (fixed)

      import SwiftUI
      
      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]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name))
          }
      }
    • 12:32 - Build custom controls: Recipe attribute

      import SwiftUI
      
      struct IngredientAttribute: CodableAttributedStringKey {
          typealias Value = Ingredient.ID
      
          static let name = "SampleRecipeEditor.IngredientAttribute"
      }
      
      extension AttributeScopes {
          /// An attribute scope for custom attributes defined by this app.
          struct CustomAttributes: AttributeScope {
              /// An attribute for marking text as a reference to an recipe's ingredient.
              let ingredient: IngredientAttribute
          }
      }
      
      extension AttributeDynamicLookup {
          /// The subscript for pulling custom attributes into the dynamic attribute lookup.
          ///
          /// This makes them available throughout the code using the name they have in the
          /// `AttributeScopes.CustomAttributes` scope.
          subscript<T: AttributedStringKey>(
              dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T>
          ) -> T {
              self[T.self]
          }
      }
    • 12:56 - Build custom controls: Modifying text (initial attempt)

      import SwiftUI
      
      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]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = text.characters.ranges(of: name.characters)
      
                      for range in ranges {
                          // modifying `text` without updating `selection` is invalid and resets the cursor 
                          text[range].ingredient = ingredientId
                      }
                  })
          }
      }
    • 17:40 - AttributedString Character View

      text.characters[index] // "👋🏻"
    • 17:44 - AttributedString Unicode Scalar View

      text.unicodeScalars[index] // "👋"
    • 17:49 - AttributedString Runs View

      text.runs[index] // "Hello 👋🏻! ..."
    • 18:13 - AttributedString UTF-8 View

      text.utf8[index] // "240"
    • 18:17 - AttributedString UTF-16 View

      text.utf16[index] // "55357"
    • 18:59 - Updating Indices during AttributedString Mutations

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard var cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      let originalRange = cookingRange
      text.transform(updating: &cookingRange) { text in
        text[originalRange].foregroundColor = .orange
        
        let insertionPoint = text
          .index(text.startIndex, offsetByCharacters: 6)
        
        text.characters
          .insert(contentsOf: "chef ", at: insertionPoint)
      }
      
      print(text[cookingRange])
    • 20:22 - Build custom controls: Modifying text (fixed)

      import SwiftUI
      
      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]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = RangeSet(text.characters.ranges(of: name.characters))
      
                      text.transform(updating: &selection) { text in
                          text[ranges].ingredient = ingredientId
                      }
                  })
          }
      }
    • 22:03 - Define your text format: RecipeFormattingDefinition Scope

      struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
          struct Scope: AttributeScope {
              let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
              let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
              let ingredient: IngredientAttribute
          }
      
          var body: some AttributedTextFormattingDefinition<Scope> {
      
          }
      }
      
      // pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`:
      
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
                  .attributedTextFormattingDefinition(RecipeFormattingDefinition())
    • 23:50 - Define your text format: AttributedTextValueConstraints

      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
              }
          }
      }
      
      // list the value constraint in the recipe formatting definition's body:
          var body: some AttributedTextFormattingDefinition<Scope> {
              IngredientsAreGreen()
          }
    • 29:28 - AttributedStringKey Constraint: Inherited by Added Text

      static let inheritedByAddedText = false
    • 30:12 - AttributedStringKey Constraint: Invalidation Conditions

      static let invalidationConditions:
        Set<AttributedString.AttributeInvalidationCondition>? =
        [.textChanged]
    • 31:25 - AttributedStringKey Constraint: Run Boundaries

      static let runBoundaries:
        AttributedString.AttributeRunBoundaries? =
        .paragraph
    • 32:46 - Define your text format: AttributedStringKey Constraints

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

Developer Footer

  • ビデオ
  • WWDC25
  • Code Along:SwiftUIのAttributedStringを使って優れたテキスト体験を実現
  • メニューを開く メニューを閉じる
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    Open Menu Close Menu
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • SF Symbols
    メニューを開く メニューを閉じる
    • アクセシビリティ
    • アクセサリ
    • App Extension
    • App Store
    • オーディオとビデオ(英語)
    • 拡張現実
    • デザイン
    • 配信
    • 教育
    • フォント(英語)
    • ゲーム
    • ヘルスケアとフィットネス
    • アプリ内課金
    • ローカリゼーション
    • マップと位置情報
    • 機械学習とAI
    • オープンソース(英語)
    • セキュリティ
    • SafariとWeb(英語)
    メニューを開く メニューを閉じる
    • 英語ドキュメント(完全版)
    • 日本語ドキュメント(一部トピック)
    • チュートリアル
    • ダウンロード(英語)
    • フォーラム(英語)
    • ビデオ
    Open Menu Close Menu
    • サポートドキュメント
    • お問い合わせ
    • バグ報告
    • システム状況(英語)
    メニューを開く メニューを閉じる
    • Apple Developer
    • App Store Connect
    • Certificates, IDs, & Profiles(英語)
    • フィードバックアシスタント
    メニューを開く メニューを閉じる
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program(英語)
    • News Partner Program(英語)
    • Video Partner Program(英語)
    • セキュリティ報奨金プログラム(英語)
    • Security Research Device Program(英語)
    Open Menu Close Menu
    • Appleに相談
    • Apple Developer Center
    • App Store Awards(英語)
    • Apple Design Awards
    • Apple Developer Academy(英語)
    • WWDC
    Apple Developerアプリを入手する
    Copyright © 2025 Apple Inc. All rights reserved.
    利用規約 プライバシーポリシー 契約とガイドライン