格式化 UITextField 的 text 之后光标应该定位在何处?

1,867 阅读2分钟

这是我参与更文挑战的第7天,活动详情查看: 更文挑战

在用户输入电话号码时,产品设计上经常会要求在输入的文本中加入分隔符(比如:空格),以更清晰的方式显示电话号码。

在 UITextField 的 text 中加入分隔符轻而易举,但是如何正确的修改光标的位置呢?下面是我在阅读 PhoneNumberKit 之后学习到的方法:

在修改 UITextField 的 text 之前,查找当前光标之后第一个非分隔符的字符(也就是当前光标后第一个用户输入的字符),并查看光标之后一共有多少个与第一个非分隔符相同的字符,然后在格式化(添加分隔符)之后的字符串中从末尾开始查找相同个数的之前找到的第一个非分隔符,找到的位置就是当前光标应该定位的位置。

说起来比较绕,我们来看代码(仅展示主要代码)。

首先定义一个用来存储当前光标信息的结构体 CursorPosition

internal struct CursorPosition {
    let numberAfterCursor: String
    let repetitionCountFromEnd: Int
}

其中 numberAfterCursor 表示当前光标后的第一个非分隔符字符,repetitionCountFromEnd 表示一共有多少个。

获取当前光标后的第一个非分隔符字符串,并计算一共有多少个。

internal func extractCursorPosition() -> CursorPosition? {
    var repetitionCountFromEnd = 0
    // Check that there is text in the UITextField
    guard let text = text, let selectedTextRange = selectedTextRange else {
        return nil
    }
    let textAsNSString = text as NSString
    let cursorEnd = offset(from: beginningOfDocument, to: selectedTextRange.end)
    // Look for the next valid number after the cursor, when found return a CursorPosition struct
    for i in cursorEnd..<textAsNSString.length {
        let cursorRange = NSRange(location: i, length: 1)
        let candidateNumberAfterCursor: NSString = textAsNSString.substring(with: cursorRange) as NSString
        if candidateNumberAfterCursor.rangeOfCharacter(from: self.nonNumericSet as CharacterSet).location == NSNotFound {
            for j in cursorRange.location..<textAsNSString.length {
                let candidateCharacter = textAsNSString.substring(with: NSRange(location: j, length: 1))
                if candidateCharacter == candidateNumberAfterCursor as String {
                    repetitionCountFromEnd += 1
                }
            }
            return CursorPosition(numberAfterCursor: candidateNumberAfterCursor as String, repetitionCountFromEnd: repetitionCountFromEnd)
        }
    }
    return nil
}

根据当前文本找的信息,在新的的字符串中查找相同个数的之前找到的第一个非分隔符

// Finds position of previous cursor in new formatted text
internal func selectionRangeForNumberReplacement(textField: UITextField, formattedText: String) -> NSRange? {
    let textAsNSString = formattedText as NSString
    var countFromEnd = 0
    guard let cursorPosition = extractCursorPosition() else {
        return nil
    }

    for i in stride(from: textAsNSString.length - 1, through: 0, by: -1) {
        let candidateRange = NSRange(location: i, length: 1)
        let candidateCharacter = textAsNSString.substring(with: candidateRange)
        if candidateCharacter == cursorPosition.numberAfterCursor {
            countFromEnd += 1
            if countFromEnd == cursorPosition.repetitionCountFromEnd {
                return candidateRange
            }
        }
    }

    return nil
}

最后依然是在 UITextField 的代理方法中监听并修改 text 属性。

open func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
    // This allows for the case when a user autocompletes a phone number:
    if range == NSRange(location: 0, length: 0) && string.isBlank {
        return true
    }

    guard let text = text else {
        return false
    }

    let textAsNSString = text as NSString
    let changedRange = textAsNSString.substring(with: range) as NSString
    let modifiedTextField = textAsNSString.replacingCharacters(in: range, with: string)

    let filteredCharacters = modifiedTextField.filter {
        String($0).rangeOfCharacter(from: (textField as! PhoneNumberTextField).nonNumericSet as CharacterSet) == nil
    }
    let rawNumberString = String(filteredCharacters)

    let formattedNationalNumber = self.partialFormatter.formatPartial(rawNumberString as String)
    var selectedTextRange: NSRange?

    let nonNumericRange = (changedRange.rangeOfCharacter(from: self.nonNumericSet as CharacterSet).location != NSNotFound)
    if range.length == 1, string.isEmpty, nonNumericRange {
        selectedTextRange = self.selectionRangeForNumberReplacement(textField: textField, formattedText: modifiedTextField)
        textField.text = modifiedTextField
    } else {
        selectedTextRange = self.selectionRangeForNumberReplacement(textField: textField, formattedText: formattedNationalNumber)
        textField.text = formattedNationalNumber
    }
    sendActions(for: .editingChanged)
    if let selectedTextRange = selectedTextRange, let selectionRangePosition = textField.position(from: beginningOfDocument, offset: selectedTextRange.location) {
        let selectionRange = textField.textRange(from: selectionRangePosition, to: selectionRangePosition)
        textField.selectedTextRange = selectionRange
    }

   // 处理其他逻辑。

    return false
}

这里区分了两种情况:

  1. 删除一个字符,
  2. 新增、替换等其他情况。

注意:在直接修改 UITextField 的 text 属性之后,需要调用 sendActions(for: .editingChanged),否则外界无法监听到文本的修改。

本人在阅读 PhoneNumberKit 源码的过程中,学习到了很多技巧,同时也发现一些可以优化的地方,并提交了 Pull Request,希望越来越多的同学可以参与到开源中。