最近项目中有个类似评论的需求,在textView中输入评论内容,且可以@到一个对象,对象是自己的业务数据,这里就简化为@到一个用户名,本地用一个数组存放几个string数据。要求:
- 被@的字符串使用富文本(标记不同的颜色)
- @后光标定位当前@的字符串之后,自动加一个空格用于区分
- 删除的时候@和被@的内容作为一个整体一起删除
标记出被@的内容
一个字符串被@之后,首先应该拿到被@的内容,并且生成一个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:effectiveRange:)方法会返回指定位置的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保持自己的职责单一对调用者提供服务即可。实现比较单间,只是提供个思路。