MacアプリでラップしたNSTextViewの挿入位置を表示するには、キーボードフォーカスを戻し、現在の選択範囲を指定してscrollRangeToVisible(_:)を呼び出します。キャレットの色は別に設定してください。キャレットを画面内に表示することと、点滅を止めることには、それぞれ異なる制御が必要です。

このガイドでは、Reveal insertion pointボタンを備えた小さなローカルエディタを作成します。ドキュメント内の別の場所までスクロールした後、編集位置に戻るという、デスクトップでよくある操作を扱います。この実装は現在の選択範囲を保持し、通常のテキスト入力とカーソル描画はAppKitに任せます。

挿入位置が見えない理由を特定する

まず、目に見える症状を確認します。スクロール、フォーカス、選択範囲、カーソルのアニメーションは似た見え方になることがありますが、それぞれ必要な対処は異なります。

見えている現象 確認する項目 適切な対処
編集位置がドキュメントの表示領域の外にある テキストビューの現在の選択範囲 その範囲が見える位置までスクロールする
別のコントロールをクリックするとエディタで入力できなくなる ウィンドウのfirst responder 要求されたときに明示的にフォーカスを戻す
キャレットが背景に紛れて見分けにくい キャレットと背景の色 テキストビューの色プロパティを設定する
単一のキャレットではなく、テキストがハイライトされている 選択範囲の長さが0より大きいか ユーザーが移動を求めない限り、選択範囲を保持する
エディタがアクティブなのにキャレットが表示されたり消えたりする 通常のカーソルアニメーション 点滅はスクロールとは別の問題として扱う

AppleはscrollRangeToVisible(_:)について、指定した範囲の先頭の文字が見えるまで、それを含むスクロールビューをスクロールすると説明しています。その範囲を中央に配置することや、長い選択範囲全体を表示することは保証していません。グリフの矩形を自分で計算せずに挿入位置を表示する用途に適しています。Appleのスクロールに関するドキュメント。

フォーカスは、それを含むウィンドウが管理します。Appleは、テキストビューのbecomeFirstResponder()を直接呼び出すのではなく、window.makeFirstResponder(textView)を使うよう明示しています。以下の実装もこの手順に従い、実際にどのオブジェクトがfirst responderになったかを確認します。AppleのNSTextViewドキュメント、first responderの切り替えに関するドキュメント。

表示コマンド付きのローカルエディタを作る

XcodeでSwiftUIとSwiftを使ったmacOS Appプロジェクトを作成します。この例では、デプロイメントターゲットをmacOS 13以降に設定します。生成されたアプリのエントリーポイントを最初のファイルで置き換え、2つ目のファイルを同じアプリターゲットに追加します。この2つのファイルで、サンプルアプリ全体が構成されます。

この例ではサンプルテキストを使い、AppKitビューの存続期間中は編集内容をそのビュー内に保持します。ドキュメントの保存機能も外部のテキストモデルもありません。アプリを閉じると編集内容は破棄されます。コードは取得したドキュメントに照らして確認しましたが、利用可能な環境がLinuxでAppKit SDKがないため、ここではコンパイルも実行もしていません。

SwiftUIウィンドウを作成する

ボタンを押すとリクエスト番号が増えます。これにより、SwiftUIが更新されるたびにエディタへフォーカスを戻したりスクロールしたりせず、ラッパーが処理する特定のコマンドを渡せます。

// CaretDemoApp.swift
import SwiftUI

@main
struct CaretDemoApp: App {
    var body: some Scene {
        WindowGroup("Local Editor") {
            EditorScreen()
        }
    }
}

struct EditorScreen: View {
    @State private var revealRequest = 0

    private let sampleText = (1...80)
        .map { "Sample row \($0): blue square, green circle." }
        .joined(separator: "\n")

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Button("Reveal insertion point") {
                revealRequest += 1
            }

            CaretEditor(
                initialText: sampleText,
                revealRequest: revealRequest
            )
        }
        .padding()
        .frame(minWidth: 480, minHeight: 320)
    }
}

スクロール可能なAppKitエディタをラップする

NSViewRepresentableは、SwiftUIの階層内でAppKitビューを管理するためのAppleのブリッジです。ここでは、NSTextViewをドキュメントビューに持つNSScrollViewをラップします。コーディネーターは、最後に処理したリクエストを記録します。作成時には、Appleが提供するスクロール可能なテキストビューのファクトリを使います。NSViewRepresentable、scrollableTextView()。

// CaretEditor.swift
import AppKit
import SwiftUI

@MainActor
struct CaretEditor: NSViewRepresentable {
    let initialText: String
    let revealRequest: Int

