若要在 Mac App 的包裝 NSTextView 中顯示插入點,請恢復鍵盤焦點,並使用目前的選取範圍呼叫 scrollRangeToVisible(_:)。請另外設定插入點顏色。讓插入點留在螢幕上,以及讓它停止閃爍,需要使用不同的控制方式。

本指南會建立一個小型本機編輯器,並加入 Reveal insertion point 按鈕。它處理一項常見的桌面操作:在文件中捲動到其他位置後,返回原本的編輯位置。此實作會保留目前的選取範圍,並讓 AppKit 管理一般文字輸入與游標繪製。

判斷插入點消失的原因

先確認你看到的狀況。捲動、焦點、選取範圍和游標動畫可能呈現出相似的結果,但各自需要不同的處理方式。

觀察到的狀況 檢查項目 適當的動作
編輯位置位於文件可見區域之外 文字檢視的目前選取範圍 將該範圍捲動至可見區域
點擊其他控制項後,編輯器停止輸入 視窗的第一回應者 在使用者要求時明確恢復焦點
插入點難以和背景區分 插入點與背景顏色 設定文字檢視的顏色屬性
顯示的是反白文字,而非單一插入點 選取範圍是否有非零長度 除非使用者要求移動,否則保留選取範圍
編輯器啟用時,插入點時隱時現 正常的游標動畫 將閃爍和捲動分開處理

Apple 說明,scrollRangeToVisible(_:) 會捲動外層捲動檢視,直到所提供範圍的第一個字元可見。它不保證會將該範圍置中,也不保證會顯示整個長選取範圍。這項 API 適合用來顯示插入位置,無須自行計算字形矩形。Apple 的捲動說明。

焦點由所在的視窗管理。Apple 明確指示呼叫端使用 window.makeFirstResponder(textView),而不是直接呼叫文字檢視的 becomeFirstResponder()。以下實作遵循這個方式,並檢查實際成為第一回應者的物件。Apple 的 NSTextView 說明、第一回應者轉換說明。

建立含有顯示命令的本機編輯器

在 Xcode 中使用 SwiftUI 和 Swift 建立 macOS App 專案。本範例將部署目標設為 macOS 13 或更新版本。將產生的 App 進入點替換為第一個檔案,並將第二個檔案加入相同的 App 目標。這兩個檔案合在一起,就是完整的示範 App。

範例使用合成文字,並在 AppKit 檢視的生命週期內,將編輯內容保留於該檢視中。它沒有實作文件儲存,也沒有外部文字模型。關閉 App 時,編輯內容會遭到捨棄。我已根據取得的文件檢視程式碼;由於目前環境是 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 是 Apple 提供的橋接方式,可在 SwiftUI 階層中管理 AppKit 檢視。此處的代表檢視是 NSScrollView,其文件檢視則是 NSTextView。協調器會記住最後處理的請求。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())
    }
}

編輯器一開始會在範例文字結尾設定零長度的選取範圍。按下 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 會在請求編號變更時作出回應,並保留文件內容。這符合 Apple 對建立及更新 AppKit 檢視之 representable 生命週期的說明。SwiftUI 也會控制代表檢視的版面配置,因此這個包裝檢視不會設定捲動檢視的 frame 或 bounds。Apple 對 representable 生命週期與版面配置的指引。

在此範例中,請避免每次更新都指定 textView.string = initialText。這麼做會用原始範例取代使用者目前的文件。同樣地,若無條件將選取範圍設在文件結尾,顯示命令就會移動編輯位置。

若編輯器以 SwiftUI 狀態為依據,請明確加入雙向同步:透過協調器傳遞使用者的編輯,並定義何時以傳入的模型變更取代文字。取代文字時,請保留有效的選取範圍,並定義如何解決並行變更。這是與顯示插入點不同的工作;此範例刻意讓可編輯內容只有一個來源。Apple 說明,協調器是將委派方法和 target-action 互動傳遞至 SwiftUI 的機制。協調器指引。

焦點檢查也很重要。如果現有的回應者拒絕放棄焦點,makeFirstResponder(_:) 可能會失敗。Apple 也指出,即使方法成功返回,若指定的回應者不接受焦點,視窗本身仍可能成為第一回應者。在捲動前檢查 window.firstResponder === textView,即可確認實際結果。第一回應者的返回值與行為。

