textView实现@功能

845 阅读5分钟

最近项目中有个类似评论的需求,在textView中输入评论内容,且可以@到一个对象,对象是自己的业务数据,这里就简化为@到一个用户名,本地用一个数组存放几个string数据。要求:

  1. 被@的字符串使用富文本(标记不同的颜色)
  2. @后光标定位当前@的字符串之后,自动加一个空格用于区分
  3. 删除的时候@和被@的内容作为一个整体一起删除

标记出被@的内容

一个字符串被@之后,首先应该拿到被@的内容,并且生成一个NSAttributedString对象,用于接下来把这个对象插入到textView的attributedText属性中

func createMentionAttributedString(mention: String) -> NSAttributedString {
    let attributes: [NSAttributedString.Key: Any] = [
        .backgroundColor: UIColor.yellow,
        .foregroundColor: UIColor.blue
    ]
    let mentionString = NSMutableAttributedString(string: "@\(mention)", attributes: attributes)
    
    // 添加自定义属性用于识别
    mentionString.addAttribute(NSAttributedString.Key(rawValue: "Mention"), value: mention, range: NSRange(location: 0, length: mentionString.length))
    
    // 添加一个空格
    let spaceString = NSAttributedString(string: " ", attributes: nil)
    mentionString.append(spaceString)
    
    return mentionString
}

这里主要是添加自定义属性是个关键点addAttribute(NSAttributedString.Key(rawValue: "Mention"),Mention最为所有被@内容的key,后面会用到,自己随便定义就好

将被@的内容插入到textView中

插入到textView的关键,是获取到插入的具体位置信息,直接使用textView.selectedRange属性,获取到当前光标的停留位置。官方文档的描述是The current selection range of the text view.,那我当前光标闪烁的位置,应该就是当前选择的range范围,至少确定了range的开始位置就足够了

func insertMention(attributedText: NSAttributedString, textView: UITextView) {
    let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
    let selectedRange = textView.selectedRange
    
    // 插入自定义的 AttributedString
    mutableAttributedText.replaceCharacters(in: selectedRange, with: attributedText)
    textView.attributedText = mutableAttributedText
    
    // 设置光标位置在空格之后
    let newCursorLocation = selectedRange.location + attributedText.length
    textView.selectedRange = NSRange(location: newCursorLocation, length: 0)
}

设置光标位置在空格之后,让用户在当前插入@内容的后面界面输入,其实也可以直接把光标定位到textView整体内容的最后,看自己的业务需求

删除@内容的整体

监听textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String)方法,在删除的逻辑里拿到要删除的@内容,整体删除

func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
    // 检查是否是删除操作
    if text.isEmpty {
        let rangeBeforeCursor = NSRange(location: range.location - 1, length: 1)
        if range.location > 0 {
            let attributes = textView.attributedText.attributes(at: rangeBeforeCursor.location, effectiveRange: nil)
            
            // 检查是否包含自定义的 Mention 属性
            if let mention = attributes[NSAttributedString.Key(rawValue: "Mention")] as? String {
                // 删除整个 @ 联系人字符串
                let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
                mutableAttributedText.deleteCharacters(in: NSRange(location: range.location - mention.count - 1, length: mention.count + 2))
                
                textView.attributedText = mutableAttributedText
                textView.selectedRange = NSRange(location: range.location - mention.count - 1, length: 0)
                return false
            }
        }
    }
    return true
}

因为之前@的时候,为了美观,我给它在@后加了个空格,所以删除的时候也要连带这个自己加的空格一并删除。在方法的range属性中去掉空格的范围,拿到的rangeBeforeCursor才是真正要删除的@内容。

attributes(at:​effective​Range:)方法会返回指定位置的attributes属性,其官方描述为:Returns the attributes for the character at the specified index.

