mcpField
A
axiom-textkit-ref

axiom-textkit-ref

github.com/charleswiltgen開発1.2k173 回インストール今日更新
リポジトリ

axiom-textkit-ref について

TextKit 2 のアーキテクチャ、TextKit 1 からの移行、Writing Tools 連携、SwiftUI TextEditor を扱う Swift 開発者向けのリファレンススキルです。ビューポートレイアウト、選択範囲、カスタムレイアウトフラグメントも解説します。

主な機能

  1. 01TextKit 2 の構造と主要型
  2. 02TextKit 1 からの移行ガイド
  3. 03ビューポートレイアウトとテキスト範囲
  4. 04Writing Tools との連携
  5. 05カスタムレイアウトと描画

活用シーン

  1. 01テキストビューを TextKit 2 に移行
  2. 02表示領域内のテキストレイアウトを管理
  3. 03Writing Tools の編集機能を統合
  4. 04独自のテキスト背景や添付要素を作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-textkit-ref

インストール後、Claude Code で/axiom-textkit-refを入力して読み込まれたか確認します。

詳細

TextKit 2 リファレンス

TextKit 2 のアーキテクチャ、TextKit 1 からの移行、Writing Tools との統合、iOS 26 までの AttributedString を使った SwiftUI TextEditor を網羅した完全なリファレンスです。

アーキテクチャ

TextKit 2 は MVC パターンを採用し、正確性、安全性、パフォーマンスに最適化された新しいクラスを使用します。

モデル層

NSTextContentManager(抽象クラス)

  • バッキングストアから NSTextElement オブジェクトを生成します
  • ドキュメント内の要素範囲を追跡します
  • デフォルトの実装:NSTextContentStorage

NSTextContentStorage

  • NSTextStorage をバッキングストアとして使用します
  • コンテンツを NSTextParagraph 要素に自動的に分割します
  • テキストが変更されると、更新された要素を生成します

NSTextElement(抽象クラス)

  • コンテンツの一部(段落、添付ファイル、カスタム型)を表します
  • 不変の値セマンティクスを持ちます
  • 作成後にプロパティを変更することはできません
  • デフォルトの実装:NSTextParagraph

NSTextParagraph

  • 1 つの段落を表します
  • ドキュメント内の範囲を含みます
コントローラー層

NSTextLayoutManager

  • TextKit 1 の NSLayoutManager を置き換えます
  • グリフ API はありません(グリフを完全に抽象化します)
  • 要素を受け取り、コンテナ内にレイアウトして、レイアウトフラグメントを生成します
  • 常に非連続レイアウトを使用します

NSTextLayoutFragment

  • 1 つ以上の要素に対する不変のレイアウト情報です
  • 主なプロパティ:
    • textLineFragments — NSTextLineFragment の配列
    • layoutFragmentFrame — コンテナ内のレイアウト境界
    • renderingSurfaceBounds — 実際の描画境界(フレームを超える場合があります)

NSTextLineFragment

  • 1 行のテキストに関する測定情報です
  • 行数のカウントや幾何学的な問い合わせに使用します
ビュー層

NSTextViewportLayoutController

  • ビューポートレイアウトの信頼できる情報源です
  • 表示部分のみのレイアウトを調整します
  • デリゲートメソッドを呼び出します:willLayout、configureRenderingSurface、didLayout

NSTextContainer

  • レイアウト先の幾何学的な情報を提供します
  • 除外パス(非矩形レイアウト)を定義できます
オブジェクトベースの範囲

NSTextLocation(プロトコル)

  • テキスト内の単一の位置を表します
  • 整数インデックスを置き換えます
  • 構造化ドキュメント(ネストされた要素を含む DOM など)をサポートします

NSTextRange

  • 開始位置と終了位置(終了位置は含みません)
  • ネストされた構造を表現できます
  • 非線形ドキュメントでは NSRange と互換性がありません

NSTextSelection

  • 次の情報を含みます:粒度、親和性、場合によっては分離した範囲
  • 読み取り専用のプロパティ
  • 不変の値セマンティクスを持ちます

NSTextSelectionNavigation

  • 選択範囲に対する操作を実行します
  • 新しい NSTextSelection インスタンスを返します
  • 双方向テキストを正しく処理します

主な設計原則

1. 正確性 — グリフ API なし

WWDC 2021 より:

「TextKit 2 はグリフ処理を抽象化し、国際的なテキストに一貫した使用体験を提供します。」

グリフがない理由

問題: カンナダ語やアラビア語などの文字体系では:

  • 1 つのグリフが複数の文字を表す場合があります(合字)
  • 1 つの文字が複数のグリフに分かれる場合があります
  • 字形形成の過程でグリフの順序が変わります
  • 文字とグリフの正確な対応付けはできません

例(カンナダ語の単語「October」):

  • 4 番目の文字が 2 つのグリフに分かれます
  • 合字の適用前にグリフの順序が変わります
  • 3 番目のグリフが結合形になり、別のグリフの下に移動します

解決策: グリフインデックスの代わりに、NSTextLocation、NSTextRange、NSTextSelection を使用します。

2. 安全性 — 値セマンティクス

不変オブジェクト:

  • NSTextElement
  • NSTextLayoutFragment
  • NSTextLineFragment
  • NSTextSelection

メリット:

  • 意図しない共有がありません
  • 変更による副作用がありません
  • 状態を把握しやすくなります

パターン: レイアウトや選択範囲を変更するには、目的の変更を反映した新しいインスタンスを作成します。

3. パフォーマンス — ビューポートレイアウト

常に非連続レイアウト: TextKit 2 は、表示されているコンテンツとオーバースクロール領域のみをレイアウトします。

TextKit 1:

  • 非連続レイアウトは任意(ブール値のプロパティ)
  • レイアウト状態を確認できない
  • レイアウトする部分を制御できない

TextKit 2:

  • 常に非連続レイアウト
  • ビューポートが表示領域を定義
  • ビューポートの一貫したレイアウト情報
  • ビューポートのレイアウト更新に関する通知

ビューポートのデリゲートメソッド:

  1. textViewportLayoutControllerWillLayout(_:) — レイアウト前の準備
  2. textViewportLayoutController(_:configureRenderingSurfaceFor:) — フラグメントごとの処理
  3. textViewportLayoutControllerDidLayout(_:) — レイアウト後のクリーンアップ

TextKit 1 からの移行

主なパラダイムシフト
TextKit 1TextKit 2
グリフ要素
NSRangeNSTextLocation/NSTextRange
NSLayoutManagerNSTextLayoutManager
グリフ APIグリフ API なし
非連続レイアウトは任意常に非連続レイアウト
NSTextStorage に直接アクセスNSTextContentManager 経由
API 名の見分け方

WWDC 2022 より:

  • 名前に .offset が含まれる → TextKit 1
  • 名前に .location が含まれる → TextKit 2
NSRange ↔ NSTextRange の変換

NSRange → NSTextRange:

// UITextView/NSTextView
let nsRange = NSRange(location: 0, length: 10)

// Via content manager
let startLocation = textContentManager.location(
    textContentManager.documentRange.location,
    offsetBy: nsRange.location
)!
let endLocation = textContentManager.location(
    startLocation,
    offsetBy: nsRange.length
)!
let textRange = NSTextRange(location: startLocation, end: endLocation)

NSTextRange → NSRange:

let startOffset = textContentManager.offset(
    from: textContentManager.documentRange.location,
    to: textRange.location
)
let length = textContentManager.offset(
    from: textRange.location,
    to: textRange.endLocation
)
let nsRange = NSRange(location: startOffset, length: length)
グリフ API の置き換え

直接対応するグリフ API はありません。 より高レベルの構造を使用する必要があります。

例(TextKit 1 — 行数のカウント):

// TextKit 1 - iterate glyphs
var lineCount = 0
let glyphRange = layoutManager.glyphRange(for: textContainer)
for glyphIndex in glyphRange.location..<NSMaxRange(glyphRange) {
    let lineRect = layoutManager.lineFragmentRect(
        forGlyphAt: glyphIndex,
        effectiveRange: nil
    )
    // Count unique rects...
}

置き換え(TextKit 2 — フラグメントの列挙):

// TextKit 2 - enumerate layout fragments
var lineCount = 0
textLayoutManager.enumerateTextLayoutFragments(
    from: textLayoutManager.documentRange.location,
    options: [.ensuresLayout]
) { fragment in
    lineCount += fragment.textLineFragments.count
    return true
}
互換モード(UITextView/NSTextView)

TextKit 1 への自動フォールバック: .layoutManager プロパティにアクセスすると発生します。

警告(WWDC 2022):

「textView.layoutManager にアクセスすると、TK1 にフォールバックします」

フォールバックが発生すると:

  • TextKit 2 に自動で戻る方法はありません
  • 切り替えにはコストがかかります
  • UI 状態(選択範囲、スクロール位置)が失われます
  • 一方向の操作です

