WWDC Quick Look 💓 By SwiftGGTeam
TextKit でアプリのテキスト体験を高める

TextKit でアプリのテキスト体験を高める

元の動画を見る

ハイライト

UITextView と NSTextView は TextKit の内部 hook を公開するようになりました。これにより開発者は、selection、undo、dictation など framework が提供する editing 機能を手放さずに、line number や collapsible section などの custom behavior を追加できます。

主要内容

Apple platform で text editing を作るとき、長い間 2 択の問題がありました。UITextView / NSTextView を使うか、自分で text view を作るかです。

Framework の text view はそのまま使えます。Text input、selection、accessibility、undo/redo、dictation、inline prediction などはすべて無料で手に入ります。ただし代償として customization 能力は限られます。Line number の描画方法を変えたい、text の横に custom view を追加したい、といったことは難しかったのです。

もう一つの選択肢は、TextKit を使って custom text view をゼロから構築することです。NSTextLayoutManager を設定し、viewport layout を自分で処理し、すべての content を自分で render します。制御権はすべて手に入りますが、framework が提供してくれていた機能も失います。Production-grade の text editor を自前で実装するのは小さな作業ではありません。

TextKit の 4 層 architecture は、このジレンマを説明します。Storage layer は text data、layout layer は分割と測定、viewport layer は visible content の追跡、view layer は実際の rendering を担当します。最初の 3 層は framework 間で共有されますが、view layer は自分で用意する部分です。

2027 年の update はこの壁を取り払います。UITextViewNSTextViewNSTextViewportLayoutControllerDelegate に準拠するようになり、subclass 化して viewport layout の callback method を override できます。つまり framework のすべての機能を保ったまま、自分の描画 logic を注入できます。

詳細

TextKit architecture の振り返り

TextKit の rendering flow は 4 層に分かれます(00:17)。

  1. Storage layer: NSTextContentStorageNSAttributedStringNSTextParagraph object に分解します。
  2. Layout layer: NSTextLayoutManager が glyph を測定し、不変の NSTextLayoutFragment を作成します。
  3. Viewport layer: NSTextViewportLayoutController が visible region を追跡し、ユーザーに見えている内容だけを render します。
  4. View layer: 実際に text を render する UIViewNSView、または CALayer です。

この architecture の中心思想は on-demand rendering です。Viewport layout process は、現在の scroll position と交差する layout fragment だけを処理します。

新しい Rendering Surface API

2027 年以前、TextKit には rendering target view を参照する統一的な方法がありませんでした(09:47)。新しい 2 つの protocol がこの問題を解決します。

class MyView: UIView, NSTextViewportRenderingSurface {}

ポイント:

  • NSTextViewportRenderingSurface: viewport 内で描画可能な visual element を表します。
  • NSTextViewportRenderingSurfaceKey: rendering surface を一意に識別する key で、NSTextLayoutFragment を key として使えます。

NSMapTable を cache として組み合わせます。

var cache: NSMapTable<NSTextLayoutFragment, MyView>

これにより viewport layout の間、各 layout fragment に対応する rendering view を追跡できます。

SwiftUI で UITextView を使う

SwiftUI の TextEditor は simple な場面に向いていますが、UITextView の完全な機能が必要な場合もあります(12:39)。

import SwiftUI

struct MyTextView: View {
    var body: some View { TextViewRepresentable() }
}

#if os(macOS)
struct TextViewRepresentable: NSViewRepresentable {
    func makeNSView(context: Context) -> NSTextView {
      NSTextView()
    }
    func updateNSView(_ nsView: NSTextView, context: Context) {}
}
#else
struct TextViewRepresentable: UIViewRepresentable {
    func makeUIView(context: Context) -> UITextView {
        UITextView()
    }
    func updateUIView(_ uiView: UITextView, context: Context) {}
}
#endif

ポイント:

  • macOS では NSViewRepresentable、iOS/Mac Catalyst では UIViewRepresentable を使います。
  • make method で text view を初期化します。
  • update method で view を同期します。

Line number 機能を追加する

講演の最初の例は、iPad 向け code editor を作り、line number を追加するものです(13:33)。

import UIKit

class TextView: UITextView {}

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    textView.font = UIFont.monospacedSystemFont
}

核心は、TextView の 3 つの viewport controller delegate method を override することです(14:42)。

class TextView: UITextView {
    // Setup phase: state を reset し、starting line number を計算
    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    // 各 paragraph の位置情報を収集
    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    // 蓄積した情報を ContainerView に渡す
    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }
}

Starting line number は enumerateTextElements で計算します(15:59)。

func startingLineNumber(for viewportRange: NSTextRange?) -> Int {
    guard let viewportRange,
          let storage = textLayoutManager?.textContentManager as? NSTextContentStorage else { return 0 }
    let startLocation = storage.documentRange.location
    var count = 1
    storage.enumerateTextElements(from: startLocation) { element in
        guard let range = element.elementRange else { return true }
        if range.location.compare(viewportRange.location) != .orderedAscending { return false }
        count += 1
        return true
    }
    return count
}

