SwiftUI로 감싼 NSTextView에서 검색 하이라이트를 계속 업데이트하려면 쿼리나 텍스트가 바뀔 때 TextKit 2 렌더링 속성을 무효화하고, 렌더링 검증기에서 현재 콘텐츠를 바탕으로 하이라이트를 다시 만들고, 다시 그리기를 요청합니다.
이 안내에서는 Tile A, Circle B와 같은 합성 레이블을 위한 간단한 Mac 편집기를 만듭니다. 구체적인 작업은 같은 앱에서 검색 필드를 변경하거나 문서를 편집하는 동안 일치하는 레이블을 강조 표시하는 것입니다.
하이라이트가 바뀔 때 무엇을 업데이트해야 하나요?
서로 독립적인 입력은 두 가지입니다. 문자의 내용인 문서와 하이라이트 쿼리입니다. 문서를 편집하면 텍스트 콘텐츠가 바뀝니다. 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 앱을 만들고, 배포 대상을 macOS 13 이상으로 설정한 다음 생성된 앱 및 콘텐츠 뷰 선언을 이 단일 파일로 바꾸세요. 타깃에는 @main 앱 선언이 정확히 하나 있어야 합니다.
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 상태를 받아 변경된 쿼리를 기존 텍스트 뷰에 복사합니다. 검증기는 실행될 때마다 저장된 쿼리를 읽습니다. 약한 참조는 오래된 쿼리가 포함될 수 있는 초기 representable 값을 캡처하는 대신 AppKit 뷰를 가리킵니다. 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