    @MainActor
    final class Coordinator {
        var lastHandledRequest: Int

        init(lastHandledRequest: Int) {
            self.lastHandledRequest = lastHandledRequest
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(lastHandledRequest: revealRequest)
    }

    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSTextView.scrollableTextView()
        scrollView.hasVerticalScroller = true

        guard let textView = scrollView.documentView as? NSTextView else {
            preconditionFailure("Expected an NSTextView document view")
        }

        textView.isRichText = false
        textView.isEditable = true
        textView.isSelectable = true
        textView.allowsUndo = true
        textView.drawsBackground = true
        textView.backgroundColor = .textBackgroundColor
        textView.textColor = .textColor
        textView.insertionPointColor = .textColor
        textView.font = .systemFont(ofSize: 15)
        textView.textContainerInset = NSSize(width: 12, height: 12)
        textView.string = initialText

        let end = (initialText as NSString).length
        textView.setSelectedRange(NSRange(location: end, length: 0))
        return scrollView
    }

    func updateNSView(_ scrollView: NSScrollView, context: Context) {
        guard revealRequest != context.coordinator.lastHandledRequest else {
            return
        }
        context.coordinator.lastHandledRequest = revealRequest

        guard let textView = scrollView.documentView as? NSTextView,
              let window = textView.window,
              textView.acceptsFirstResponder else {
            return
        }

        guard window.makeFirstResponder(textView),
              window.firstResponder === textView else {
            return
        }

        textView.scrollRangeToVisible(textView.selectedRange())
    }
}

エディタは、サンプルテキストの末尾に長さ0の選択範囲を置いた状態で起動します。Reveal insertion pointを押すと、エディタにフォーカスが移り、その位置が表示されます。以降は現在の選択範囲を使うため、別の場所にキャレットを移動すれば、コマンドで表示される位置も変わります。このコマンドはテキストを置き換えず、選択範囲も移動しません。

末尾位置にはNSString.lengthを使います。これはUTF-16コード単位の数を返します。SwiftのUTF-16ビューはインデックス付きのNSString APIと一致しますが、SwiftのString.countは文字数を数えます。AppKitのテキスト範囲を作るときは、表現を揃えてください。既存のselectedRange()を読み取れば、表示コマンド自体のためにオフセットを計算する必要はありません。NSString length、Swift String.UTF16View、Swift String.count。

SwiftUIの更新中も編集状態を保つ

重要な設計上の選択は、作成処理とコマンド処理を分けることです。makeNSViewはエディタを一度だけ初期化します。updateNSViewはリクエスト番号が変わったときに応答し、ドキュメントの内容には手を加えません。これは、AppKitビューの作成と更新についてAppleが説明するRepresentableのライフサイクルに沿っています。RepresentableビューのレイアウトもSwiftUIが管理するため、このラッパーはスクロールビューのフレームや境界を設定しません。Representableのライフサイクルとレイアウトに関するAppleのガイダンス。

この例では、更新のたびにtextView.string = initialTextを代入するのは避けてください。そうすると、ユーザーの現在のドキュメントが元のサンプルに置き換わってしまいます。同様に、選択範囲を常にドキュメントの末尾に設定すると、表示操作によって編集位置が移動します。

SwiftUIの状態をモデルにするエディタでは、意図的な双方向同期を追加してください。コーディネーター経由でユーザーの編集を転送し、モデルから届いた変更がいつテキストを置き換えるかを定義します。置き換えの際は、有効な選択範囲を保持し、同時変更の解決方法も定めてください。これはキャレット表示とは別の役割です。このサンプルでは、編集可能な内容の管理元を1つにするため、同期処理は意図的に設けていません。Appleは、デリゲートやtarget-actionによる操作をSwiftUIに転送する仕組みとしてコーディネーターを説明しています。コーディネーターに関するガイダンス。

フォーカスのチェックも重要です。現在のresponderが辞任を拒むと、makeFirstResponder(_:)は失敗することがあります。またAppleによると、要求したresponderがフォーカスを受け入れない場合、戻り値が成功でも、ウィンドウ自体がfirst responderのままになることがあります。window.firstResponder === textViewを確認すれば、実際の結果を検証してからスクロールできます。first responderの切り替えに関する戻り値と動作。

エディタがウィンドウにまだ追加されていない場合、このサンプルは何もせずにリクエストを処理済みにします。追加された後でもう一度ボタンを押してください。初期フォーカス機能を追加する場合は、関係のない更新中に繰り返しフォーカスを取り戻すのではなく、アタッチ状態を考慮した明示的なリクエストを設計してください。

