NSTextView の検索ハイライト (Command+F) が消える問題の根本原因と解決

SwiftUI アプリの中で NSTextViewNSViewRepresentable でラップしていると、ある日突然こんな現象に出会うことがあります。

Command+F でファインドバーを開いて検索する → すべての検索結果にハイライトが表示されない

これは「検索ロジックがバグっている」のでも「NSTextFinder の使い方が間違っている」のでもありません。ほぼ間違いなく、NSTextView の周辺(レイアウト・属性更新・SwiftUI 側のレイアウト連鎖)のいずれかが原因です。

現在開発中のMac用テキストエディタでもこの問題に長く悩まされました。最終的に、3 つの独立した根本原因が重なって発生していたことが判明しました。本記事では、それぞれの原因と対処法を、再現できるレベルの粒度でまとめます。読み終えるころには、同じ症状に出会ったときに「どこから疑うべきか」がはっきり分かるはずです。

結論: 3つの罠があった

詳しい説明の前に、原因を先にまとめておきます。あなたのアプリにも該当しそうな項目から読んでも構いません。

#何が起きるか対処
1TextKit 2 がデフォルトNSTextStorage の属性を変更すると
NSTextFinder のハイライトが復旧しない
TextKit 1 を明示的に組み立てる
2レイアウトの連鎖フッターの高さ変動 → safeAreaInset
TextView のフレーム変化 → glyph 再生成 →
ハイライト消滅
フッターに固定高さを与える/selection 伝播を分離
3属性の「等価な再代入」textView.font = font を毎回呼ぶと、
内部で setFont:range: が全範囲に走り temporary attribute が消える
差分検知してから書き込む

順に詳しく見ていきます。

罠1 :macOS 14+ では NSTextView のデフォルトが TextKit 2 になっている

何が変わったのか

NSTextView は長らく TextKit 1NSTextStorage + NSLayoutManager + NSTextContainer の三点セット)でレイアウトを行ってきました。ところが macOS 12 以降、より高速で柔軟な TextKit 2NSTextContentStorage + NSTextLayoutManager + NSTextContainer)が導入され、macOS 14 からは NSTextView() をデフォルトイニシャライザで作ると TextKit 2 で初期化されるようになっています。

ここが落とし穴です。NSTextView のインターフェース自体はほぼ変わらないので、何も意識せずに使っていると「気づかないうちに TextKit 2 で動いている」状態になります。

TextKit 2 と NSTextFinder の相性

NSTextFinder の検索ハイライト(黄色のあれ)は、内部的には NSLayoutManager.addTemporaryAttribute(_:value:forCharacterRange:) に相当する仕組みで .backgroundColor を一時的に塗るものです。

TextKit 1 のときは、NSTextStorage の中身を更新しても temporary attribute は基本的に維持されます(厳密には glyph 再生成が走らない限り)。ところが TextKit 2 では、NSTextStorage(より正確には背後の NSTextContentStorage)の属性を書き換えた瞬間、検索ハイライトが復旧しないケースが多発します。具体的には、ファインドバーで検索した直後にフォントを設定し直す、テーマを切り替える、あるいは段落スタイルを再適用する、といった操作で容赦なく消えます。

これが macOS 13 以前のサンプルコードでは問題にならなかったのに、macOS 14+ で再ビルドすると突然出てくる現象の正体です。

解決策:TextKit 1 を明示的に組み立てる

NSTextView を「黙って渡された TextKit に乗っかる」のではなく、自分で TextKit 1 を組み立ててから渡します

let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)

let textContainer = NSTextContainer()
layoutManager.addTextContainer(textContainer)

let textView = MyTextView(frame: .zero, textContainer: textContainer)

ポイントは NSTextView(frame:textContainer:) イニシャライザを使うことです。このイニシャライザに TextKit 1 系の NSTextContainer を渡すと、その textView は TextKit 1 として動作します。逆に NSTextView() だと TextKit 2 になります(macOS 14+)。

