若要讓 SwiftUI 包裝的 NSTextView 中的搜尋醒目提示保持更新,請在查詢或文字變更時使 TextKit 2 繪製屬性失效,在繪製驗證器中根據目前內容重建醒目提示,並要求重新繪製。
本逐步解說會建立一個小型 Mac 編輯器,使用 Tile A 和 Circle B 這類合成標籤。具體任務是在使用者變更搜尋欄位或於同一個 App 中編輯文件時,醒目提示符合條件的標籤。
醒目提示變更時,哪些內容需要更新?
有兩個彼此獨立的輸入:文件字元和醒目提示查詢。編輯文件會變更文字內容。變更 SwiftUI 搜尋欄位則會變更應用程式狀態,但不一定會變更文字系統中的任何內容。包裝檢視必須將這兩種事件都連接到醒目提示器。
Apple 提供 renderingAttributesValidator,作為提供版面片段繪製屬性的回呼。它也提供 invalidateRenderingAttributes(for:),用來使文字範圍內的這些屬性失效。這兩個 API 可支援一種設計:將查詢儲存為目前狀態,並在繪製需要驗證時重建醒目提示。繪製屬性驗證器、使繪製屬性失效。
重新繪製是另一項工作。設定 needsDisplay 會標記檢視,讓它在下一次事件迴圈時繪製。這個設定本身不會說明哪條醒目提示規則已變更,也不會計算新的符合項目。因此,下方的實作會使繪製屬性失效並要求重新繪製。NSView needsDisplay。
| 事件 | 要更新的狀態 | 動作 |
|---|---|---|
| 搜尋欄位變更 | 文字檢視目前的查詢 | 使繪製屬性失效並要求重新繪製 |
| 文件文字變更 | AppKit 已持有編輯後的文字 | 透過文字委派使醒目提示失效 |
| 片段需要驗證 | 片段的暫時醒目提示屬性 | 清除先前的背景色並套用目前的符合項目 |
| 查詢變成空白 | 目前的查詢 | 清除先前的醒目提示,不新增替代項目 |
讓文字檢視使用 TextKit 2
此範例請使用 macOS 13 或更新版本作為部署目標,並以 NSTextView(usingTextLayoutManager: true) 明確初始化文字檢視。Apple 在 WWDC22 場次中介紹相關轉變時,也推出了可選擇引擎的便利建構子。此處使用的核心 AppKit 繪製屬性 API 文件標示為 macOS 12 起可用。TextKit 和文字檢視的新功能、NSTextLayoutManager。
診斷目前使用的引擎時,請檢查 textView.textLayoutManager。存取 layoutManager,包括透過文字容器存取,可能會觸發 TextKit 1 相容模式。Apple 說明,對現有文字檢視而言,這項轉換是單向的。請勿在 TextKit 2 路徑中使用舊版版面管理器查詢。TextKit 相容性與現代化。
另一項界線是 SwiftUI 對版面配置的管理權。Apple 不建議直接變更所呈現檢視的 frame 或 bounds。此處呈現的根檢視是 NSScrollView;初始框架的設定由其 AppKit 文件子檢視負責。SwiftUI 控制捲動檢視的外部尺寸。NSViewRepresentable。
建立完整的可編輯範例
在 Xcode 中建立 macOS SwiftUI App,將部署目標設為 macOS 13 或更新版本,並以這個單一檔案取代產生的 App 與內容檢視宣告。目標中只保留一個 @main App 宣告。
SwiftUI 管理搜尋查詢。AppKit 文字檢視在其生命週期內管理這份示範文件。符合條件的搜尋刻意採用字面比對、區分大小寫且不重疊,讓合成標籤的範例行為可預期。這是記憶體內示範,沒有實作文件儲存功能。
import SwiftUI
import AppKit
@main
struct HighlightDemoApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
}
struct ContentView: View {
@State private var query = "Tile"
var body: some View {
VStack {
TextField("Highlight label", text: $query)
HighlightEditor(query: query)
}
.padding()
.frame(minWidth: 480, minHeight: 300)
}
}
struct HighlightEditor: NSViewRepresentable {
let query: String
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSScrollView {
let scroll = NSScrollView()
scroll.hasVerticalScroller = true
let view = HighlightTextView(usingTextLayoutManager: true)
view.frame = scroll.contentView.bounds
view.autoresizingMask = [.width]
view.isVerticallyResizable = true
view.isHorizontallyResizable = false
view.textContainer?.widthTracksTextView = true
view.textContainer?.containerSize = NSSize(
width: scroll.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
view.isRichText = false
view.font = .systemFont(ofSize: 16)
view.string = "Tile A\nCircle B\nTile C\nSquare D"
view.delegate = context.coordinator
scroll.documentView = view
guard let manager = view.textLayoutManager else {
preconditionFailure("This demo requires TextKit 2")
}
manager.renderingAttributesValidator = { [weak view] manager, fragment in
view?.render(manager, fragment: fragment)
}
view.query = query
view.refreshHighlights()
return scroll
}
func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let view = scroll.documentView as? HighlightTextView,
view.query != query else { return }
view.query = query
view.refreshHighlights()
}
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
func textDidChange(_ notification: Notification) {
(notification.object as? HighlightTextView)?.refreshHighlights()
}
}
}
@MainActor
final class HighlightTextView: NSTextView {
var query = ""
func refreshHighlights() {
guard let manager = textLayoutManager else { return }
manager.invalidateRenderingAttributes(for: manager.documentRange)
needsDisplay = true
}
func render(_ manager: NSTextLayoutManager, fragment: NSTextLayoutFragment) {
let fragmentRange = fragment.rangeInElement
manager.removeRenderingAttribute(.backgroundColor, for: fragmentRange)
guard !query.isEmpty, let content = manager.textContentManager else { return }
let text = string as NSString
var cursor = 0
while cursor < text.length {
let match = text.range(of: query, options: [.literal],
range: NSRange(location: cursor, length: text.length - cursor))
guard match.location != NSNotFound else { break }
guard let start = content.location(content.documentRange.location,
offsetBy: match.location),
let end = content.location(start, offsetBy: match.length),
let range = NSTextRange(location: start, end: end) else {
assertionFailure("Match does not map to current text content")
return
}
if let part = range.intersection(fragmentRange), !part.isEmpty {
manager.addRenderingAttribute(.backgroundColor,
value: NSColor.systemYellow.withAlphaComponent(0.35), for: part)
}
cursor = NSMaxRange(match)
}
}
}
已依據取得的 API 宣告檢視程式碼,但在此環境中尚未使用 AppKit 編譯或執行。另有獨立的確定性檢查驗證下文所述的示範 UTF-16 位移;這些檢查無法證明 GUI 行為。
為什麼包裝檢視使用兩條更新路徑
updateNSView 會接收新的 SwiftUI 狀態,並將變更後的查詢複製到現有文字檢視。每次驗證器執行時,都會讀取儲存的查詢。它的弱參照指向 AppKit 檢視,而非捕捉可能含有過時查詢的初始 representable 值。Apple 將 representable 的更新方法定義為套用新 SwiftUI 資訊的位置。NSViewRepresentable 生命週期。
在文件中輸入文字會經過協調器路徑。Apple 將 textDidChange(_:) 文件定義為字元或格式屬性變更時的委派通知。協調器會立即更新醒目提示;不需要文件繫結來觸發 SwiftUI 更新。textDidChange(_:)。
初始範例文字只會在建立時指派。之後更新查詢時,文件不會受到影響。若日後加入文件繫結,請在取代內容前比對傳入內容,並明確決定由哪一方管理編輯。若每次更新都重新指派範例文字,便會覆寫使用者的文件。
為什麼必須先清除,再檢查查詢是否為空白
每次驗證時,醒目提示器都會先從片段移除先前的 .backgroundColor 屬性,再尋找符合項目。因此,即使查詢為空白,仍會執行清理。這可解決常見的實作錯誤:搜尋正常運作,但清空欄位後,最後一組醒目提示仍然留在畫面上。
此範例使用文件記載的逐鍵移除與新增 API,將變更限制在背景屬性。它假設此醒目提示器管理繪製背景;若其他功能也會提供背景,請將這些規則合併到同一個繪製策略中。移除繪製屬性、新增繪製屬性。
符合項目也會與正在驗證的片段取交集。Apple 定義 rangeInElement 相對於文件起點,而 intersection(_:) 會傳回共用文字範圍。如此一來,符合項目只會貢獻屬於目前片段的部分。片段範圍、文字範圍交集。
讓符合項目的位移與目前文件保持一致
此範例會搜尋 NSString 快照,並使用它傳回的 NSRange。Apple 指出,NSString 的長度、索引和範圍皆以 UTF-16 碼元為單位。Swift 也透過 utf16 提供字串的 UTF-16 表示;Swift 字元數不能取代這些位移。NSString 索引、String.utf16。
舉例進行檢查時,在範例文件前方加上中性符號與空格 🔷 。第一個 Tile 的 UTF-16 位移會是 3,長度為 4。第二個 Tile 則從位移 19 開始。在開頭插入 X 後,這些位置會變成 5 和 21。這些是計算所得的範例,並非 AppKit 執行結果。
透過目前的內容管理器轉換每個符合項目:先從文件起點移至符合項目的起點,再從該處移至符合項目的終點。請勿自行從整數索引建構文字位置,也不要在文件編輯後保留舊的符合項目位置。Apple 示範了透過內容管理器進行這項轉換;其位置 API 在位移超出範圍時可能會傳回 nil。範圍轉換指引、文字內容儲存位置。
搜尋迴圈會依照實際傳回的符合項目終點向前移動。Apple 說明,即使搜尋範圍較小,搜尋結果仍相對於字串開頭。此範例的空查詢防護與向前推進也能避免迴圈停滯。若要實作一般使用者可見的尋找功能,請選擇適當的在地化比對語意;Apple 建議對呈現給使用者的文字使用標準在地化搜尋。NSString 範圍搜尋。
依照實用順序診斷過時的醒目提示
使用以下順序,區分狀態未更新與繪製或範圍問題。這些是此範例預期進行的檢查,並非已回報的測試結果。
- 從
Tile開始。兩個Tile標籤都應有背景醒目提示。 - 將查詢改為
Circle。先前的背景應消失,而Circle標籤應出現醒目提示。 - 清空查詢。所有搜尋背景都應消失。
- 還原
Tile,然後將一個符合的標籤編輯成Square。編輯過的標籤應不再符合。 - 在第一個標籤前插入
🔷。醒目提示應跟隨文字,而非停留在原本的整數位移上。 - 新增足夠多的合成標籤行,讓文件可以捲動。變更查詢,再捲動瀏覽文件,檢查新顯示的片段。
若查詢有變更但畫面沒有更新,請先檢查 updateNSView、儲存的查詢和失效呼叫。若查詢變更有效,但文件編輯無效,請檢查委派指派和 textDidChange(_:)。若先前符合的文字仍有顏色,請確認每次因查詢為空白而提前返回前,都會先執行清理。
若 textLayoutManager 變成 nil,請先調查相容模式,再新增重新繪製呼叫。Apple 提供版面管理器切換通知,用於診斷 NSTextView 的回退情況。此示範要求使用 TextKit 2,未實作 TextKit 1 醒目提示器。相容模式診斷。
將實作調整為較大的文件
此實作優先採用易於檢視的更新流程:使文件的繪製屬性失效,並在每次片段驗證時掃描目前字串。這會重複執行工作,適用於小型示範,不適用於尚未評估效能的大型文件工作負載。
對於較大的文件,可依每個文件與查詢版本計算一次符合項目,再讓驗證程序將這些項目與各片段取交集。將快取的符合項目與產生它們的確切文字版本關聯。若以非同步方式進行比對,在套用結果前,應丟棄文件或查詢版本已變更的結果。
請將繪製工作與要求整份文件幾何資訊的工作分開。Apple 的版面配置文件區分了繪製失效與版面配置失效,其 TextKit 架構指引也建議將版面配置工作集中於可視區域,因為確保畫面外內容完成版面配置可能耗費大量資源。背景顏色查詢變更應先使用繪製失效,預設不必加入整份文件的版面配置計算。NSTextLayoutManager 作業、認識 TextKit 2。
隨著編輯器擴充,請保持更新順序明確:接收目前狀態、使受影響的繪製屬性失效、清除過時的醒目提示值,並依據目前文字重建符合項目。請分別驗證查詢變更、文件編輯、清除和捲動,讓每個問題都能指向實作中的特定部分。
來源
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple
- renderingAttributesValidator — Apple
- invalidateRenderingAttributes(for:) — Apple
- needsDisplay — Apple
- TextKit 和文字檢視的新功能 — Apple
- textDidChange(_:) — Apple
- removeRenderingAttribute(_:for:) — Apple
- addRenderingAttribute(_:value:for:) — Apple
- rangeInElement — Apple
- intersection(_:) — Apple
- NSString — Apple
- utf16 — Apple
- location(_:offsetBy:) — Apple
- range(of:options:range:) — Apple
- 認識 TextKit 2 — Apple