フォールバックを防ぐ:

  1. まず .textLayoutManager(TextKit 2)を確認します
  2. else 節でのみ .layoutManager にアクセスします
  3. TK1 が必要な場合は初期化時にオプトアウトします
// Check TextKit 2 first
if let textLayoutManager = textView.textLayoutManager {
    // TextKit 2 code
} else if let layoutManager = textView.layoutManager {
    // TextKit 1 fallback (old OS versions)
}

フォールバックのデバッグ:

  • UIKit: _UITextViewEnablingCompatibilityMode にブレークポイントを設定します
  • AppKit: willSwitchToNSLayoutManagerNotification を監視します
NSTextView のオプトイン(macOS)

TextKit 2 の NSTextView を作成する:

let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer()
textLayoutManager.textContainer = textContainer

let textView = NSTextView(frame: .zero, textContainer: textContainer)
// textView.textLayoutManager now available

新しい便利なイニシャライザー:

// iOS 16+ / macOS 13+
let textView = UITextView(usingTextLayoutManager: true)
let nsTextView = NSTextView(usingTextLayoutManager: true)

デリゲートフック

NSTextContentStorageDelegate

ストレージを変更せずに属性をカスタマイズする:

func textContentStorage(
    _ textContentStorage: NSTextContentStorage,
    textParagraphWith range: NSRange
) -> NSTextParagraph? {
    // Modify attributes for display
    var attributedString = textContentStorage.attributedString!
        .attributedSubstring(from: range)

    // Add custom attributes
    if isComment(range) {
        attributedString.addAttribute(
            .foregroundColor,
            value: UIColor.systemIndigo,
            range: NSRange(location: 0, length: attributedString.length)
        )
    }

    return NSTextParagraph(attributedString: attributedString)
}

要素をフィルタリングする(コンテンツの表示/非表示):

func textContentManager(
    _ textContentManager: NSTextContentManager,
    shouldEnumerate textElement: NSTextElement,
    options: NSTextContentManager.EnumerationOptions
) -> Bool {
    // Return false to hide element
    if hideComments && isComment(textElement) {
        return false
    }
    return true
}
NSTextLayoutManagerDelegate

カスタムレイアウトフラグメントを提供する:

func textLayoutManager(
    _ textLayoutManager: NSTextLayoutManager,
    textLayoutFragmentFor location: NSTextLocation,
    in textElement: NSTextElement
) -> NSTextLayoutFragment {
    // Return custom fragment for special styling
    if isComment(textElement) {
        return BubbleLayoutFragment(
            textElement: textElement,
            range: textElement.elementRange
        )
    }
    return NSTextLayoutFragment(
        textElement: textElement,
        range: textElement.elementRange
    )
}
NSTextViewportLayoutController.Delegate

ビューポートレイアウトのライフサイクル:

func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
    // Prepare for layout: clear sublayers, begin animation
}

func textViewportLayoutController(
    _ controller: NSTextViewportLayoutController,
    configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment
) {
    // Update geometry for each visible fragment
    let layer = getOrCreateLayer(for: textLayoutFragment)
    layer.frame = textLayoutFragment.layoutFragmentFrame
    // Animate to new position if needed
}

func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
    // Finish: commit animations, update scroll indicators
}

実践的なパターン

カスタムレイアウトフラグメント(吹き出しの背景)
class BubbleLayoutFragment: NSTextLayoutFragment {
    override func draw(at point: CGPoint, in context: CGContext) {
        // Draw custom background
        context.setFillColor(UIColor.systemIndigo.cgColor)
        let bubblePath = UIBezierPath(
            roundedRect: layoutFragmentFrame,
            cornerRadius: 8
        )
        context.addPath(bubblePath.cgPath)
        context.fillPath()

        // Draw text on top
        super.draw(at: point, in: context)
    }
}
レンダリング属性(一時的なスタイル設定)

テキストストレージを変更せずに属性を追加する:

textLayoutManager.addRenderingAttribute(
    .foregroundColor,
    value: UIColor.green,
    for: ingredientRange
)

// Remove when no longer needed
textLayoutManager.removeRenderingAttribute(
    .foregroundColor,
    for: ingredientRange
)
UIView を使ったテキスト添付ファイル
// iOS 15+
let attachment = NSTextAttachment()
attachment.image = UIImage(systemName: "star.fill")