これだけで罠①はクリアです。Markdown ハイライトのために addTemporaryAttribute(.foregroundColor, ...) を使っているような構成でも、NSTextFinder.backgroundColor の temporary attribute と平和に共存できるようになります(属性キーが違うので衝突しないため)。

補足:「TextKit 2 のままで頑張る」道もありますが、NSTextFinder 周りの細かい挙動が macOS のバージョンごとに変わっており、現時点(2026 年 5 月)では TextKit 1 に逃げるのが安牌です。将来的に TextKit 2 が成熟したら移行を検討すれば十分です。

罠2:レイアウトの連鎖が glyph 再生成を引き起こす

症状の出方

罠①を潰すと、検索ハイライトはほとんどのケースで安定します。ところが、特定の操作だけ消えることに気づきます。たとえば:

  • テキストを範囲選択した瞬間、ハイライトが消える
  • ウィンドウを少しリサイズすると消える
  • 何もしていなくても、フッターの表示が切り替わったタイミングで消える

これは AppKit 単体で NSTextView を使っているときには出にくく、SwiftUI からラップしているときに特に顕著です。

原因:SwiftUI のレイアウト再計算が TextView のフレームを揺らす

SwiftUI の safeAreaInset(edge: .bottom) などで本文の下にフッターを敷いていると、フッターの intrinsic size が変動した瞬間、本文(NSTextView)のフレームも 1 ピクセル単位で揺れることがあります。

私の Kami の場合、こういう連鎖でした:

  1. ユーザがテキストを範囲選択する
  2. フッターの「文字数表示」が "1,234文字 / 56行" から "選択 12文字 / 1,234文字 / 56行" に変わる
  3. フッターの intrinsic height が数ピクセル増える
  4. safeAreaInset 経由で 本文の高さが減る
  5. 本文のフレーム変化で NSLayoutManager の glyph 再生成が走る
  6. glyph 再生成で temporary attribute(NSTextFinder のハイライト)が消える

つまり「検索バーは何もしていない」のに、フッターの高さが揺れただけでハイライトが死ぬわけです。これは罠①より気づきにくく、しかも 罠①を潰した後でも独立して残るので非常に厄介です。

解決策 A:レイアウトの揺れを物理的に抑える

最もシンプルなのは、揺れる部分に固定高さを与えることです。

.safeAreaInset(edge: .bottom, spacing: 0) {
    HStack {
        Spacer(minLength: 0)
        WordCountStatus(text: document.text, selection: selection)
    }
    .padding(.horizontal, 18)
    // intrinsic height を固定して safeAreaInset の高さ変動を遮断する
    .frame(height: 26)
    .background(.bar)
}

.frame(height: 26) の 1 行で、フッター内のテキストがどう変わっても外側の高さは 26pt で固定されます。これで「フッターの高さ変動 → 本文フレーム変化 → glyph 再生成」の連鎖が物理的に切れます。

解決策 B:selection 変化を SwiftUI ツリー全体に伝播させない

もう一段階深い対処として、そもそも selection の変化で TextViewRepresentable を再評価させないようにします。素朴に書くと、こんな構造になりがちです。

@State private var selectedLength: Int = 0

var body: some View {
    TextViewRepresentable(text: $document.text, selectedLength: $selectedLength, ...)
        .safeAreaInset(edge: .bottom) {
            WordCountStatus(text: document.text, selectedLength: selectedLength)
        }
}

これだと、selection が変わるたびに EditorViewbody が再評価され、SwiftUI の差分検知のはずれ方によっては TextViewRepresentable.updateNSView が呼ばれてしまうことがあります。updateNSView の中でフォントや段落スタイルを書き戻していると、罠③(後述)も同時に発火します。

これを避けるには、selection を ObservableObject 経由でフッターだけに伝えるのが効きます。

final class TextViewSelection: ObservableObject {
    @Published var selectedLength: Int = 0
}

struct EditorView: View {
    // @State だが reference identity しか追跡しないので、
    // selection.selectedLength の変化では body が再評価されない
    @State private var selection = TextViewSelection()

