Избегание клавиатуры в фокусе UITextField

Пара сообщения назад я писал об автоматической обработке кнопки «Далее». В этом посте я хотел бы написать об автоматическом отказе от использования клавиатуры таким образом, чтобы обеспечить как удобство работы пользователя, так и удобство работы разработчика.

Большинство приложений имеют немного этакая форма, которую требуется заполнить, пусть даже просто логин/регистрация, а то и несколько. Меня как пользователя огорчает то, что клавиатура закрывает текстовое поле, которое я собираюсь заполнить; это плохой пользовательский опыт. Как разработчики, мы хотели бы решить эту проблему как можно проще и сделать решение максимально пригодным для повторного использования.

Что означает хороший пользовательский опыт?

  • Сфокусированный UITextField находится над клавиатурой в фокусе.
  • Сфокусированный UITextField «отправляется обратно» при увольнении.

Что означает хороший опыт разработчика? Все должно происходить как можно автоматически, поэтому мы еще раз пойдем по протоколу. Что этот протокол должен инкапсулировать?

  • Наблюдение за клавиатурой покажет/скроет уведомления.
  • По внешнему виду клавиатуры необходимо изменить scrollView.contentInset а также scrollView.contentOffset таким образом, который приносит UITextField прямо над клавиатурой.
  • При исчезновении клавиатуры необходимо сбросить вставку и смещение к предыдущим значениям.

Имея это в виду, давайте построим наш протокол:

protocol KeyboardListener: AnyObject { // 1

   var scrollView: UIScrollView { get } // 2
   var contentOffsetPreKeyboardDisplay: CGPoint? { get set } // 3
   var contentInsetPreKeyboardDisplay: UIEdgeInsets? { get set } // 4

   func keyboardChanged(with notification: Notification) // 5

}

Нам нужно ограничить этот протокол, чтобы он соответствовал только классам (1), потому что нам нужно изменить два preKeyboard свойства (3, 4) — мы будем использовать их, чтобы узнать, как вернуть scrollViewвставку и смещение при отпускании клавиатуры — и мы, скорее всего, реализуем это в UIViewController тем не мение.

Протокол также должен иметь scrollView (2), иначе это не совсем… осуществимо (я думаю, это могло бы быть выполнимый). Наконец, нам нужен метод, который будет обрабатывать все (5), но он просто действует как прокси для двух помощников, которые мы реализуем чуть позже:

extension KeyboardListener {

   func keyboardChanged(with notification: Notification) {
      guard
         notification.name == UIResponder.keyboardWillShowNotification,
         let rawFrameEnd = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey],
         let frameEnd = rawFrameEnd as? CGRect,
         let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval
      else {
         resetScrollView() // 1

         return
      }

      if let currentTextField = UIResponder.current as? UITextField {
         updateContentOffsetOnTextFieldFocus(currentTextField, bottomCoveredArea: frame.height) // 2
      }

      scrollView.contentInset.bottom += frameEnd.height // 3
   }

}

Если уведомление не для willShowили мы не можем разобрать уведомление userInfoвыручить и сбросить scrollView. Если это так, увеличьте нижнюю вставку на высоту клавиатуры (3). Что касается (2), мы находим текущего первого респондента с помощью маленькая хитрость звонить updateContentOffsetOnTextFieldFocus(_:bottomCoveredArea:) с, но мы могли бы также вызвать его из нашего делегата textFieldShouldBeginEditing(_:).

Первый помощник обновит наши два preKeyboard характеристики:

extension KeyboardListener where Self: UIViewController { // 1

   func keyboardChanged(with notification: Notification) {
      // [...]
   }

   func updateContentOffsetOnTextFieldFocus(_ textField: UITextField, bottomCoveredArea: CGFloat) {
      let projectedKeyboardY = view.window!.frame.minY - bottomCoveredArea // 2

      if contentInsetPreKeyboardDisplay == nil { // 3
         contentInsetPreKeyboardDisplay = scrollView.contentInset
      }
      if contentOffsetPreKeyboardDisplay == nil { // 4
         contentOffsetPreKeyboardDisplay = scrollView.contentOffset
      }

      let textFieldFrameInWindow = view.window!.convert(textField.frame,
                                                        from: textField.superview) // 5
      let bottomLimit = textFieldFrameInWindow.maxY + 10 // 6

      guard bottomLimit > projectedKeyboardY else { return } // 7

      let delta = projectedKeyboardY - bottomLimit // 8
      let newOffset = CGPoint(x: scrollView.contentOffset.x,
                              y: scrollView.contentOffset.y - delta) // 9

      scrollView.setContentOffset(newOffset, animated: true) // 10
   }

}

