WWDC Quick Look 💓 By SwiftGGTeam
Dive deeper into Writing Tools

Dive deeper into Writing Tools

元の動画を見る

ハイライト

Writing Tools に今年新登場した Writing Tools Coordinator API により、独自のテキストエンジンでもインプレースでの書き換え、アニメーション、インラインの校正といった完全な体験を実現できるようになりました。


主要内容

昨年 Apple が発表した Writing Tools は、標準の UITextViewNSTextView であればそのまま利用できました。しかし、独自のテキストエンジンを実装している app(メモアプリ、コードエディタ、リッチテキストエディタなど)では、選択したテキストに対してパネル形式で結果を出し、Replace ボタンを押して置き換えるという、限定的な体験しか得られませんでした。結果はフローティングパネルに表示されるだけで、アニメーションもなく、インラインの校正アンダーラインも引かれず、純正の Mail や Notes と比べて明らかに見劣りする状態でした。

iOS、iPadOS、macOS 26 ではこの問題に対する答えが用意されました。それが UIWritingToolsCoordinator および NSWritingToolsCoordinator です。Coordinator は、テキストビューと Writing Tools の間のやり取りを引き受けてくれます。アプリ側は context(テキスト + 選択範囲)を提供し、Coordinator は delegate を呼び出してテキストの置換、アニメーションプレビュー、校正アンダーラインの描画、状態変化への応答を行います。最終的には、Rewrite 時にテキストが元のビューの中で直接アニメーションしながら置き換わり、Proofread 時には元のテキストの上にアンダーラインが描かれ、それをタップすると候補が表示される、という体験が実現できます。

Coordinator 以外にも、今年は Writing Tools が visionOS にも対応しました。さらに ChatGPT 連携(テキスト生成、画像生成)、書き換え後の Follow Up での追加リクエスト(「もう少し温かみのあるトーンに」「もっと口語的に」など)、Shortcuts からの自動化エントリも追加されています。標準のテキストビュー側でも API が拡充され、標準のツールバーボタン、標準メニュー項目 writingToolsItems、リッチテキスト用の presentationIntent 結果オプションが用意されました。


詳細

独自テキストエンジンの統合: Coordinator の三段構え

最初のステップは Coordinator をビューに紐付けることです。UIKit では UIInteraction、AppKit では NSView のインスタンスプロパティとして扱います (11:46)。

// Attach a coordinator to the view
// AppKit

func configureWritingTools() {
    guard NSWritingToolsCoordinator.isWritingToolsAvailable else { return }
       
    let coordinator = NSWritingToolsCoordinator(delegate: self)

    coordinator.preferredBehavior = .complete
    coordinator.preferredResultOptions = [.richText, .list]
    writingToolsCoordinator = coordinator
}

ポイント:

  • isWritingToolsAvailable は可用性のフォールバックとして使います。Apple Intelligence をサポートしていないデバイスでは早期 return しておき、以降の設定が無駄にならないようにします。
  • preferredBehavior = .complete はフル体験(インプレース書き換え + アニメーション + インライン校正)を意味します。これに対し .limited はパネルモードのみとなります。
  • preferredResultOptions ではビューが扱える結果のフォーマットを宣言します。.richText はボールド・イタリックといった装飾を表示でき、.list を加えればリストに、.table を加えればテーブルに対応します。.presentationIntent を加えるとセマンティックな意図ベースの表現になります。

二つ目のステップは context の提供です (13:06)。

// Prepare the context

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        requestsContextsFor scope: NSWritingToolsCoordinator.ContextScope,
        completion: @escaping ([NSWritingToolsCoordinator.Context]) -> Void) {

    var contexts = [NSWritingToolsCoordinator.Context]()
                
    switch scope {
    case .userSelection:
        let context = getContextObjectForSelection()
        contexts.append(context)
        break
        // other cases…
    }
        
    // Save references to the contexts for later delegate calls.
    storeContexts(contexts)
    completion(contexts)
}