    var body: some View {
        TextViewRepresentable(text: $document.text, selection: selection, ...)
            .safeAreaInset(edge: .bottom) {
                WordCountStatus(text: document.text, selection: selection)
            }
    }
}

struct WordCountStatus: View {
    let text: String
    // こちらは @ObservedObject なので selection 変化で自分だけ再描画される
    @ObservedObject var selection: TextViewSelection
    ...
}

ポイントは EditorView 側を @StateObject ではなく @State にすることです。@State は値(この場合は参照)の identity しか見ないので、selection.selectedLength が変わっても body を再評価しません。一方、WordCountStatus 側は @ObservedObject で購読しているので、ちゃんと再描画されます。

TextViewRepresentable の中の NSTextView は触らせない」「フッターだけが selection を見る」という責任分離が、SwiftUI と AppKit の橋渡しを安定させる鍵です。

解決策 C:ファインドバー表示中は selection 更新をスキップ

念のための保険として、ファインドバーが開いている間は selection の通知自体を握り潰します。

func textViewDidChangeSelection(_ notification: Notification) {
    guard let textView = notification.object as? NSTextView else { return }
    // ファインドバーが見えている間は触らない
    if let container = textView.enclosingScrollView as? NSTextFinderBarContainer,
       container.isFindBarVisible { return }
    // ... selection.selectedLength を更新
}

NSTextFinder がマッチを順番に選択していくとき、マッチごとに textViewDidChangeSelection が飛んできます。この通知に反応してフッターを更新すると、ファインド中ずっと SwiftUI 側のレイアウトが揺れることになります。表示中はそもそも触らない、という割り切りが安全です。

罠3:属性の「等価な再代入」が全範囲属性更新を引き起こす

症状

罠①と罠②を潰しても、フォントを変更した直後だけ検索ハイライトが消えるケースが残ります。あるいは、何もしていないのに updateNSView が呼ばれた瞬間に消えます。

原因:textView.font = font は副作用が大きい

NSTextView.font のセッターは、現在の値と等しい NSFont を代入しても、内部で setFont:range: を全範囲に対して実行しますtypingAttributesdefaultParagraphStyle も同様です。

そして setFont:range:NSTextStorage 上の属性を書き換えるので、罠①で TextKit 1 にしていても、そのタイミングで glyph 再生成が走り、temporary attribute(ハイライト)が消えることがあります。特に paragraphStyle のような重い属性を毎回入れ直していると、ほぼ確実に消えます。

つまり、「同じ値を入れているから副作用ないでしょ」は通用しません。SwiftUI の updateNSView は頻繁に呼ばれるので、ここで毎回フォントを書き戻していると、検索ハイライトが安定して残ってくれません。

解決策:差分検知してから書き込む

書き込む前に「本当に変わったか」を判定し、変わったときだけ書き込みます。

private static func fingerprint(font: NSFont, paragraph: NSParagraphStyle) -> String {
    "\(font.fontName)|\(font.pointSize)|\(paragraph.lineHeightMultiple)"
}

func updateNSView(_ scrollView: NSScrollView, context: Context) {
    let currentFont = fontSettings.currentNSFont()
    let currentParagraph = fontSettings.currentParagraphStyle()
    let currentFingerprint = Self.fingerprint(font: currentFont, paragraph: currentParagraph)

    let fontNeedsApply = context.coordinator.lastFontFingerprint != currentFingerprints
    if fontNeedsApply {
        Self.applyAttributesToStorage(textView: textView, font: currentFont, paragraph: currentParagraph)
        Self.applyTypingAttributes(textView: textView, font: currentFont, paragraph: currentParagraph)
        context.coordinator.lastFontFingerprint = currentFingerprint
    }
}

NSFont 同士の == 比較は意外と当てにならないので、比較したい属性だけを文字列フィンガープリントにして覚えておくのが堅実です。NSParagraphStyle も同様で、自分で見たい属性だけを取り出して比較します。

おまけ:font は storage 経由で塗る