如果編輯器尚未附加至視窗,此範例會消耗該請求,但不採取任何動作。附加完成後,再按一次按鈕。若要實作初始焦點功能,請設計一個明確且會等待檢視附加完成的請求,而不是在無關的更新期間反覆搶回焦點。

設定顏色,但保留系統游標繪製方式

範例將 .textColor 同時指定為文字與插入點顏色,並將 .textBackgroundColor 指定為編輯器背景色。這些是用於文字和文字後方區域的文件化語意顏色。insertionPointColor 則是文件化的插入點顏色設定屬性。請在 App 支援的每個 macOS 版本和外觀模式中檢查實際顯示效果。插入點顏色、文字顏色、文字背景顏色。

設定顏色不會要求取得焦點、不會捲動文件,也不會選擇不閃爍的顯示模式。診斷插入點消失的問題時,請分開處理這些工作。例如,若插入位置仍在捲動檢視的可見區域之外,改變顏色也無法讓它顯示出來。

範例也不會存取 layoutManager 來計算插入點的幾何資訊。Apple 說明,在 macOS 12 或更新版本中,存取文字檢視或文字容器上的此屬性,可能會使檢視切換至使用 NSLayoutManager 的相容模式。使用高階捲動 API,就無須為了顯示選取範圍而引入這項 TextKit 相容性變更。TextKit 相容性行為。

了解讓插入點永遠可見的限制

如果「保持可見」的意思是「永不閃爍」,那麼捲動命令無法滿足這項需求。Apple 的新式游標文件將自動動畫與永遠可見的顯示模式區分開來。

在 macOS 14 或更新版本中,NSTextInsertionIndicator 提供 .automatic、.visible 和 .hidden 顯示模式。Apple 說明,.visible 可讓游標持續顯示並停用動畫,例如用來表示拖放目的地。標準 AppKit 文字檢視會自動使用系統游標介面。採用系統文字游標指南、NSTextInsertionIndicator。

文件化的自訂游標方式要求自行建立並定位指示器。其顯示模式只控制該指示器;Apple 的指南沒有提供 NSTextView 設定,讓你將內部管理的指示器改為 .visible。對自訂文字介面而言,自訂方式是一項架構選擇,你也必須負責更新游標幾何資訊與作用中狀態。它不能直接套用到這個包裝檢視中。

另外兩個看似較舊的 API 也沒有提供這項設定。文件指出,shouldDrawInsertionPoint 是唯讀屬性。drawInsertionPoint(in:color:turnedOn:) 會繪製或清除游標,而 Apple 表示呼叫端不應需要直接呼叫它。兩者都沒有被文件化為永遠可見的開關。插入點繪製狀態、插入點繪製方法。

對本文介紹的一般編輯器操作,請保留 AppKit 的游標動畫,並提供可靠的方式返回目前位置。自訂游標實作需要另行設計;若使用 NSTextInsertionIndicator,也需要處理 macOS 14 的可用性界線。

在 App 中確認行為

使用此範例,分別檢查捲動、選取範圍、焦點與外觀。以下列出的是根據實作推得的預期行為,不是實際執行結果。

  1. 啟動 App 並按下 Reveal insertion point。編輯器應取得焦點,並顯示文件結尾的位置。
  2. 點選較前面的列來移動插入點。不要點擊其他文字位置,直接捲動離開;接著按下按鈕。畫面應顯示該較前面的位置。
  3. 選取數個字詞並按下按鈕。選取範圍應維持反白;此命令不會將它收合為插入點。
  4. 輸入醒目的標籤,例如 orange triangle,然後連續按幾次按鈕。編輯內容應保持完整。
  5. 調整視窗大小後,再次執行顯示操作。命令應使用目前的範圍,而不是舊的螢幕座標。
  6. 在支援的 macOS 版本中檢查淺色與深色外觀。確認文字、背景和插入點顏色搭配後仍清晰易讀。

如果捲動成功,但插入點仍時隱時現,請確認你看到的是正常閃爍,還是作用中的文字選取範圍。如果命令沒有作用,請先檢查視窗附加狀態和第一回應者的檢查條件。針對失敗的步驟處理問題:保留文字、在使用者要求時恢復焦點,並顯示目前範圍。

來源