ポイント:

  • delegate メソッドは completion handler を伴う非同期形式になっています。大きなドキュメントの場合、内部のテキストストレージの処理に時間がかかる可能性があるためです。
  • Context は二つの要素から構成されます。NSAttributedString のテキスト本文と、NSRange の選択範囲です。
  • 少なくとも現在選択中のテキストは含める必要があります。前後の段落も含めておけば、Writing Tools により多くの文脈を渡せます。
  • 選択範囲がない場合は、ドキュメント全体を context として返し、range はカーソル位置に設定します。これにより Writing Tools がドキュメント全体に対して操作できるようになります。
  • storeContexts(contexts) で context への参照を保持しておきます。後続の置換、プレビュー、校正のコールバックには同じ context オブジェクトが渡ってくるためです。

三つ目のステップはテキスト置換への応答です (13:48)。

// Respond to text changes from Writing Tools

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        replace range: NSRange,
        in context: NSWritingToolsCoordinator.Context,
        proposedText replacementText: NSAttributedString,
        reason: NSWritingToolsCoordinator.TextReplacementReason,
        animationParameters: NSWritingToolsCoordinator.AnimationParameters?,
        completion: @escaping (NSAttributedString?) -> Void) {
}

// Update selected range

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        select ranges: [NSValue],
        in context: NSWritingToolsCoordinator.Context,
        completion: @escaping () -> Void) {
}

ポイント:

  • replace メソッドは、Writing Tools が提案した結果をビューのテキストストレージに書き戻します。同じ context に対して複数回コールバックされることがあり、それぞれが独立した range への変更に対応します。
  • range は context 内のテキストに対する相対的な範囲なので、ビュー側のテキストストレージの座標系に変換する必要があります。
  • select メソッドは置換完了後に呼ばれ、新しいテキストに対して選択範囲を更新するためのものです。

アニメーションプレビュー: 置換を「動かす」

Writing Tools は処理中にアニメーション効果(発光やフローなど)を表示したがります。とはいえ、実際のビューを直接操作するわけではなく、アプリ側にプレビュー画像を要求して、その上に効果をオーバーレイするという仕組みになっています (14:41)。

// Generate preview for animation (macOS)

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        requestsPreviewFor textAnimation: NSWritingToolsCoordinator.TextAnimation,
        of range: NSRange,
        in context: NSWritingToolsCoordinator.Context,
        completion: @escaping ([NSTextPreview]?) -> Void) {
}
    
func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        requestsPreviewFor rect: NSRect,
        in context: NSWritingToolsCoordinator.Context,
        completion: @escaping (NSTextPreview?) -> Void) {
}

ポイント:

  • macOS では二つの delegate メソッドが用意されています。range に対してプレビューを配列で返すもの(最低 1 つ。1 行ごとに分けるとアニメーションがより滑らかになります)と、rect に対して 1 枚のプレビューを返すものです。
  • プレビューを描画する際、背景は必ず透明にする必要があります。アニメーション中、Writing Tools がプレビュー画像を元の位置に重ねて表示するためです。
  • iOS では UITargetedPreview を使い (14:58)、delegate メソッドは 1 つだけです。
  • アニメーションの前後には prepareFor / finish のペアとなるコールバックがあります (15:08)。開始前に該当 range の実際のテキストを非表示にし、終了後に再表示することで、プレビュー画像と実テキストが重なって二重に見えるのを防ぎます。

インライン校正: ベジェパスを返す

Proofread モードでは、元のビューにアンダーラインを描画する必要があります。Writing Tools は座標を自分で計算するのではなく、アプリにパスを要求します (15:39)。

// Create proofreading marks

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        requestsUnderlinePathsFor range: NSRange,
        in context: NSWritingToolsCoordinator.Context,
        completion: @escaping ([NSBezierPath]) -> Void) {
}

func writingToolsCoordinator(_ writingToolsCoordinator: NSWritingToolsCoordinator,
        requestsBoundingBezierPathsFor range: NSRange,
        in context: NSWritingToolsCoordinator.Context,
        completion: @escaping ([NSBezierPath]) -> Void) {
}

