Мобильная разработка

TextKit 2: Обнаружение автозамены клавиатуры iOS

Решение проблемы отсутствия делегатных вызовов для автозамены текста в TextKit 2. Используйте NSTextLayoutManagerDelegate для predictive text и автопунктуации клавиатуры iOS. Fallback на NSTextStorage для старых версий.

Отсутствие делегатных обратных вызовов для автозаменяемого текста в TextKit 2

Я создал пользовательский текстовый редактор с использованием TextKit 2, и столкнулся с проблемой, когда определенные изменения текста, инициированные клавиатурой iOS, не вызывают обычные методы делегата или жизненный цикл редактирования текста.

Описание проблемы

Замена по предложению слов

Когда я печатаю букву, например “t”, и выбираю предложенное слово (“the”) из предложений iOS-клавиатуры, замена происходит мгновенно без какого-либо обратного вызова делегата, фиксирующего это изменение. Мне нужно обнаруживать эти замены для применения пользовательских стилей к заменяемым словам.

Сокращение двойного пробела в точку

Когда пользователь печатает два пробела (которые клавиатура преобразует в точку), это также происходит молча без вызова:

  • insertText(_:)
  • textView(_:shouldChangeTextIn:replacementText:)
  • textViewDidChange(_:)

Текущие методы делегата не работают

Стандартные методы делегата не предоставляют информацию об этих автоматических изменениях:

swift
func insertText(_ text: String)
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool
func textViewDidChange(_ textView: UITextView)

Ограниченное обходное решение

Единственное место, где можно наблюдать изменения — это обратные вызовы хранилища TextKit:

swift
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 не ловит автозамену

Коротко: автозамену клавиатуры iOS реализация клавиатуры применяет прямо к хранителю текста/слою layout, минуя типичные точки входа вроде insertText(_:), textView(_:shouldChangeTextIn:replacementText:) и даже textViewDidChange(_:). Это подтверждают практические отчёты и обсуждения — например, проблема в RxSwift, где привязка к textViewDidChange не получает автозамену (ReactiveX/RxSwift issue #333), и старые треды на StackOverflow о том, что autocorrect игнорирует shouldChangeTextIn (пример обсуждения).
Иными словами, происхождение правки (пользователь вводил текст или клавиатура подставила слово/точку) может не отражаться в стандартных делегатах.


TextKit 2: надежный путь — NSTextLayoutManagerDelegate

Начиная с TextKit 2 Apple ввели делегат уровня layout — NSTextLayoutManagerDelegate, и он получает вызовы для всех изменений текста, включая те, что делает клавиатура. Метод выглядит так:

swift
@available(iOS 16, *)
func textLayoutManager(
 _ textLayoutManager: NSTextLayoutManager,
 didChangeTextIn range: NSTextRange,
 replacementText: String
)

Что даёт этот метод:

  • range — диапазон текста, который был заменён;
  • replacementText — строка, которую вставила клавиатура (например, «the» при выборе предложения или “.” при двойном пробеле).

Пример регистрации делегата:

swift
if #available(iOS 16, *) {
 textView.textLayoutManager?.delegate = self
}

И реализация (упрощённо):

swift
@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 напрямую:

swift
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
)

Тактика:

  1. В willProcessEditing сохраните снимок исходного текста (или только затронутую часть).
  2. В 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))

Пример реализации (с упрощённой функцией вычисления):

swift
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).

Практические примеры кода и советы по применению стилей

  1. Начните с TextKit 2, если можно:
  • Зарегистрируйте textView.textLayoutManager?.delegate = self.
  • В textLayoutManager(_:didChangeTextIn:replacementText:) применяйте атрибуты к полученному диапазону/замене. ReplacementText даёт явный контент для распознавания предиктивной подстановки.
  1. Если работаете на iOS < 16, используйте NSTextStorage + снимок + diff (см. выше). Это надёжно обнаружит изменение и позволит извлечь вставленный текст даже при автозаменах.

  2. Как безопасно применять атрибуты:

  • Оборачивайте изменения в textStorage.beginEditing() / endEditing().
  • Избегайте изменения содержимого в didProcessEditing синхронно, если это вызывает рекурсию; можно поставить задачу на следующий цикл RunLoop или применять только атрибуты (не изменяя текст).
  • Проверяйте, не идентичны ли текущие атрибуты, чтобы не писать их заново и не вызывать лишних изменений.
  1. Тонкие моменты:
  • Апдейт UI должен учитывать UndoManager — не ломайте историю правок.
  • Тестируйте на реальных устройствах и с разными клавиатурами (с/без предиктива, аппаратная клавиатура ведёт себя иначе).
  • Для реактивных библиотек (RxSwift) переключитесь с наблюдения textViewDidChange на наблюдение textStorage или, где возможно, на TextKit 2‑делегат (см. обсуждение).
  1. Пример: подсветка вставленного слова после автозамены (псевдокод, fallback через NSTextStorage):
swift
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 и краткий разбор параметров textStorageSmallDeskSoftware.
  • TextKit 2 — лучший инструмент там, где он доступен; если ваш продукт ориентирован на iOS 16+, используйте NSTextLayoutManagerDelegate как основной путь.

Источники


Заключение

Итог: для надёжного обнаружения автозамены клавиатуры iOS используйте TextKit 2 и его NSTextLayoutManagerDelegate (метод textLayoutManager(_:didChangeTextIn:replacementText:)) — он возвращает и заменённый диапазон, и сам вставленный текст. Если TextKit 2 недоступен, фолбэк — NSTextStorage + снимок/дифф с расчётом editedRange и changeInLength, но это менее удобный и более хрупкий путь: учитывайте ограничения по Undo, selection и избегайте рекурсивных правок.

Авторы
Проверено модерацией
Модерация