Автоматическая обработка кнопки Далее

Ввод текста в несколько текстовых полей является такой распространенной схемой - везде, не только в iOS - должен быть способ легко переходить от одного поля к другому, желательно «правильному». К сожалению, iOS не предлагает эту функцию, но давайте посмотрим, как мы можем сделать это сами.

Во-первых, краткий обзор того, что нам нужно:

  • См. Кнопку «Далее», если после текущего поля есть какие-либо поля; желательно только те, которые еще пусты.
  • Нажав кнопку «Далее», мы перейдем в нужное поле.
  • См. Кнопку «Возврат», если после текущего поля больше нет; желательно с учетом пустых.
  • Нажатие кнопки Return увольняет текущего респондента.
  • Все должно работать автоматически, независимо от вида и количества полей.
  • Вероятно, все должно быть инкапсулировано в протокол.

Круто, давайте перейдем к протоколу. Что нам здесь нужно?

  • Список текстовых полей (1).
  • Метод, обрабатывающий начало редактирования текстовых полей для отображения правильной кнопки (2).
  • Метод, обрабатывающий нажатие конца / возврата при редактировании текстовых полей для выполнения правильного действия (3).
protocol NextTextFieldHandler {
    var textFields: [UITextField] { get } // 1 
    func setupReturnKeyType(for textField: UITextField) // 2 
    func handleReturnKeyTapped(on textField: UITextField) // 3 
}

Свойство textFields (1) может содержать либо все поля в представлении, либо только те поля, по которым мы заинтересованы в навигации - протоколу это не важно, наша задача - решить, что необходимо, при соблюдении протокол. Эти две функции используются для удовлетворения двух других наших потребностей (2, 3).

extension NextTextFieldHandler { 
    private var _textFields: [UITextField] { 
        return textFields.filter { !$0.isHidden && $0.alpha != 0 && $0.isEnabled } // 1 
    }
}

Мы могли бы добавить некоторые меры безопасности, на случай, если мы не будем осторожны, что также будет действовать для удобства: эти условия придется записывать снова и снова, чтобы мы могли автоматически отфильтровать скрытые поля и отключенные (1) .

Но, как указано выше, протокол не должен заботиться о том, что входит в textFields; по какой-то причине мы могли бы захотеть включить отключенное текстовое поле, и у нас не было бы возможности сделать это. Если вы действительно знаете, что в проекте нет странных сценариев, вы можете добавить указанное выше свойство и использовать его вместо textFields.

Далее нам нужен способ извлечь все поля после текущего:

extension NextTextFieldHandler { 
    private func fields(after textField: UITextField) -> ArraySlice<UITextField> { 
        let textFields = self.textFields // 1 
        guard let currentIndex = textFields.index(of: textField) else { return [] } // 2 
        
        return textFields.suffix(from: min(currentIndex + 1, textFields.endIndex - 1)) // 3 
    }
 
}

Сначала мы сохраняем массив в локальной константе для небольшого повышения эффективности (1), поскольку свойство textFields может быть вычисленным. В некоторых текстовых полях это усиление незначительно, но все же рекомендуется не перебирать вычисляемые свойства - особенно при итерации - если нам действительно не нужно, чтобы свойство пересчитывалось снова и снова, что нам не нужно.

Затем мы находим индекс переданного текстового поля или спасаемся, если не можем его найти. Наконец, мы возвращаем ArraySlice со всеми текстовыми полями, начиная с поля, следующего за текущим.

Зачем нужен ломтик? Потому что сохраняет оригинальные индексы. Так, например, если у нас есть [field1, field2, field3, field4] и мы делаем срез, начиная с индекса 2, мы получим [field3, field4], но каждый элемент будет иметь следующие индексы: [2, 3]. Мы чуть позже увидим, зачем это нужно.

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

extension NextTextFieldHandler { 
    private func emptyFieldsExist(after textField: UITextField) -> Bool { 
        return fields(after: textField)
            .filter { $0.text?.isEmpty != false }
            .isEmpty == false 
    } 
}

Он берет только поля после текущего, фильтрует то, в котором есть текст, и проверяет, является ли результат пустым.

Наконец, мы можем приступить к реализации:

extension NextTextFieldHandler { 
    func setupReturnKeyType(for textField: UITextField) { 
        let textFields = self.textFields // 1 
        guard let currentIndex = textFields.index(of: textField) else { return } // 2 
        let emptyFieldsExistAfterCurrent = emptyFieldsExist(after: textField) 
        if currentIndex < textFields.endIndex - 1, emptyFieldsExistAfterCurrent { // 2 
            textField.returnKeyType = .next 
        } 
        else { // 3 
            textField.returnKeyType = .done 
        } 
    } 
}

Как и раньше, мы сохраняем массив textFields в локальной константе (1) и спасаемся, если не можем найти индекс переданного в textField (2).

Затем мы должны принять решение:

  • Если мы не на последнем textField и у нас есть пустые поля после текущего, ключ возврата должен быть .next (2).
  • Если мы находимся на последнем textField или все поля после текущего заполнены, ключ возврата должен быть .done (3).

Наконец, логика нажатия клавиши Next / Return немного сложнее, и мы наконец поймем, почему мы использовали ArraySlice выше:

extension NextTextFieldHandler { 
    func handleReturnKeyTapped(on textField: UITextField) { 
        let textFields = self.textFields // 1 
        guard let currentIndex = textFields.index(of: textField) else { return } // 2 
        let fieldsAfterCurrent = fields(after: textField) // 3 
        let nextEmptyIndex = fieldsAfterCurrent
            .firstIndex { $0.text?.isEmpty != false } // 4 
            ?? textFields.index(currentIndex, offsetBy: 1, limitedBy: textFields.endIndex - 1) // 5 
            ?? textFields.endIndex - 1 // 6 
        let emptyFieldsExistAfterCurrent =	emptyFieldsExist(after: textField) 
        if currentIndex == textFields.endIndex - 1 || !emptyFieldsExistAfterCurrent { // 7 
            textField.resignFirstResponder() 
        } 
        else { // 8 
            textFields[nextEmptyIndex].becomeFirstResponder() 
        } 
    } 
}

Как всегда, мы сначала сохраняем массив textFields в локальной константе (1) и выходим, если не можем найти индекс переданного в textField (2). Затем мы получаем следующие поля после того, которое было передано как срез (3), и находим индекс первого пустого поля (4), возвращаясь к следующему текстовому полю, если это не удается (5), снова возвращаясь к последнее текстовое поле, если это тоже не удается (6).

Теперь нам нужно принять другое решение:

  • Если мы находимся на последнем textField или во всех полях после заполнения текущего, уволите первого респондента (7).
  • Если мы не на последнем textField и у нас есть пустые поля после текущего, сфокусируйтесь на первом пустом textField после текущего (8).

И вот здесь нам нужен ArraySlice. Давайте рассмотрим предыдущий пример:

  • textFields = [field1, field2, field3, field4].
  • Мы на странице field1, и первое пустое текстовое поле - это field3 с индексом 2.
  • fields(after: field1) возвращает [field2, field3, field4] с индексами [1, 2, 3].
  • firstIndex { $0.text?.isEmpty != false } (4) вернет индекс 2, как и должно.
  • textFields[nextEmptyIndex].becomeFirstResponder() сфокусируется на правильном поле, field3.

Если бы мы не использовали срез, fields(after: field) все равно вернул бы field2, field3, field4], но с индексами [0, 1, 2]. Кроме того, мы не используем filter внутри fields(after:), потому что это приведет к сбросу индексов, заставляя их начинать с 0.

Если бы мы не использовали срез, мы могли бы также найти field3, используя first { $0.text?.isEmpty != false } в (4), тогда мы бы нашли его индекс, вызвав textFields.index(of: nextField), но я думаю, что срез немного лучше, поскольку мы можем писать более простые резервные варианты (5, 6).

Как мы его используем? На самом деле довольно просто:

extension LoginViewController: UITextFieldDelegate, NextTextFieldHandler { 
    var textFields: [UITextField] { // 1 
        return aStackView.arrangedSubviews
            .compactMap { $0 as? UITextField }
            .filter { !$0.isHidden && $0.alpha != 0 && $0.isEnabled } // 2 
        /* 3 
        if mode == .login { 
            return [emailTextField, passwordTextField] 
        } 
        else { 
            return [nameTextField, emailTextField, passwordTextField] 
        } 
        */ 
    } 
    func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { 
        setupReturnKeyType(for: textField) // 4 
        return true 
    } 
    func textFieldShouldReturn(_ textField: UITextField) -> Bool { 
        handleReturnKeyTapped(on: textField) // 5 
        return true 
    } 
}

Сначала мы устанавливаем нужный нам textFields (1). Мы извлекаем все текстовые поля из нашего воображаемого UIStackView (2) (или возвращаем их вручную - 3), затем вызываем setupReturnKeyType(for:) из textFieldShouldBeginEditing(:_) (4) и handleReturnKeyTapped(on:) из textFieldShouldReturn(:_) (5).

Как всегда, я хотел бы знать, что вы думаете, или можно ли что-нибудь улучшить @rolandleth.

Первоначально опубликовано на rolandleth.com.