ポイント:

  • requestsUnderlinePathsFor はアンダーラインのパスを返します。Writing Tools はこれを使って校正マークを描画します。
  • requestsBoundingBezierPathsFor はその range を囲むヒット領域を返し、クリック・タップへの応答に使われます。ユーザがある候補をタップすると、修正の詳細がポップアップ表示されます。
  • ビューのレイアウトが変化した場合(ウインドウのリサイズ、フォント変更など)、updateForReflowedText を呼び出して Coordinator にパスやプレビューを再要求するよう通知します。
  • 外部からテキストが変更された場合(Undo、外部からの同期など)には、updateRange:withText を呼んで Writing Tools の状態を同期させます。

Display Attributes vs Presentation Intents

リッチテキストの場面では選択を迫られます (06:48)。.richText を単独で使う場合、Writing Tools はスタイルを display attribute として表現します。具体的なフォントサイズ、ボールド、イタリックといった情報が NSAttributedString の属性に直接書き込まれる方式で、TextEdit はこのモードを採用しています。

.presentationIntent を加えると、Writing Tools はセマンティックな意図に切り替えます。見出し段落には header intent が付与され、具体的なフォントサイズは含まれません。リストには list intent、コードブロックには code block intent が付きます。Notes はこの方式を採用したうえで、intent を Notes 独自のセマンティックなスタイルシステムに変換しています。

要点として、presentation intent はデフォルトのスタイルを持たず、レンダリングはアプリ側の責任になります。一部のスタイル(アンダーライン、上付き、下付き)は intent では表現できないため、display attribute にフォールバックします。また、presentation intent を有効にした場合、requestContexts をオーバーライドして、既存の段落の intent も Writing Tools に渡してあげる必要があります。


重要ポイント

  • テキスト主体の app に Writing Tools のツールバーボタンを追加する: なぜ取り組む価値があるか — 選択してから呼び出す動線は、ユーザに気付かれない可能性があります。Notes や Mail のようにツールバーに分かりやすいエントリを置けば、利用率は明らかに向上します。始め方: UIKit では UIBarButtonItem、AppKit では NSToolbarItem を使います。標準 API にはアイコンとローカライゼーションが組み込まれています。

  • メニュー項目は writingToolsItems API で手動配置に変更する: なぜ取り組む価値があるか — 今年は Proofread / Rewrite / Summary のメニュー構造が変わっており、自動挿入の位置が独自のメニュー設計と合わない場合があります。API で標準項目を取得して自前で配置すれば、今年や来年のアップデートにも自動的に追従できます。始め方: automaticallyInsertsWritingToolsItemsfalse にして、writingToolsItems で標準項目を取得し、自分のメニュー構造に合わせて挿入するだけです。

  • 独自テキストエンジンを Coordinator のフル体験にアップグレードする: なぜ取り組む価値があるか — 基本的な対応だけではパネルモードしか使えず、体験の差は明らかです。Coordinator が提供するインプレースのアニメーションとインライン校正を取り入れれば、システム app と同等の体験になります。始め方: Apple のサンプルコード「Enhancing your custom text engine with Writing Tools」を参考に、コアとなる 6 つの delegate(context、replace、select、preview、underline path、state change)を実装します。

  • リッチテキストエディタでは .presentationIntent を検討する: なぜ取り組む価値があるか — アプリ側に独自のセマンティックなスタイルシステム(見出し、サブタイトル、引用ブロックなど)が既にある場合、intent ベースの構造化された結果の方が、具体的なフォントサイズをパースするより内部スタイルへのマッピングが堅牢になります。始め方: preferredResultOptions.presentationIntent を加え、requestContexts をオーバーライドして既存段落の intent を渡し、intent から内部スタイルへのマッピング関数を 1 層作ります。

  • delegate メソッドは本物の非同期として実装する: なぜ取り組む価値があるか — 大きなドキュメントのプレビュー生成やアンダーラインのパス計算は時間のかかる処理です。completion handler をブロックするとアニメーションがカクつきます。始め方: プレビューのレンダリングはバックグラウンドキューで行い、計算が終わったらメインスレッドにコールバックします。replace の処理はテキストストレージのバッチ編集 API を使い、文字単位でのレイアウトトリガを避けましょう。


関連セッション

コメント

GitHub Issues · utterances