textView.font = font の代わりに、NSTextStorage に直接 attribute を書き込むのも効きます。

private static func applyAttributesToStorage(
    textView: NSTextView, font: NSFont, paragraph: NSParagraphStyle
) {
    guard let storage = textView.textStorage, storage.length > 0 else { return }
    let range = NSRange(location: 0, length: storage.length)
    storage.beginEditing()
    storage.addAttribute(.font, value: font, range: range)
    storage.addAttribute(.paragraphStyle, value: paragraph, range: range)
    storage.endEditing()
}

beginEditing() / endEditing() で囲むことで、複数の属性変更を 1 トランザクションにまとめられます。これにより、属性変更ごとに layout が走るのを防げます。なお、textView.font のセッターは defaultParagraphStyle の更新やら typingAttributes の更新やら 複合的な副作用を持っているので、「中で何が起きているか分からない」のが嫌な場合はこちらの方が予測しやすいです。

ただし、textView.string = text で本文をまるごと差し替えると属性は消えるので、string 代入のあとに必ず属性を再適用する点は忘れずに。また、新規入力に新しいフォントを反映させるには typingAttributes の更新も必要です。

まとめ:チェックリスト

検索ハイライトが消えるとき、以下を順番に潰していくと、ほとんどのケースで解決できます。

  • NSTextView を init(frame:textContainer:) で作っているか? デフォルトイニシャライザだと TextKit 2 になっている
  • safeAreaInset の中身に固定高さを与えているか? フッター内テキストの長さで高さが揺れていないか
  • selection の変化で SwiftUI ツリー全体が再評価されていないか? @State + ObservableObject で必要なところだけ購読する
  • updateNSView で毎回 textView.font = font していないか? フィンガープリントで差分検知してから書き込む
  • ファインドバー表示中の textViewDidChangeSelection を握り潰しているか? マッチ移動のたびに反応していないか
  • textView.string = text のあとに属性を再適用しているか? 本文差し替えで属性は消える

なぜ Markdown ハイライトと検索ハイライトは共存できるのか

ついでに「自前のシンタックスハイライト」と「NSTextFinder のハイライト」が共存できる仕組みも整理しておきます。

NSTextStorage への addAttribute(.foregroundColor, ...)永続的属性で、ストレージに直接書き込まれます。一方、NSLayoutManager.addTemporaryAttribute(.backgroundColor, ...)レンダリング属性で、ストレージには触らずに layout manager 内のテーブルに保持されます。

この 2 つは独立した記憶領域に保存されるため、TextKit 1 を使っている限りは互いに干渉しません。Markdown シンタックスの色を addTemporaryAttribute(.foregroundColor, ...) で塗り、検索ハイライトを addTemporaryAttribute(.backgroundColor, ...)(NSTextFinder が内部的にやる)で塗るならば、属性キーが違うので完全に共存できます。これが「TextKit 1 を強制する」最大のメリットでもあります。

逆に、Markdown ハイライトを storage.addAttribute(.foregroundColor, ...) でやってしまうと、ストレージ書き換えのたびに glyph 再生成が起きやすくなり、検索ハイライトに干渉しやすくなります。シンタックスハイライト系は基本的に temporary attribute で塗る、と覚えておくとよいです。

おわりに

検索ハイライトが消える問題は、原因が「TextKit のバージョン違い」「SwiftUI のレイアウト連鎖」「属性更新の副作用」と、まったく異なるレイヤーに散らばっています。だからこそ「これさえ直せば OK」という単一の解はなく、複数の罠を同時に潰す必要があります

ですが、いったん仕組みを理解してしまえば、対処自体はそれぞれ数十行のコードで済みます。この記事のチェックリストを上から順に試して、あなたのアプリの検索ハイライトが安定してくれることを祈っています。

私自身、この戦いを通じて TextKit 1 と TextKit 2 の差分にかなり詳しくなりました。NSTextView を黙って NSTextView() で作らない」という一言だけでも、覚えて帰っていただけると嬉しいです。

Sources