Теперь мы обновим расширение протокола с помощью Self: UIViewController ограничение (1), потому что нам понадобится доступ к окну. Это не должно быть неудобством, потому что этот протокол, скорее всего, будет использоваться UIViewControllers, но другим подходом будет замена всех view.window происшествия с UIApplication.shared.keyWindow или вариация UIApplication.shared.windows[yourIndex]если у вас сложная иерархия.

Затем мы вычисляем minY для клавиатуры (2) — используем параметр для тех случаев, когда у нас есть кастомный inputView и мы будем называть это из textFieldShouldBeginEditing(_:), Например. Затем мы проверяем, является ли наш preKeyboard свойства nil и если они есть, мы присваиваем текущие значения из scrollView (3, 4); их может не быть nil если мы изменили их до вызова этого метода.

Затем мы конвертируем textFieldх maxY в координатах окна (5) и добавить 10 к нему (6), поэтому у нас есть небольшой отступ между полем и клавиатурой. Если bottomLimit находится над клавиатурой minYничего не делать, потому что textField уже полностью виден (7). Если bottomLimit находится ниже клавиатуры minYвычислить разницу между ними (8), чтобы мы знали, сколько прокручивать scrollView (9, 10) так, что textField будет видно.

Второй помощник сбрасывает наш scrollView вернуться к исходным значениям:

extension KeyboardListener where Self: UIViewController {

   func keyboardChanged(with notification: Notification) {
      // [...]
   }

   func updateContentOffsetOnTextFieldFocus(_ textField: UITextField, bottomCoveredArea: CGFloat) {
      // [...]
   }

   func resetScrollView() {
      guard // 1
         let originalInsets = contentInsetPreKeyboardDisplay,
         let originalOffset = contentOffsetPreKeyboardDisplay
      else { return }

      scrollView.contentInset = originalInsets // 2
      scrollView.setContentOffset(originalOffset, animated: true) // 3

      contentInsetPreKeyboardDisplay = nil // 4
      contentOffsetPreKeyboardDisplay = nil // 5
   }

}

Если у нас нет оригинальных вставок/смещений, ничего не делайте; например, используется аппаратная клавиатура (1). Если мы это делаем, мы сбрасываем scrollView к своим исходным значениям до клавиатуры (2, 3) и nil-снаружи preKeyboard свойства (4, 5).

Использование этого может варьироваться в зависимости от ваших потребностей, но обычный сценарий будет выглядеть так:

final class FormViewController: UIViewController, KeyboardListener {

   let scrollView = UIScrollView()

   /* Or if you have a tableView:

      private let tableView = UITableView()
      var scrollView: UIScrollView {
         return tableView
      }
   */

   // [...]

   override func viewDidLoad() {
      super.videDidLoad()

      NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: nil) { [weak self] notification in
         self?.keyboardChanged(with: notification)
      }
      NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: nil) { [weak self] notification in
         self?.keyboardChanged(with: notification)
      }

      // And that's it!
   }

   // [...]

}

Это был длинный пост, но теперь у нас есть хорошая логика «держать текстовое поле над клавиатурой», и если мы реализуем все это вместе с автоматическая обработка кнопки «Далее»это будет похоже на волшебство для наших пользователей.

Проверить эта почта о небольшой автоматизации этого еще дальше, внедрив систему Broadcaster/Listener и переместив наблюдателей в Broadcaster сам. Нам больше не нужно было бы добавлять наблюдателей в наши контроллеры представлений, нам просто нужно было бы вызвать Broadcaster.shared.addListener(self).

Как обычно, дайте мне знать, если есть что-то, что можно улучшить. @роландлет.

Вы можете найти больше подобных статей в моем блоге или подписаться на мой ежемесячный Новостная рассылка. Первоначально опубликовано на https://rolandleth.com.

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *