NSTextLayoutManager renderingAttributes not updating reliably when NSTextView is hosted in SwiftUI (NSViewRepresentable)
01:55 05 Mar 2026

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)
}
swiftui nsattributedstring appkit nstextview textkit2