最近需求中有一个常见场景:限制用户输入框的文字长度。乍一看很简单其实坑不少,网上的解决方案都不够严谨,记录一下。

一看这个需求立马想到监听文字变化通知UITextView.textDidChangeNotification

NotificationCenter.default.addObserver(self, selector: #selector(onTextDidChange(sender:)), name: UITextView.textDidChangeNotification, object: textView)
var textView: UITextView!
/// 最大字数
var maxTextSize: Int = 20
@objc func onTextDidChange(sender: Notification) {
   if(textView.text.count > maxTextSize) 
   {
      textView.text= String(textView.text[..<String.Index(encodedOffset: maxTextSize)])
   }
}

然而当使用系统自带键盘输入拼音时,会出现严重问题:你还没有选中要选的文字时,已经被键盘当做字母输入到 textView 当中了,比如用系统键盘输入“你好”,它会把n i h a o显示在 textView 中, 不但没有输入汉字,还每个字母占两个字符长度。

处理方法是判断系统键盘输入拼音处于高亮状态时不截取字符
另外注意一点,如果逻辑是先将文字铺上去,再在onTextDidChange去截取文字的话,被截掉的永远是末尾的文字。而我们希望的是一旦达到字数上限,用户从任意位置开始都无法继续输入文字了。
UITextView 有一个代理方法
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text;
这个方法可以拦截到你即将向 textView 输入的起始位置、个数和具体文字。显然在这个方法进行输入限制更合适。

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    // range: The range of characters to be replaced.(location、count)
    // 高亮控制
    let selectedRange = textView.markedTextRange
    if let selectedRange = selectedRange {
        let position =  textView.position(from: (selectedRange.start), offset: 0)
        if position != nil {
            let startOffset = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
            let endOffset = textView.offset(from: textView.beginningOfDocument, to: selectedRange.end)
            let offsetRange = NSMakeRange(startOffset, endOffset - startOffset) // 高亮部分起始位置
            if offsetRange.location < maxTextSize {
                // 高亮部分先不进行字数统计
                return true
            } else {
                Toast.showFail(message: "字数已达上限")
                return false
            }
        }
    }

    // 在最末添加
    if range.location >= maxTextSize {
        Toast.showFail(message: "字数已达上限")
        return false
    }

    // 在其他位置添加
    if textView.text.count >= maxTextSize {
        Toast.showFail(message: "字数已达上限")
        return false
    }

    return true
}

出现一个新问题,你会发现当字数达到上限时,用户无法删除文字或将选中文字进行 cut 或替代了。因此需要多加一个判断:

    ...
    // 在其他位置添加
    if textView.text.count >= maxTextSize && range.length <  text.count {
        Toast.showFail(message: "字数已达上限")
        return false
    }

    return true
}

这样已经实现了“阻止用户继续输入”的目的。但仅有shouldChangeTextIn:仍不够,当字数还未达上限,输入联想词复制粘贴文字导致超出后,就需要进行截取了。如图:

@objc func onTextDidChange(sender: Notification) {
    if textView.text.count > maxTextSize {
        let selectRange = textView.markedTextRange
        if let selectRange = selectRange {
            let position =  textView.position(from: (selectRange.start), offset: 0)
            if (position != nil) {
                // 高亮部分不进行截取,否则中文输入会把高亮区域的拼音强制截取为字母,等高亮取消后再计算字符总数并截取
                return
            }
        }

        textView.text = String(textView.text[..<String.Index(encodedOffset: maxTextSize)])

        // 对于粘贴文字的case,粘贴结束后若超出字数限制,则让光标移动到末尾处
        textView.selectedRange = NSRange(location: textView.text.count, length: 0)
    }
}

这样便把中文拼音高亮、联想词、复制粘贴等场景都考虑到了。