Автоматично боравене с бутона Напред

Въвеждането на текст в множество текстови полета е толкова често срещан модел - навсякъде, не само в iOS - трябва да има начин за лесно навигиране от едно поле до следващото, за предпочитане „правилното“. За съжаление, iOS не предлага тази функция, но нека да видим как можем да постигнем това сами.

Първо, кратко обобщение на това, от което се нуждаем:

  • Вижте бутона Напред, ако има полета след текущото; за предпочитане само такива, които са все още празни.
  • Докосването на бутона Next ни отвежда до правилното поле.
  • Вижте бутона Връщане, ако няма повече полета след текущото; за предпочитане като се вземат предвид празните.
  • Докосването на бутона за връщане оттегля текущия отговарящ.
  • Всичко трябва да работи автоматично, независимо от изгледа и без значение колко полета има.
  • Вероятно всичко трябва да бъде капсулирано в протокол.

Страхотно, нека се потопим и да започнем с протокола. Какво ще ни трябва тук?

  • Списък с текстови полета (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.