TextKit 2: Обнаружение автозамены клавиатуры iOS
Решение проблемы отсутствия делегатных вызовов для автозамены текста в TextKit 2. Используйте NSTextLayoutManagerDelegate для predictive text и автопунктуации клавиатуры iOS. Fallback на NSTextStorage для старых версий.
Отсутствие делегатных обратных вызовов для автозаменяемого текста в TextKit 2
Я создал пользовательский текстовый редактор с использованием TextKit 2, и столкнулся с проблемой, когда определенные изменения текста, инициированные клавиатурой iOS, не вызывают обычные методы делегата или жизненный цикл редактирования текста.
Описание проблемы
Замена по предложению слов
Когда я печатаю букву, например “t”, и выбираю предложенное слово (“the”) из предложений iOS-клавиатуры, замена происходит мгновенно без какого-либо обратного вызова делегата, фиксирующего это изменение. Мне нужно обнаруживать эти замены для применения пользовательских стилей к заменяемым словам.
Сокращение двойного пробела в точку
Когда пользователь печатает два пробела (которые клавиатура преобразует в точку), это также происходит молча без вызова:
insertText(_:)textView(_:shouldChangeTextIn:replacementText:)textViewDidChange(_:)
Текущие методы делегата не работают
Стандартные методы делегата не предоставляют информацию об этих автоматических изменениях:
func insertText(_ text: String)
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool
func textViewDidChange(_ textView: UITextView)
Ограниченное обходное решение
Единственное место, где можно наблюдать изменения — это обратные вызовы хранилища TextKit:
func textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int)
func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int)
Однако эти обратные вызовы не уточняют происхождение или причину изменения.
Вопрос
Кто-нибудь нашел надежный способ обнаруживать или отслеживать замены текста, инициированные клавиатурой (такие как предиктивный текст или автопунктуация), в TextKit 2?
TextKit 2 действительно решает проблему: стандартные методы UITextViewDelegate пропускают автозамену, но в TextKit 2 есть делегат layout‑уровня — textLayoutManager(_:didChangeTextIn:replacementText:), который вызывается для автозамены клавиатуры iOS (predictive text, double‑space → period) и даёт вам и заменённый диапазон, и вставленный текст; для старых версий iOS придётся использовать NSTextStorage + снимок текста и вычисление дельты вручную как запасной путь. Для безопасного применения стилей учитывайте ограничения редактирования в willProcessEditing/didProcessEditing и избегайте рекурсивных правок.
Содержание
- Почему UITextViewDelegate не ловит автозамену
- TextKit 2: надежный путь — NSTextLayoutManagerDelegate
- Запасной путь для старых iOS: NSTextStorageDelegate и diff
- Практические примеры кода и советы по применению стилей
- Подводные камни, тестирование и рекомендации по совместимости
- Источники
- Заключение
Почему UITextViewDelegate не ловит автозамену
Коротко: автозамену клавиатуры iOS реализация клавиатуры применяет прямо к хранителю текста/слою layout, минуя типичные точки входа вроде insertText(_:), textView(_:shouldChangeTextIn:replacementText:) и даже textViewDidChange(_:). Это подтверждают практические отчёты и обсуждения — например, проблема в RxSwift, где привязка к textViewDidChange не получает автозамену (ReactiveX/RxSwift issue #333), и старые треды на StackOverflow о том, что autocorrect игнорирует shouldChangeTextIn (пример обсуждения).
Иными словами, происхождение правки (пользователь вводил текст или клавиатура подставила слово/точку) может не отражаться в стандартных делегатах.
TextKit 2: надежный путь — NSTextLayoutManagerDelegate
Начиная с TextKit 2 Apple ввели делегат уровня layout — NSTextLayoutManagerDelegate, и он получает вызовы для всех изменений текста, включая те, что делает клавиатура. Метод выглядит так:
@available(iOS 16, *)
func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
didChangeTextIn range: NSTextRange,
replacementText: String
)
Что даёт этот метод:
range— диапазон текста, который был заменён;replacementText— строка, которую вставила клавиатура (например, «the» при выборе предложения или “.” при двойном пробеле).
Пример регистрации делегата:
if #available(iOS 16, *) {
textView.textLayoutManager?.delegate = self
}
И реализация (упрощённо):
@available(iOS 16, *)
extension MyViewController: NSTextLayoutManagerDelegate {
func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
didChangeTextIn range: NSTextRange,
replacementText: String
) {
// Здесь вы получаете replacementText и диапазон замены.
// Используйте API TextKit 2 для конвертации NSTextRange → NSRange
// и применяйте нужные атрибуты к textView.textStorage.
}
}
Почему это лучше: этот делегат вызывается для автозамен, predictive suggestions и двойного пробела, и даёт вставленный текст прямо в параметре replacementText. Подробное объяснение и примерный рабочий поток есть в статье о TextKit 2 (TextKit2: A Top-Down Approach) и в официальном руководстве Apple по TextKit 2 (Using TextKit 2 to interact with text).
Важно: TextKit 2‑делегат доступен на более новых версиях iOS (с учётом примеров — iOS 16+ в некоторых реализациях), поэтому для более старых iOS нужен запасной план.
Запасной путь для старых iOS: NSTextStorageDelegate и diff
Если TextKit 2 недоступен (старые версии iOS), единственное системное место, куда приходят все правки — NSTextStorage. Его делегаты дают willProcessEditing и didProcessEditing, но не содержат replacementText напрямую:
func textStorage(
_ textStorage: NSTextStorage,
willProcessEditing editedMask: NSTextStorage.EditActions,
range editedRange: NSRange,
changeInLength delta: Int
)
func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorage.EditActions,
range editedRange: NSRange,
changeInLength delta: Int
)
Тактика:
- В
willProcessEditingсохраните снимок исходного текста (или только затронутую часть). - В
didProcessEditingполучите новый текст и, используяeditedRangeиdelta, вычислите вставленный фрагмент: в новом тексте вставленный фрагмент находится в позицииeditedRange.locationдлинойeditedRange.length + delta. Проще говоря:
replacementLength = editedRange.length + delta
replacementText = (textStorage.string as NSString).substring(with: NSRange(location: editedRange.location, length: replacementLength))
Пример реализации (с упрощённой функцией вычисления):
private var preEditText: String?
func textStorage(
_ textStorage: NSTextStorage,
willProcessEditing editedMask: NSTextStorage.EditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
preEditText = textStorage.string
}
func textStorage(
_ textStorage: NSTextStorage,
didProcessEditing editedMask: NSTextStorage.EditActions,
range editedRange: NSRange,
changeInLength delta: Int
) {
guard let before = preEditText else { return }
let after = textStorage.string as NSString
let replacementLength = editedRange.length + delta
guard replacementLength >= 0, editedRange.location + replacementLength <= after.length else { return }
let replacement = after.substring(with: NSRange(location: editedRange.location, length: replacementLength))
// Теперь replacement — вставленный текст; примените стили к соответствующему диапазону.
preEditText = nil
}
Ограничения этого подхода:
NSTextStorageне говорит, кто инициировал правку (клавиатура, ваш код или Undo).- Работа в
didProcessEditingи изменение текста там может привести к сложностям с Undo/redo и даже крашам; некоторые авторы рекомендуют вносить прямые изменения вwillProcessEditingили аккуратно планировать правки (советы и кейсы, сводка по параметрам — SmallDeskSoftware).
Практические примеры кода и советы по применению стилей
- Начните с TextKit 2, если можно:
- Зарегистрируйте
textView.textLayoutManager?.delegate = self. - В
textLayoutManager(_:didChangeTextIn:replacementText:)применяйте атрибуты к полученному диапазону/замене. ReplacementText даёт явный контент для распознавания предиктивной подстановки.
-
Если работаете на iOS < 16, используйте
NSTextStorage+ снимок + diff (см. выше). Это надёжно обнаружит изменение и позволит извлечь вставленный текст даже при автозаменах. -
Как безопасно применять атрибуты:
- Оборачивайте изменения в
textStorage.beginEditing()/endEditing(). - Избегайте изменения содержимого в
didProcessEditingсинхронно, если это вызывает рекурсию; можно поставить задачу на следующий цикл RunLoop или применять только атрибуты (не изменяя текст). - Проверяйте, не идентичны ли текущие атрибуты, чтобы не писать их заново и не вызывать лишних изменений.
- Тонкие моменты:
- Апдейт UI должен учитывать UndoManager — не ломайте историю правок.
- Тестируйте на реальных устройствах и с разными клавиатурами (с/без предиктива, аппаратная клавиатура ведёт себя иначе).
- Для реактивных библиотек (RxSwift) переключитесь с наблюдения
textViewDidChangeна наблюдениеtextStorageили, где возможно, на TextKit 2‑делегат (см. обсуждение).
- Пример: подсветка вставленного слова после автозамены (псевдокод, fallback через NSTextStorage):
func textStorage(... didProcessEditing editedMask: ..., range editedRange: NSRange, changeInLength delta: Int) {
let after = textStorage.string as NSString
let replacementLength = editedRange.length + delta
let nsRange = NSRange(location: editedRange.location, length: replacementLength)
let replacement = after.substring(with: nsRange)
// Простейшая эвристика: если replacement содержит точку и delta < 0 — это может быть "double-space -> ."
// Применяем атрибуты:
textStorage.beginEditing()
textStorage.addAttribute(.backgroundColor, value: UIColor.yellow, range: nsRange)
textStorage.endEditing()
}
Подводные камни, тестирование и рекомендации по совместимости
- Аппаратные клавиатуры часто не генерируют предиктивные подстановки — учтите это при тестировании.
- Не полагайтесь на
textViewDidChangeдля автозамен — она не всегда вызывается. См. обсуждения и багрепорты (RxSwift issue, StackOverflow). - Изменения в
didProcessEditingмогут влиять на selection/undo; альтернативный вариант — подготовить изменения вwillProcessEditingили применять только атрибуты, не меняя текст. Полезные заметки о крашах и обходах — Noah Gilmore и краткий разбор параметровtextStorage— SmallDeskSoftware. - TextKit 2 — лучший инструмент там, где он доступен; если ваш продукт ориентирован на iOS 16+, используйте
NSTextLayoutManagerDelegateкак основной путь.
Источники
- TextKit2: A Top-Down Approach
- UITextView.rx_text doesn’t catch iOS autocorrect text changes · Issue #333 · ReactiveX/RxSwift
- textView:shouldChangeTextInRange:replacementText: returning NO, but autocorrect ignores? — Stack Overflow
- Using TextKit 2 to interact with text — Apple Developer Documentation
- Swift TextKit: summary of willProcessEditing/didProcessEditing parameters — SmallDeskSoftware
- NSTextStorageDelegate’s textStorage(_,willProcessEditing:…) moves selection — Stack Overflow
- NSTextStorage: Crash in didProcessEditing — Noah Gilmore
- How to use Auto-Correction and predictive text on your iPhone, iPad, or iPod touch — Apple Support
Заключение
Итог: для надёжного обнаружения автозамены клавиатуры iOS используйте TextKit 2 и его NSTextLayoutManagerDelegate (метод textLayoutManager(_:didChangeTextIn:replacementText:)) — он возвращает и заменённый диапазон, и сам вставленный текст. Если TextKit 2 недоступен, фолбэк — NSTextStorage + снимок/дифф с расчётом editedRange и changeInLength, но это менее удобный и более хрупкий путь: учитывайте ограничения по Undo, selection и избегайте рекурсивных правок.