// Provide view for interaction
class AttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        super.loadView()
        let button = UIButton(type: .system)
        button.setTitle("Tap me", for: .normal)
        button.addTarget(self, action: #selector(didTap), for: .touchUpInside)
        view = button
    }

    @objc func didTap() {
        // Handle tap
    }
}
リストとテーブル
// Create list
let listItem = NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.textLists = [listItem]

attributedString.addAttribute(
    .paragraphStyle,
    value: paragraphStyle,
    range: range
)

NSTextList は UIKit で利用できます(iOS 16 以降)。以前は AppKit のみでした。

ヒットテストと選択範囲のジオメトリ
// Get text range at point
let location = textLayoutManager.location(
    interactingAt: point,
    inContainerAt: textContainer.location
)

// Get bounding rect for range
var boundingRect = CGRect.zero
textLayoutManager.enumerateTextSegments(
    in: textRange,
    type: .standard,
    options: []
) { segmentRange, segmentRect, baselinePosition, textContainer in
    boundingRect = boundingRect.union(segmentRect)
    return true
}

Writing Tools(iOS 18 以降)

基本的な統合(TextKit 2 が必要)

WWDC 2024 より:

「Writing Tools のすべての機能をサポートするには、UITextView または NSTextView で TextKit 2 を使用する必要があります。TextKit 1 を使用している場合は、書き換え結果をパネルに表示するだけの限定的な機能になります。」

ネイティブのテキストビューでは無料で利用可能:

// UITextView, NSTextView, WKWebView
// Writing Tools appears automatically
ライフサイクルのデリゲートメソッド
func textViewWritingToolsWillBegin(_ textView: UITextView) {
    // Pause syncing, prevent edits
    isSyncing = false
}

func textViewWritingToolsDidEnd(_ textView: UITextView) {
    // Resume syncing
    isSyncing = true
}

// Check if active
if textView.isWritingToolsActive {
    // Don't persist text storage
}
動作の制御
// Opt out completely
textView.writingToolsBehavior = .none

// Panel-only experience (no in-line edits)
textView.writingToolsBehavior = .limited

// Full experience (default)
textView.writingToolsBehavior = .default
結果のオプション
// Plain text only
textView.writingToolsResultOptions = [.plainText]

// Rich text
textView.writingToolsResultOptions = [.richText]

// Rich text + tables
textView.writingToolsResultOptions = [.richText, .table]

// Rich text + lists
textView.writingToolsResultOptions = [.richText, .list]
保護された範囲
// UITextViewDelegate / NSTextViewDelegate
func textView(
    _ textView: UITextView,
    writingToolsIgnoredRangesIn enclosingRange: NSRange
) -> [NSRange] {
    // Return ranges that Writing Tools should not modify
    return codeBlockRanges + quoteRanges
}

WKWebView: <blockquote> タグと <pre> タグは自動的に無視されます。

Writing Tools Coordinator(iOS 26 以降)

カスタムテキストエンジン向けの高度な統合です。

セットアップ
// UIKit
let coordinator = UIWritingToolsCoordinator()
coordinator.delegate = self
textView.addInteraction(coordinator)
coordinator.writingToolsBehavior = .default
coordinator.writingToolsResultOptions = [.richText]

// AppKit
let coordinator = NSWritingToolsCoordinator()
coordinator.delegate = self
customView.writingToolsCoordinator = coordinator
Coordinator のデリゲート

コンテキストを提供する:

func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    requestContexts scope: NSWritingToolsCoordinator.ContextScope
) async -> [NSWritingToolsCoordinator.Context] {
    // Return attributed string + selection range
    let context = NSWritingToolsCoordinator.Context(
        attributedString: currentText,
        range: currentSelection
    )
    return [context]
}

変更を適用する:

func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    replace context: NSWritingToolsCoordinator.Context,
    range: NSRange,
    with attributedString: NSAttributedString
) async {
    // Update text storage
    textStorage.replaceCharacters(in: range, with: attributedString)
}

選択範囲を更新する:

func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    updateSelectedRange selectedRange: NSRange,
    in context: NSWritingToolsCoordinator.Context
) async {
    // Update selection
    self.selectedRange = selectedRange
}

アニメーション用のプレビューを提供する:

// macOS
func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    previewsFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSTextPreview] {
    // Return one preview per line for smooth animation
    return textLines.map { line in
        NSTextPreview(
            image: renderImage(for: line),
            frame: line.frame
        )
    }
}

