若要在 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 中確認行為
使用此範例,分別檢查捲動、選取範圍、焦點與外觀。以下列出的是根據實作推得的預期行為,不是實際執行結果。
- 啟動 App 並按下
Reveal insertion point。編輯器應取得焦點,並顯示文件結尾的位置。 - 點選較前面的列來移動插入點。不要點擊其他文字位置,直接捲動離開;接著按下按鈕。畫面應顯示該較前面的位置。
- 選取數個字詞並按下按鈕。選取範圍應維持反白;此命令不會將它收合為插入點。
- 輸入醒目的標籤,例如
orange triangle,然後連續按幾次按鈕。編輯內容應保持完整。 - 調整視窗大小後,再次執行顯示操作。命令應使用目前的範圍,而不是舊的螢幕座標。
- 在支援的 macOS 版本中檢查淺色與深色外觀。確認文字、背景和插入點顏色搭配後仍清晰易讀。
如果捲動成功,但插入點仍時隱時現,請確認你看到的是正常閃爍,還是作用中的文字選取範圍。如果命令沒有作用,請先檢查視窗附加狀態和第一回應者的檢查條件。針對失敗的步驟處理問題:保留文字、在使用者要求時恢復焦點,並顯示目前範圍。
來源
- NSViewRepresentable — Apple
- NSTextView — Apple
- scrollableTextView() — Apple
- scrollRangeToVisible(_:) — Apple
- makeFirstResponder(_:) — Apple
- length — Apple
- String.UTF16View — Apple
- count — Apple
- insertionPointColor — Apple
- textColor — Apple
- textBackgroundColor — Apple
- 在自訂文字檢視中採用系統文字游標 — Apple
- NSTextInsertionIndicator — Apple
- shouldDrawInsertionPoint — Apple
- drawInsertionPoint(in:color:turnedOn:) — Apple