NSTextView通过NSViewRepresentable嵌入SwiftUI时,NSTextLayoutManager渲染属性更新不可靠问题
NSTextView通过NSViewRepresentable嵌入SwiftUI时,NSTextLayoutManager渲染属性更新不可靠问题
嘿,我之前在做SwiftUI嵌入TextKit 2的NSTextView时,也碰到过一模一样的糟心问题——要么高亮死活画不出来,要么第一次显示后就再也不更新,折腾了好一阵才摸清楚TextKit 2和SwiftUI生命周期配合的门道!下面给你说说我是怎么解决的:
可能的问题根源&对应解决办法
1. 别让TextKit核心对象被意外重建
SwiftUI的NSViewRepresentable如果没处理好,每次状态更新可能会导致text view、layout manager这些核心对象被重新创建,之前的渲染属性设置自然就失效了。所以一定要把这些对象存在协调器里,确保它们的生命周期稳定:
struct TextKit2TextView: NSViewRepresentable { @Binding var text: String @Binding var highlightString: String func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSTextView { // 初始化TextKit 2组件,后续存在协调器中 let textStorage = NSTextStorage(string: text) let layoutManager = NSTextLayoutManager() let textContainer = NSTextContainer(size: .zero) textStorage.addLayoutManager(layoutManager) layoutManager.addTextContainer(textContainer) let textView = NSTextView(frame: .zero, textContainer: textContainer) textView.isEditable = false textView.isSelectable = true // 核心对象绑定到协调器,避免重建丢失状态 context.coordinator.textStorage = textStorage context.coordinator.layoutManager = layoutManager context.coordinator.textView = textView return textView } func updateNSView(_ nsView: NSTextView, context: Context) { // 仅在文本实际变化时更新,避免无效操作干扰TextKit状态 if context.coordinator.textStorage?.string != text { context.coordinator.textStorage?.mutate { storage in storage.replaceCharacters(in: NSRange(location: 0, length: storage.length), with: text) } } // 触发高亮更新逻辑 context.coordinator.updateHighlight(for: highlightString) } class Coordinator: NSObject { var parent: TextKit2TextView var textStorage: NSTextStorage? var layoutManager: NSTextLayoutManager? var textView: NSTextView? init(_ parent: TextKit2TextView) { self.parent = parent } func updateHighlight(for targetString: String) { guard let layoutManager = layoutManager, let textStorage = textStorage else { return } // 先清除之前的高亮状态 layoutManager.removeRenderingAttributes(forAllOccurrencesOf: NSAttributedString.Key.backgroundColor) guard !targetString.isEmpty else { // 清空后精准无效化对应范围的渲染属性 let fullRange = NSRange(location: 0, length: textStorage.length) layoutManager.invalidateLayout(for: fullRange, type: .renderingAttributes) textView?.setNeedsDisplay(textView?.bounds ?? .zero) return } // 查找所有匹配的文本范围 let fullRange = NSRange(location: 0, length: textStorage.length) var ranges: [NSRange] = [] var searchRange = fullRange while let range = textStorage.string.range(of: targetString, options: [], range: searchRange) { let foundRange = NSRange(range, in: textStorage.string) ranges.append(foundRange) // 更新搜索范围,避免重复匹配同一内容 searchRange = NSRange(location: foundRange.location + foundRange.length, length: fullRange.length - (foundRange.location + foundRange.length)) if searchRange.location >= fullRange.length { break } } // 用批量更新包裹所有属性操作,避免TextKit异步渲染冲突 layoutManager.performBatchUpdates { for range in ranges { guard let textRange = NSTextRange(range, in: textStorage) else { continue } layoutManager.addRenderingAttributes( [.backgroundColor: NSColor.yellow.withAlphaComponent(0.3)], for: textRange ) } } completionHandler: { [weak self] _ in // 强制触发视图重绘,解决SwiftUI与AppKit重绘时机不匹配的问题 self?.textView?.setNeedsDisplay(self?.textView?.bounds ?? .zero) } } } }
2. 用精准的无效化方式,别瞎全局invalidate
我之前图省事直接调用layoutManager.invalidateLayout(),结果要么没反应要么整个布局乱套。后来才知道要精准指定无效化的范围和类型:
- 更新渲染属性时,用
layoutManager.invalidateLayout(for: textRange, type: .renderingAttributes),只无效化需要更新的范围的渲染属性,效率更高也更可靠。 - 所有属性修改操作尽量用
performBatchUpdates包裹,TextKit 2会把这些操作合并成一个批次处理,避免异步渲染的冲突。
3. 适配SwiftUI和AppKit的生命周期差异
SwiftUI的updateNSView会频繁调用,所以一定要在里面做判断(比如文本没变化就不重新设置text storage),避免无效操作干扰TextKit的状态。另外,有时候设置完渲染属性后,AppKit的视图可能没及时重绘,所以在performBatchUpdates的完成回调里手动调用textView?.setNeedsDisplay(),强制触发重绘。
4. 所有TextKit操作必须在主线程执行
TextKit 2的所有操作(包括设置渲染属性、无效化布局)都必须在主线程执行!如果你的高亮字符串是从异步任务来的,一定要用DispatchQueue.main.async包裹更新逻辑,不然会出现各种奇怪的不生效情况。
额外小提示
- 如果你需要支持动态修改高亮颜色或者其他属性,也可以把颜色做成SwiftUI的
Binding,在updateHighlight里一起更新。 - 测试的时候可以加个print,看看
updateHighlight是不是每次高亮字符串变化都被调用了,排查是触发逻辑问题还是TextKit本身的问题。
我当时就是靠这些调整,把之前时灵时不灵的高亮改成每次都能即时更新的,你可以照着试试,应该能解决你的问题!




