Чтобы подсветка результатов поиска обновлялась в NSTextView, обёрнутом в SwiftUI, при изменении запроса или текста аннулируйте атрибуты отображения TextKit 2, перестраивайте подсветку по текущему содержимому в валидаторе отображения и запрашивайте повторную отрисовку.
В этом руководстве создаётся небольшой редактор для Mac с искусственными метками, например Tile A и Circle B. Задача — подсвечивать совпадающие метки, пока пользователь меняет поле поиска или редактирует документ в том же приложении.
Что нужно обновлять при изменении подсветки?
Есть два независимых входных параметра: символы документа и запрос для подсветки. Редактирование документа меняет его текстовое содержимое. Изменение поля поиска SwiftUI меняет состояние приложения, не обязательно затрагивая текстовую систему. Обёртка должна передавать оба события подсветчику.
Apple предоставляет renderingAttributesValidator в качестве обратного вызова для передачи атрибутов отображения фрагмента макета. Также доступен invalidateRenderingAttributes(for:) для аннулирования этих атрибутов в заданном диапазоне текста. Вместе эти API позволяют хранить запрос в текущем состоянии и перестраивать подсветку при проверке отображения. Валидатор атрибутов отображения, Аннулирование атрибутов отображения.
Повторная отрисовка — отдельная задача. Установка needsDisplay помечает представление для отрисовки на следующем проходе цикла обработки событий. Она сама по себе не указывает, какое правило подсветки изменилось, и не рассчитывает новые совпадения. Поэтому приведённая ниже реализация аннулирует атрибуты отображения и запрашивает повторную отрисовку. NSView needsDisplay.
| Событие | Обновляемое состояние | Действие |
|---|---|---|
| Изменение поля поиска | Текущий запрос текстового представления | Аннулировать атрибуты отображения и запросить повторную отрисовку |
| Изменение текста документа | AppKit уже содержит отредактированный текст | Аннулировать подсветку через делегат текста |
| Требуется проверка фрагмента | Временные атрибуты подсветки | Очистить прежний фон и применить текущие совпадения |
| Запрос становится пустым | Текущий запрос | Удалить прежнюю подсветку, не добавляя новую |
Оставьте текстовое представление на TextKit 2
Для этого примера укажите целевую версию macOS 13 или новее и явно инициализируйте текстовое представление с помощью NSTextView(usingTextLayoutManager: true). Apple представила конструкторы, выбирающие движок, при переходе, описанном в сессии WWDC22. Основные API AppKit для атрибутов отображения, используемые здесь, документированы как доступные начиная с macOS 12. Что нового в TextKit и текстовых представлениях, NSTextLayoutManager.
При диагностике активного движка проверяйте textView.textLayoutManager. Обращение к layoutManager, в том числе через текстовый контейнер, может включить режим совместимости с TextKit 1. Apple описывает этот переход как необратимый для существующего текстового представления. Не используйте запросы к устаревшему менеджеру макета в коде для TextKit 2. Совместимость и модернизация TextKit.
Другая граница — управление макетом со стороны SwiftUI. Apple предостерегает от прямого изменения frame или bounds у представляемого представления. Здесь корневым представлением служит NSScrollView; начальное назначение кадра выполняется для подчинённого представления документа в AppKit. Внешний размер области прокрутки контролирует SwiftUI. NSViewRepresentable.
Создайте полноценный редактируемый пример
Создайте в Xcode приложение SwiftUI для macOS, укажите целевую версию 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, описанные ниже; эти проверки не подтверждают работу графического интерфейса.
Зачем обёртке нужны два пути обновления
updateNSView получает новое состояние SwiftUI и копирует изменившийся запрос в существующее текстовое представление. При каждом запуске валидатор считывает сохранённый запрос. Слабая ссылка указывает на представление AppKit, а не захватывает исходное значение представляемого объекта, в котором мог сохраниться устаревший запрос. Apple указывает, что метод обновления представляемого объекта предназначен для применения новых данных SwiftUI. Жизненный цикл NSViewRepresentable.
Ввод текста в документ проходит через координатор. Apple описывает textDidChange(_:) как уведомление делегату об изменении символов или атрибутов форматирования. Координатор в ответ сразу обновляет подсветку; для вызова обновления SwiftUI привязка документа не нужна. textDidChange(_:).
Начальный пример текста задаётся только при создании. Последующие обновления запроса не затрагивают документ. Если позднее вы добавите привязку документа, сравнивайте входящее содержимое перед заменой и определите, какая сторона отвечает за изменения. Повторное назначение примера текста при каждом обновлении перезаписывало бы документ пользователя.
Почему нужно очищать подсветку до проверки пустого запроса
При каждой проверке подсветчик удаляет предыдущий атрибут .backgroundColor фрагмента, прежде чем искать совпадения. Поэтому очистка выполняется и при пустом запросе. Так устраняется распространённая ошибка: поиск работает, пока поле не очищают, после чего на экране остаётся прежняя подсветка.
В примере используются документированные API для удаления и добавления отдельных ключей, поэтому изменения ограничены атрибутом фона. Предполагается, что фон отображения контролирует этот подсветчик; если его также задаёт другая функция, объедините правила в одной политике отображения. Удаление атрибута отображения, Добавление атрибута отображения.
Совпадения также пересекаются с проверяемым фрагментом. По документации Apple, rangeInElement отсчитывается от начала документа, а intersection(_:) возвращает общий диапазон текста. Таким образом, совпадение учитывается только в той части, которая относится к текущему фрагменту. Диапазон фрагмента, Пересечение текстовых диапазонов.
Сохраняйте соответствие смещений совпадений текущему документу
В примере выполняется поиск по снимку NSString и используется возвращённый им NSRange. Apple указывает, что длины, индексы и диапазоны NSString используют кодовые единицы UTF-16. Swift также предоставляет представление строки в UTF-16 через utf16; количество символов Swift не заменяет эти смещения. Индексация NSString, String.utf16.
Для наглядности добавьте в начало примера документа нейтральный символ и пробел 🔷 . Тогда первое Tile начинается со смещения UTF-16 3 и имеет длину 4. Второе вхождение начинается со смещения 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