カーソル描画を置き換えずに色を設定する

この例では、テキストと挿入位置の両方に.textColorを、エディタの背景に.textBackgroundColorを設定します。これらはテキストとその背後の領域を指定する、ドキュメント化されたセマンティックカラーです。insertionPointColorは、キャレットの色を設定するためのドキュメント化されたプロパティです。アプリがサポートする各macOSバージョンと外観で、見え方を確認してください。キャレットの色、テキストの色、テキストの背景色。

色の設定では、フォーカスの要求、ドキュメントのスクロール、点滅しない表示モードの選択は行いません。キャレットが見えない原因を調べるときは、これらの役割を分けてください。たとえば色を変えても、スクロールビューの表示領域外にある位置は表示されません。

また、このサンプルでは、キャレットのジオメトリを計算するためにlayoutManagerへアクセスしません。Appleによると、macOS 12以降では、テキストビューまたはテキストコンテナでこのプロパティにアクセスすると、NSLayoutManagerを使う互換モードに切り替わることがあります。選択範囲を表示するだけなら、高水準のスクロールAPIを呼び出すことで、TextKitの互換モードへの切り替えを避けられます。TextKitの互換動作。

キャレットを常に表示する要件の限界を理解する

「表示したままにする」が「点滅させない」という意味なら、スクロールコマンドでは要件を満たせません。Appleの新しいカーソル関連ドキュメントでは、自動アニメーションと常時表示モードが区別されています。

macOS 14以降では、NSTextInsertionIndicatorが.automatic、.visible、.hiddenの表示モードを提供します。Appleは、ドラッグ先の位置を示す場合など、アニメーションを無効にしてカーソルを連続表示する用途に.visibleを案内しています。標準のAppKitテキストビューは、システムのカーソルUIを自動的に使用します。システムテキストカーソルの採用ガイド、NSTextInsertionIndicator。

ドキュメント化されたカスタムカーソルの方法では、独自のインジケータを作成して配置する必要があります。その表示モードが制御するのは、そのインスタンスです。Appleのガイドには、NSTextView内部で管理されるインジケータを.visibleに変更する設定はありません。カスタム方式は、カーソルのジオメトリとアクティブ状態の更新も担う、カスタムテキストUI向けの設計上の選択肢として扱ってください。このラッパーにそのまま追加できる変更ではありません。

一見すると古い2つのAPIも、その設定の代わりにはなりません。shouldDrawInsertionPointは読み取り専用としてドキュメント化されています。drawInsertionPoint(in:color:turnedOn:)はカーソルを描画または消去するメソッドで、Appleは呼び出し元が直接呼び出す必要はないと説明しています。どちらも常時表示に切り替える設定としてはドキュメント化されていません。挿入位置の描画状態、挿入位置の描画メソッド。

この記事で扱う一般的なエディタ操作では、AppKitのカーソルアニメーションを維持し、現在の位置に戻る確実な方法を用意してください。カスタムカーソルを実装する場合は独自の設計が必要です。また、NSTextInsertionIndicatorを使う場合は、macOS 14の利用可能性を考慮した境界も必要です。

アプリで動作を確認する

このサンプルで、スクロール、選択範囲、フォーカス、外観を分けて確認できます。以下は実行結果の報告ではなく、実装から想定される動作です。

  1. アプリを起動してReveal insertion pointを押します。エディタにフォーカスが移り、末尾位置が表示されます。
  2. 前の行の中をクリックしてキャレットを移動します。別のテキスト位置をクリックせずにスクロールし、ボタンを押します。その前の位置が表示されます。
  3. 複数の単語を選択してボタンを押します。選択範囲は選択されたままです。このコマンドは選択範囲を1つのキャレットに折りたたみません。
  4. orange triangleのような目印になる文字列を入力し、ボタンを何度か押します。編集した内容は保持されます。
  5. ウィンドウのサイズを変えてから、表示操作を繰り返します。古い画面座標ではなく、現在の範囲が使われます。
  6. サポート対象のmacOSリリースで、ライトモードとダークモードの両方を確認します。テキスト、背景、キャレットの色が互いに見やすい状態であることを確認してください。

スクロールできてもキャレットが断続的に表示される場合は、通常の点滅を見ているのか、テキストが選択されているのか確認してください。コマンドが何も行わない場合は、まずウィンドウへのアタッチ状態とfirst responderのチェックを調べます。問題が起きた手順に合わせて修正してください。テキストを保持し、要求されたときにフォーカスを戻し、現在の範囲を表示します。

参考資料