I'm embedding an NSTextView (TextKit 2) inside SwiftUI using NSViewRepresentable. I want to dynamically highlight a substring by setting `NSTextLayoutManager` rendering attributes (backgroundColor) on a `NSTextRange`.
It works inconsistently:
- often the highlight doesn't draw
- if it draws once, it may stop updating even when I change rendering attributes and invalidate
Expected: updating the highlight string should immediately update the background highlight in the text view.
Actual: highlight sometimes does not appear or stops updating.
What I've tried:
- `invalidateRenderingAttributes(for: documentRange)`
- `invalidateLayout(for: range)`
- setting `needsDisplay` / `needsLayout` on NSTextView
What is the correct way to trigger re-rendering for `NSTextLayoutManager` rendering attributes when NSTextView is hosted in SwiftUI? Is there an additional step required (e.g. ensureLayout / display invalidation / delegate callback) to make rendering attributes update reliably?
import SwiftUI
struct RATestView: View {
@State private var text = """
TextKit 2 rendering highlight demo.
Type something and watch highlight update.
"""
@State private var search = "highlight"
var body: some View {
VStack {
TextField("Search", text: $search)
WrapperView(text: $text, highlight: search)
.frame(height: 300)
}
}
}
private struct WrapperView: NSViewRepresentable {
@Binding var text: String
var highlight: String
func makeNSView(context: Context) -> CustomTextView {
let view = CustomTextView()
return view
}
func updateNSView(_ nsView: CustomTextView, context: Context) {
nsView.setText(text)
nsView.setHighlight(highlight)
}
}
private final class CustomTextView: NSView {
private let textView = NSTextView()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupView()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
private func setupView() {
addSubview(textView)
textView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
func setText(_ string: String) {
if textView.string != string { textView.string = string }
}
func setHighlight(_ highlightString: String) {
guard let documentRange = textView.textLayoutManager?.documentRange else { return }
textView.textLayoutManager?.invalidateRenderingAttributes(for: documentRange)
//
let highlightRange = (textView.string as NSString).range(of: highlightString)
guard let range = convertToTextRange(textView: textView, range: highlightRange) else { return }
//
textView.textLayoutManager?.addRenderingAttribute(.backgroundColor, value: NSColor(.red), for: range)
//
textView.textLayoutManager?.invalidateLayout(for: range)
textView.needsDisplay = true
textView.needsLayout = true
}
}
private func convertToTextRange(textView: NSTextView, range: NSRange) -> NSTextRange? {
guard let textLayoutManager = textView.textLayoutManager,
let textContentManager = textLayoutManager.textContentManager,
let start = textContentManager.location(textContentManager.documentRange.location, offsetBy: range.location),
let end = textContentManager.location(start, offsetBy: range.length)
else { return nil }
return NSTextRange(location: start, end: end)
}