ハイライト
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 はこの壁を取り払います。UITextView と NSTextView は NSTextViewportLayoutControllerDelegate に準拠するようになり、subclass 化して viewport layout の callback method を override できます。つまり framework のすべての機能を保ったまま、自分の描画 logic を注入できます。
詳細
TextKit architecture の振り返り
TextKit の rendering flow は 4 層に分かれます(00:17)。
- Storage layer:
NSTextContentStorageがNSAttributedStringをNSTextParagraphobject に分解します。 - Layout layer:
NSTextLayoutManagerが glyph を測定し、不変のNSTextLayoutFragmentを作成します。 - Viewport layer:
NSTextViewportLayoutControllerが visible region を追跡し、ユーザーに見えている内容だけを render します。 - View layer: 実際に text を render する
UIView、NSView、または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を使います。 makemethod で text view を初期化します。updatemethod で 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)
}
}
}
}
ポイント:
onDidLayoutclosure は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)
}
}
ポイント:
NSTextContentStorageDelegateのshouldEnumeratemethod は、ある 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 は組み合わせて使えます。
主要な示唆
-
Code editor enhancement: Line number の例をベースに、syntax highlighting、code folding、error hint をさらに実装できます。
configureRenderingSurfaceForを override すると、特定 text range を認識して異なる rendering style を適用できます。 -
Rich text document editor: Collapsible section の考え方は長文 document の chapter management に使えます。
NSTextContentStorageDelegateと組み合わせることで、outline view と content の同期、chapter navigation、progressive loading を実現できます。 -
Collaborative note-taking app: Text attachment reuse strategy を使って、real-time collaboration に対応した note app を構築できます。Cursor indicator、user avatar、inline comment はすべて text attachment として表現でき、reuse strategy により編集中のちらつきを防げます。
-
Custom text effect: Rendering Surface API を使って、特定 text range に background animation、border highlight、gesture response を追加できます。Creative writing app や教育用途の interactive text に適しています。
-
Synchronized multi-view editing: 複数の
NSTextLayoutManagerを同じNSTextContentStorageに接続します。Main editing area と preview area の real-time 同期、または左右対照の reading mode を実現できます。
関連 Session
- What’s new in SwiftUI — SwiftUI text control の新機能
- Meet SwiftUI — SwiftUI view と UIKit/AppKit の統合
- What’s new in AppKit — AppKit の modern NSTextView update
- SwiftUI + UIKit integration — ViewRepresentable の best practice
- Advanced UI techniques — Custom view controller interaction
コメント
GitHub Issues · utterances