ポイント:

  • enumerateTextElements は text element を走査し、false を返すと走査を止めます。
  • 位置を比較して viewport の開始位置に到達したか判断します。
  • 実際の実装では cache を追加し、layout ごとの再計算を避けるべきです。

DidLayout method では、coordinate を text container から viewport へ変換する必要があります(17:02)。

class TextView: UITextView {
    private var lines: [CGRect] = []
    private var startingLineNumber = 0
    var onDidLayout: ((Int, [CGRect]) -> Void)?

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(controller)
        let origin = controller.viewportBounds.origin
        onDidLayout?(startingLineNumber, lines.map { $0.offsetBy(dx: 0, dy: -origin.y) })
    }
}

ContainerView で line number を描画します(17:16)。

class ContainerView: UIView {
    let textView = TextView()
    let lineNumberView = UIView()

    func setup() {
        textView.onDidLayout = { startingLineNumber, lines in
            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular),
                .foregroundColor: UIColor.secondaryLabel
            ]
            for (i, frame) in lines.enumerated() {
                let number = "\(startingLineNumber + i)" as NSString
                number.draw(at: CGPoint(x: 8, y: frame.minY), withAttributes: attributes)
            }
        }
    }
}

ポイント:

  • onDidLayout closure は TextView の layout 情報を ContainerView に渡します。
  • NSString.draw(withAttributes:) を使って text を直接描画します。
  • frame.minY で line number と対応する行を揃えます。

Collapsible section を実装する

2 つ目の例は recipe app で、複数 paragraph の recipe を見出しだけに折りたたむものです(19:22)。

class TextView: UITextView, NSTextContentStorageDelegate {
    var collapsedSections: Set<Int> = []

    override func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerWillLayout(textViewportLayoutController)
        // ...
    }

    override func textViewportLayoutController(_ textViewportLayoutController: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
        super.textViewportLayoutController(textViewportLayoutController, configureRenderingSurfaceFor: textLayoutFragment)
        // ...
    }

    override func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) {
        super.textViewportLayoutControllerDidLayout(textViewportLayoutController)
        // ...
    }

    // 折りたたまれた paragraph の layout を skip
    func textContentManager(shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions) -> Bool {
        // ...
    }

    // Section collapse の切り替えを処理
    func toggleSection(headerOffset: Int) {
        if collapsedSections.contains(headerOffset) {
            collapsedSections.remove(headerOffset)
        } else {
            collapsedSections.insert(headerOffset)
        }
        guard let textLayoutManager = textLayoutManager else { return }

        let textViewportLayoutController = textLayoutManager.textViewportLayoutController
        textViewportLayoutController.delegate?.textViewportLayoutControllerReceivedSetNeedsLayout?(textViewportLayoutController)
    }
}

ポイント:

  • NSTextContentStorageDelegateshouldEnumerate method は、ある text element に layout を実行するかどうかを制御します。
  • collapsedSections は折りたたまれた paragraph offset を Set に保存します。
  • 折りたたみ状態を切り替えた後、textViewportLayoutControllerReceivedSetNeedsLayout を呼んで再 layout を trigger します。

Text attachment view provider の再利用

Inline image や sticker などの text attachment は NSTextAttachmentViewProvider を通じて render されます。これらの object は immutable であるため、paragraph を編集すると再作成され、animation などの state が失われます。

2027 年の新しい registration API では reuse strategy を指定できます(22:06)。

import UIKit

class ViewController: UIViewController {
    var textView: UITextView

    func setupTextView() {
        textView = UITextView()
        textView.register(
            [.onEditingInlineParagraphs],
            forTextAttachmentViewProviderType: AnimatedAttachmentViewProvider.self
        )
    }
}

ポイント:

  • onEditingInlineParagraphs: paragraph 編集中に view provider を保持します。
  • onScrollingOutOfViewport: viewport 外へ scroll したときに cache し、戻ってきたら復元します。
  • 2 つの strategy は組み合わせて使えます。

主要な示唆

  1. Code editor enhancement: Line number の例をベースに、syntax highlighting、code folding、error hint をさらに実装できます。configureRenderingSurfaceFor を override すると、特定 text range を認識して異なる rendering style を適用できます。

  2. Rich text document editor: Collapsible section の考え方は長文 document の chapter management に使えます。NSTextContentStorageDelegate と組み合わせることで、outline view と content の同期、chapter navigation、progressive loading を実現できます。

  3. Collaborative note-taking app: Text attachment reuse strategy を使って、real-time collaboration に対応した note app を構築できます。Cursor indicator、user avatar、inline comment はすべて text attachment として表現でき、reuse strategy により編集中のちらつきを防げます。

  4. Custom text effect: Rendering Surface API を使って、特定 text range に background animation、border highlight、gesture response を追加できます。Creative writing app や教育用途の interactive text に適しています。

  5. Synchronized multi-view editing: 複数の NSTextLayoutManager を同じ NSTextContentStorage に接続します。Main editing area と preview area の real-time 同期、または左右対照の reading mode を実現できます。

関連 Session

コメント

GitHub Issues · utterances