然后通过attributes[NSAttributedString.Key(rawValue:),根据之前创建时使用的key,获取到要NSAttributedString的数组。拿到要删除的attributeText,接下来的事情就好办了,无非是获取到删除的range,设置textView的光标位置,就能实现整个删除@内容的效果了。

完整demo实例如下(删除注释):

import UIKit

class MentionTestController: BaseViewController, UITextViewDelegate {
    var textView: UITextView!
    var addUserBtn = UIButton(type: .custom)
    var submitBtn = UIButton(type: .custom)
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addUserBtn.setTitle("addUserBtn", for: .normal)
        addUserBtn.addTarget(self, action: #selector(addContactButtonTapped(_:)), for: .touchUpInside)
        addUserBtn.backgroundColor = .blue
        submitBtn.setTitle("submitBtn", for: .normal)
        submitBtn.addTarget(self, action: #selector(submitBtnTapped), for: .touchUpInside)
        submitBtn.backgroundColor = .darkGray
        textView = UITextView()
        textView.backgroundColor = .orange.withAlphaComponent(0.5)
        textView.delegate = self
        view.addSubview(textView)
        view.addSubview(addUserBtn)
        view.addSubview(submitBtn)
        
        textView.snp.makeConstraints { make in
            make.top.equalTo(self.view.safeAreaLayoutGuide.snp.top).offset(20)
            make.leading.trailing.equalTo(self.view).inset(20)
            make.height.equalTo(200)
        }
        addUserBtn.snp.makeConstraints { make in
            make.top.equalTo(textView.snp.bottom).offset(20)
            make.width.equalTo(100)
            make.height.equalTo(45)
            make.leading.equalTo(textView)
        }
        submitBtn.snp.makeConstraints { make in
            make.top.width.height.equalTo(addUserBtn)
            make.leading.equalTo(addUserBtn.snp.trailing).offset(20)
        }
    }
    func replaceMentionsInTextView(_ textView: UITextView) -> String {
        let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
        let fullRange = NSRange(location: 0, length: mutableAttributedText.length)
        mutableAttributedText.enumerateAttribute(NSAttributedString.Key(rawValue: "Mention"), in: fullRange, options: []) { (value, range, stop) in
            if let mention = value as? String {
                let replacementString = "##\(mention)##"
                mutableAttributedText.replaceCharacters(in: range, with: replacementString)
            }
        }
        return mutableAttributedText.string
    }
    func createMentionAttributedString(mention: String) -> NSAttributedString {
        let attributes: [NSAttributedString.Key: Any] = [
            .backgroundColor: UIColor.yellow,
            .foregroundColor: UIColor.blue
        ]
        let mentionString = NSMutableAttributedString(string: "@\(mention)", attributes: attributes)
        mentionString.addAttribute(NSAttributedString.Key(rawValue: "Mention"), value: mention, range: NSRange(location: 0, length: mentionString.length))
        let spaceString = NSAttributedString(string: " ", attributes: nil)
        mentionString.append(spaceString)
        return mentionString
    }
    
    func insertMention(attributedText: NSAttributedString, textView: UITextView) {
        let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
        let selectedRange = textView.selectedRange
        mutableAttributedText.replaceCharacters(in: selectedRange, with: attributedText)
        textView.attributedText = mutableAttributedText
        let newCursorLocation = selectedRange.location + attributedText.length
        textView.selectedRange = NSRange(location: newCursorLocation, length: 0)
    }
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if text.isEmpty {
            let rangeBeforeCursor = NSRange(location: range.location - 1, length: 1)
            if range.location > 0 {
                let attributes = textView.attributedText.attributes(at: rangeBeforeCursor.location, effectiveRange: nil)
                if let mention = attributes[NSAttributedString.Key(rawValue: "Mention")] as? String {
                    let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
                    mutableAttributedText.deleteCharacters(in: NSRange(location: range.location - mention.count - 1, length: mention.count + 2))
                    textView.attributedText = mutableAttributedText
                    textView.selectedRange = NSRange(location: range.location - mention.count - 1, length: 0)
                    return false
                }
            }
        }
        return true
    }
    @objc func addContactButtonTapped(_ sender: UIButton) {
        let mentions = ["JohnDoe", "Luseike", "Jerry", "Kafuka", "Swift"]
        var resName = ""
        if let randomName = mentions.randomElement() {
            resName = randomName
        } else {
            resName = mentions[0]
        }
        let mentionAttributedString = createMentionAttributedString(mention: resName)
        insertMention(attributedText: mentionAttributedString, textView: textView)
    }
    
    @objc func submitBtnTapped() {
        let replacedText = replaceMentionsInTextView(textView)
        print(replacedText)
    }
}

其他可能的操作

显示@和整体删除@其实只是前端的一种交互效果,真正传给API的数据大概率是需要对@的内容做替换,用前后端约定的一个格式将真正被@对象的id或其他信息传出去。后面通过API获取到的数据需要显示时,也要通过将被格式化的数据转换成@xx的形式回显。这就涉及到具体的业务细节,model的定义等等。简单实现下把@11的显示内容转化成 ##11## 的格式

func replaceMentionsInTextView(_ textView: UITextView) -> String {
    let mutableAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
    let fullRange = NSRange(location: 0, length: mutableAttributedText.length)
    
    mutableAttributedText.enumerateAttribute(NSAttributedString.Key(rawValue: "Mention"), in: fullRange, options: []) { (value, range, stop) in
        if let mention = value as? String {
            let replacementString = "##\(mention)##"
            mutableAttributedText.replaceCharacters(in: range, with: replacementString)
        }
    }
    
    return mutableAttributedText.string
}

另外考虑到不管是@的显示还是删除,其实都可以看做textView自身的功能扩展,所以可以直接定义一个继承textView的类,提供这些直接附加在textView身上的功能实现,textView保持自己的职责单一对调用者提供服务即可。实现比较单间,只是提供个思路。