// iOS
func writingToolsCoordinator(
    _ coordinator: UIWritingToolsCoordinator,
    previewFor context: UIWritingToolsCoordinator.Context,
    range: NSRange
) async -> UITargetedPreview {
    // Return single preview
    return UITargetedPreview(
        view: previewView,
        parameters: parameters
    )
}

校正マーク:

func writingToolsCoordinator(
    _ coordinator: NSWritingToolsCoordinator,
    underlinesFor context: NSWritingToolsCoordinator.Context,
    range: NSRange
) async -> [NSValue] {
    // Return bezier paths for underlines
    return ranges.map { range in
        let path = bezierPath(for: range)
        return NSValue(bytes: &path, objCType: "CGPath")
    }
}
PresentationIntent(iOS 26以降)

セマンティックなリッチテキストの結果オプション:

coordinator.writingToolsResultOptions = [.richText, .presentationIntent]

表示属性との違い:

表示属性(太字、イタリック):

  • 具体的なフォント情報(ポイントサイズ、フォント名)
  • セマンティックな意味はありません

PresentationIntent(ヘッダー、コードブロック、強調):

  • セマンティックなスタイル情報
  • アプリが内部スタイルに変換します
  • リスト、テーブル、コードブロックではPresentationIntentを使用します
  • 下線、下付き文字、上付き文字では引き続き表示属性を使用します

例:

// Check for presentation intent
if attributedString.runs[\.presentationIntent].contains(where: { $0?.components.contains(.header(level: 1)) == true }) {
    // This is a heading
}

SwiftUI TextEditor + AttributedString(iOS 26以降)

基本的な使い方
struct RecipeEditor: View {
    @State private var text: AttributedString = "Recipe text"

    var body: some View {
        TextEditor(text: $text)
    }
}

サポートされている属性:

  • 太字、イタリック、下線、取り消し線
  • カスタムフォント、ポイントサイズ
  • 前景色と背景色
  • カーニング、トラッキング、ベースラインオフセット
  • Genmoji
  • 行の高さ、テキスト配置、基本の書字方向
TextAlignmentとWritingDirection
// Text alignment (AttributedString.TextAlignment)
var text = AttributedString("Centered paragraph")
text.alignment = .center  // .left, .right, .center

// Writing direction for bidirectional text
var bidiText = AttributedString("Hello عربي")
bidiText.writingDirection = .rightToLeft  // .leftToRight, .rightToLeft
行の高さの制御
var multiline = AttributedString("Paragraph\nwith multiple\nlines.")
multiline.lineHeight = .exact(points: 32)       // Fixed height
multiline.lineHeight = .multiple(factor: 2.5)   // Multiplier
multiline.lineHeight = .loose                    // System loose spacing
選択範囲のバインディング
@State private var selection: AttributedTextSelection?

TextEditor(text: $text, selection: $selection)

AttributedTextSelection:

enum AttributedTextSelection {
    case none
    case single(NSRange)
    case multiple(Set<NSRange>) // For bidirectional text
}

選択したテキストの取得:

if let selection {
    let selectedText: AttributedSubstring
    switch selection.indices {
    case .none:
        selectedText = text[...]
    case .single(let range):
        selectedText = text[range]
    case .multiple(let ranges):
        // Discontiguous substring from RangeSet
        selectedText = text[selection]
    }
}
選択範囲のプログラムによる置換
var text = AttributedString("Here is my dog")
var selection = AttributedTextSelection(range: text.range(of: "dog")!)

// Replace with plain text
text.replaceSelection(&selection, withCharacters: "cat")

// Replace with attributed content
let replacement = AttributedString("horse")
text.replaceSelection(&selection, with: replacement)
DiscontiguousAttributedSubstring

RangeSetを使って、連続していない選択範囲を扱います:

let text = AttributedString("Select multiple parts of this text")
let range1 = text.range(of: "Select")!
let range2 = text.range(of: "text")!
let rangeSet = RangeSet([range1, range2])
var substring = text[rangeSet]  // DiscontiguousAttributedSubstring
substring.backgroundColor = .yellow

// Convert back to AttributedString
let combined = AttributedString(substring)
テキスト選択のアフィニティ

ビュー階層の選択アフィニティを制御します:

TextEditor(text: $text, selection: $selection)
    .textSelectionAffinity(.upstream)  // .upstream or .downstream

行境界で選択範囲をテキストの先頭側に解決する場合は、.upstreamを使用します。

カスタム書式設定の定義

編集可能な属性を制限します:

struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
    typealias FormatScope = RecipeAttributeScope

    static let constraints: [any AttributedTextValueConstraint<RecipeFormattingDefinition>] = [
        IngredientsAreGreen()
    ]
}

struct RecipeAttributeScope: AttributedScope {
    var ingredient: IngredientAttribute
    var foregroundColor: ForegroundColorAttribute
    var genmoji: GenmojiAttribute
}

TextEditorに適用します:

TextEditor(text: $text)
    .attributedTextFormattingDefinition(RecipeFormattingDefinition.self)
値の制約

カスタムロジックに基づいて属性値を制御します:

struct IngredientsAreGreen: AttributedTextValueConstraint {
    typealias Definition = RecipeFormattingDefinition
    typealias AttributeKey = ForegroundColorAttribute

    func constrain(
        _ value: inout Color?,
        in scope: RecipeFormattingDefinition.FormatScope
    ) {
        if scope.ingredient != nil {
            value = .green // Ingredients are always green
        } else {
            value = nil // Others use default
        }
    }
}

システムの動作:

  • TextEditorは制約を調べ、変更が有効かどうかを判断します
  • 制約によって変更が元に戻される場合、コントロールは無効になります
  • 制約はペーストしたコンテンツにも適用されます
カスタム属性

属性を定義します:

struct IngredientAttribute: CodableAttributedStringKey {
    typealias Value = UUID // Ingredient ID

    static let name = "ingredient"
}

extension AttributeScopes.RecipeAttributeScope {
    var ingredient: IngredientAttribute.Type { IngredientAttribute.self }
}

属性の動作:

extension IngredientAttribute {
    // Don't expand when typing after ingredient
    static let inheritedByAddedText = false

    // Remove if text in run changes
    static let invalidationConditions: [AttributedString.InvalidationCondition] = [
        .textChanged
    ]

    // Optional: constrain to paragraph boundaries
    static let runBoundaries: AttributedString.RunBoundaries = .paragraph
}
AttributedStringの変更

安全なインデックス更新:

// Transform updates indices/selection during mutation
text.transform(updating: &selection) { mutableText in
    // Find ranges
    let ranges = mutableText.characters.ranges(of: "butter")

    // Set attribute for all ranges at once
    for range in ranges {
        mutableText[range].ingredient = ingredientID
    }
}

// selection is now updated to match transformed text

古いインデックスは使用しないでください:

// BAD - indices invalidated by mutation
let range = text.characters.range(of: "butter")!
text[range].foregroundColor = .green
text.append(" (unsalted)") // range is now invalid!
AttributedStringのビュー

同じコンテンツへの複数のビュー:

  • characters — 書記素クラスタ
  • unicodeScalars — Unicodeスカラー
  • utf8 — UTF-8コード単位
  • utf16 — UTF-16コード単位

すべてのビューで同じインデックスを共有します。

既知の制限事項と注意点

ビューポートのスクロールに関する問題

専門家の記事より:

  • ビューポートによってスクロール位置が不安定になることがあります
  • スクロール中にusageBoundsForTextContainerが変化します
  • AppleのTextEditでも同じ問題が発生します
  • パフォーマンス向上とのトレードオフです
TextKit 1との互換性
  • .layoutManagerにアクセスするとフォールバックが発生します
  • 一方向の操作で、自動的に戻ることはありません
  • 切り替え時にUIの状態が失われます
  • レイアウトシステムの切り替えにはコストがかかります
AttributedStringのインデックス無効化
  • 変更を行うと、すべてのインデックスが無効になります
  • インデックスを有効なまま保つには.transform(updating:)を使用する必要があります
  • インデックスは、元のAttributedStringでのみ機能します
TextKit 1のサポート範囲の制限

TextKit 2ではサポートされていないもの:

  • NSTextTable(NSTextListまたはカスタムレイアウトを使用してください)
  • 一部のレガシーなテキスト添付ファイル
  • グリフの直接操作

リソース

WWDC:2021-10061, 2022-10090, 2023-10058, 2024-10168, 2025-265, 2025-280

ドキュメント:/uikit/nstextlayoutmanager, /appkit/textkit/using_textkit_2_to_interact_with_text, /uikit/display-text-with-a-custom-layout, /swiftui/building-rich-swiftui-text-experiences, /foundation/attributedstring, /foundation/attributedstring/textalignment, /foundation/attributedstring/lineheight, /foundation/discontiguousattributedsubstring, /uikit/writing-tools, /appkit/enhancing-your-custom-text-engine-with-writing-tools

コメント