To keep search highlights updating in a SwiftUI-wrapped NSTextView, invalidate TextKit 2 rendering attributes when the query or text changes, rebuild highlights from current content in the rendering validator, and request redisplay.
This walkthrough builds a small Mac editor for synthetic labels such as Tile A and Circle B. The concrete task is to highlight matching labels while someone changes the search field or edits the document inside the same app.
What needs to update when a highlight changes?
There are two independent inputs: the document’s characters and the highlight query. Editing the document changes text content. Changing a SwiftUI search field changes application state without necessarily changing anything in the text system. Your wrapper must connect both events to the highlighter.
Apple provides renderingAttributesValidator as the callback for supplying rendering attributes for a layout fragment. It also provides invalidateRenderingAttributes(for:) to invalidate those attributes over a text range. Together, these APIs support a design in which the query is stored as current state and highlights are rebuilt when rendering needs validation. Rendering attributes validator, Invalidate rendering attributes.
Redisplay is a separate concern. Setting needsDisplay marks the view for drawing on the next event-loop pass. It does not itself describe which highlight rule changed or calculate new matches. The implementation below therefore invalidates rendering attributes and requests redisplay. NSView needsDisplay.
| Event | State to update | Action |
|---|---|---|
| Search field changes | The text view’s current query | Invalidate rendering attributes and request redisplay |
| Document text changes | AppKit already holds the edited text | Invalidate highlights through the text delegate |
| A fragment needs validation | Its temporary highlight attributes | Clear the previous background and apply current matches |
| Query becomes empty | The current query | Clear previous highlights without adding replacements |
Keep the text view on TextKit 2
Use a macOS 13 or later deployment target for this example and explicitly initialize the text view with NSTextView(usingTextLayoutManager: true). Apple introduced the engine-selecting convenience constructors with the transition described in its WWDC22 session. The core AppKit rendering-attribute APIs used here are documented as available from macOS 12. What’s new in TextKit and text views, NSTextLayoutManager.
Check textView.textLayoutManager when diagnosing the active engine. Accessing layoutManager, including through the text container, can trigger TextKit 1 compatibility mode. Apple describes that transition as one-way for the existing text view. Keep legacy layout-manager queries out of the TextKit 2 path. TextKit compatibility and modernization.
The other boundary is SwiftUI’s layout ownership. Apple warns against directly changing the represented view’s frame or bounds. Here, the represented root is an NSScrollView; the initial frame assignment belongs to its AppKit document subview. SwiftUI controls the scroll view’s outer size. NSViewRepresentable.
Build a complete editable example
Create a macOS SwiftUI app in Xcode, set its deployment target to macOS 13 or later, and replace the generated app and content-view declarations with this single file. Keep exactly one @main app declaration in the target.
SwiftUI owns the search query. The AppKit text view owns this demonstration document for its lifetime. Matching is deliberately literal, case-sensitive, and non-overlapping so that the example has predictable behavior for synthetic labels. This is an in-memory demonstration; it does not implement document saving.
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)
}
}
}
The code has been inspected against the retrieved API declarations, but it has not been compiled or run with AppKit in this environment. Separate deterministic checks verified the illustrative UTF-16 offsets discussed below; those checks do not establish GUI behavior.
Why the wrapper uses two update paths
updateNSView receives new SwiftUI state and copies the changed query into the existing text view. The validator reads that stored query each time it runs. Its weak reference points to the AppKit view rather than capturing an initial representable value that could contain an outdated query. Apple defines the representable’s update method as the place to apply new SwiftUI information. NSViewRepresentable lifecycle.
Typing in the document follows the coordinator path. Apple documents textDidChange(_:) as a delegate notification that characters or formatting attributes changed. The coordinator responds by refreshing highlights immediately; it does not need a document binding to trigger a SwiftUI update. textDidChange(_:).
Initial sample text is assigned only during creation. Subsequent query updates leave the document untouched. If you later add a document binding, compare incoming content before replacing it and establish which side owns edits. Reassigning the sample text in every update would overwrite the user’s document.
Why clearing must happen before the empty-query check
Each validation removes the highlighter’s previous .backgroundColor attribute from the fragment before looking for matches. An empty query therefore still performs the cleanup. This addresses the common implementation error where searching works until the field is cleared, leaving the last set of highlights visible.
The example uses the documented per-key removal and addition APIs, limiting its changes to the background attribute. It assumes this highlighter owns rendering backgrounds; if another feature also supplies them, combine those rules in one rendering policy. Remove rendering attribute, Add rendering attribute.
Matches are also intersected with the fragment being validated. Apple defines rangeInElement relative to the document origin, and intersection(_:) returns the shared text range. This lets a match contribute only the portion belonging to the current fragment. Fragment range, Text-range intersection.
Keep match offsets consistent with the current document
The example searches an NSString snapshot and uses its returned NSRange. Apple specifies that NSString lengths, indexes, and ranges use UTF-16 code units. Swift also exposes a string’s UTF-16 representation through utf16; a Swift character count is not a substitute for these offsets. NSString indexing, String.utf16.
For an illustrative check, prepend the neutral symbol and space 🔷 to the sample document. The first Tile then starts at UTF-16 offset 3, with length 4. Its second occurrence starts at offset 19. Insert X at the beginning and those locations become 5 and 21. These are calculated examples, not observations from an AppKit run.
Convert each match through the current content manager: offset from the document start to the match start, then from there to the match end. Do not construct text locations from integer indexes yourself or retain old match locations after document edits. Apple demonstrates this conversion through the content manager, whose location API can return nil for an out-of-bounds offset. Range conversion guidance, Text content storage locations.
The search loop advances using the actual returned match end. Apple documents that search results are relative to the beginning of the string, even when searching a smaller range. The example’s empty-query guard and forward advancement also prevent a non-advancing loop. For a general user-facing Find feature, choose appropriate localized matching semantics; Apple recommends localized standard searching for text presented to users. NSString range search.
Diagnose stale highlights in a useful order
Use this sequence to distinguish a missing state update from a rendering or range problem. These are expected checks for the example, not reported test results.
- Start with
Tile. BothTilelabels should have a background highlight. - Change the query to
Circle. The previous backgrounds should disappear and theCirclelabel should become highlighted. - Clear the query. All search backgrounds should disappear.
- Restore
Tile, then edit one matching label toSquare. That edited label should stop matching. - Insert
🔷before the first label. Matching should follow the text rather than remain at the former integer offsets. - Add enough synthetic label lines to scroll. Change the query, then scroll through the document to check newly displayed fragments.
If the query changes but nothing updates, inspect updateNSView, the stored query, and the invalidation call first. If document edits fail while query changes work, inspect the delegate assignment and textDidChange(_:). If former matches remain colored, check that cleanup runs before every early return for an empty query.
If textLayoutManager becomes nil, investigate compatibility mode before adding redraw calls. Apple provides layout-manager switch notifications for diagnosing an NSTextView fallback. This demonstration requires TextKit 2 and does not implement a TextKit 1 highlighter. Compatibility-mode diagnostics.
Adapt the implementation to larger documents
This implementation prioritizes an inspectable update path: it invalidates the document’s rendering attributes and scans the current string during each fragment validation. That repeats work and is suitable for a small demonstration rather than an unmeasured large-document workload.
For larger documents, calculate matches once per document-and-query revision, then have validation intersect those matches with each fragment. Associate cached matches with the exact text revision that produced them. If matching runs asynchronously, discard results whose document or query revision has changed before applying them.
Keep rendering work separate from requests for document-wide geometry. Apple’s layout documentation distinguishes rendering invalidation from layout invalidation, and its TextKit architecture guidance recommends focusing layout work on the viewport because ensuring offscreen layout can be expensive. A background-color query change should start with rendering invalidation, without adding full-document layout calculations by default. NSTextLayoutManager operations, Meet TextKit 2.
Keep the update sequence explicit as the editor grows: receive current state, invalidate the affected rendering attributes, clear obsolete highlight values, and rebuild matches against current text. Verify query changes, document edits, clearing, and scrolling separately so each failure points to a specific part of the implementation.
Sources
- NSViewRepresentable — Apple
- NSTextLayoutManager — Apple
- renderingAttributesValidator — Apple
- invalidateRenderingAttributes(for:) — Apple
- needsDisplay — Apple
- What’s new in TextKit and text views — 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
- Meet TextKit 2 — Apple