在社交类 APP 中 @、# 符号构成的标记文本已经形成了某种通用的意义:前者表示通知某位好友,而后者表示为某个话题或者分类。这些标记文本一般还都带有高亮显示和可点击的特点。接下来的我会创建一个 UITextView 的子类 AttrTextView 来实现上诉功能。
开始
import UIKit enum wordType{ case hashtag // #标示文本类型 case mention // @标示文本类型 } //自定义视图用于高亮 # 和 @ 之后的文本(效果类似于微博、twitter),并添加点击事件 class AttrTextView: UITextView { var textString: NSString? var attrString: NSMutableAttributedString? var callBack: ((String,wordType) -> Void)? ... }
上码的代码首先声明了一个 wordType 的枚举类型,该类用用于对标示文本进行类型标记。接着我们定义了自定义类型 AttrTextView,并且声明了三个属性。textString 表示原文本,attrString 进行属性设置后的文本,callBack 为 # 和 @ 标记文本的点击事件回调。
文本设置
定义好属性后,我们就需要考虑使用接口的实现了。正常情况下文本应该有以下属性需要设置:常规文本的字体、颜色;# 和 @ 标记文本各自对应的字体和颜色;点击事件设置以及回调函数。代码如下:
public func setText(text: String,normalColor: UIColor,hashtagColor: UIColor,mentionColor: UIColor,normalFont: UIFont,hashTagFont: UIFont,mentionFont: UIFont,tapCallBack callBack: @escaping (String,wordType) -> Void) { self.callBack = callBack self.attrString = NSMutableAttributedString(string: text) self.textString = NSString(string: text) // Set initial font attributes for our string // 设置字体和文本颜色 attrString?.addAttribute(NSFontAttributeName,value: normalFont,range: NSRange(location: 0,length: (textString?.length)!)) attrString?.addAttribute(NSForegroundColorAttributeName,value: normalColor,length: (textString?.length)!)) // Call a custom set Hashtag and Mention Attributes Function // 设置 #、@ 的高亮色等属性 setAttrWithName(attrName: "Hashtag",wordPrefix: "#",color: hashtagColor,text: text,font: hashTagFont) setAttrWithName(attrName: "Mention",wordPrefix: "@",color: mentionColor,font: mentionFont) // Add tap gesture that calls a function tapRecognized when tapped // 添加手势 let tapper = UITapGestureRecognizer(target: self,action: #selector(self.tapRecognized(tapGesture:))) addGestureRecognizer(tapper) }
上面代码中的 setAttrWithName 函数的目的是对 #、@ 标记文本的属性进行设置,代码如下:
private func setAttrWithName(attrName: String,wordPrefix: String,color: UIColor,text: String,font: UIFont) { // Words can be separated by either a space or a line break // 将文本按照空格和 \n 键拆分为单词数组 var words: [String] = [] let wordtext: [String] = text.components(separatedBy: " ") for var word in wordtext { if word.hasPrefix("\n") { word = word.replacingOccurrences(of: "\n",with: "") } words.append(word) } // 便利数组,检查是否满足条件并进行属性设置 for word in words.filter({$0.hasPrefix(wordPrefix)}) { let range = textString!.range(of: word) attrString?.addAttribute(NSForegroundColorAttributeName,value: color,range: range) attrString?.addAttribute(attrName,value: 1,range: range) attrString?.addAttribute("Clickable",range: range) attrString?.addAttribute(NSFontAttributeName,value: font,range: range) } self.attributedText = attrString }
点击事件的处理
文本点击的处理稍微有点麻烦,需要考虑多种情况:
没有点击在任何文本上
点击在普通文本
点击在标示文本,并且需要识别标示文本的类型
func tapRecognized(tapGesture: UITapGestureRecognizer) { var wordString: String? // The String value of the word to pass into callback function var char: NSAttributedString! //The character the user clicks on. It is non optional because if the user clicks on nothing,char will be a space or " " var word: NSAttributedString? //The word the user clicks on var isHashtag: AnyObject? var isAtMention: AnyObject? // Gets the range of the character at the place the user taps // 检查用户点击字符的范围 let point = tapGesture.location(in: self) let charPosition = closestPosition(to: point) guard let charRange = tokenizer.rangeEnclosingPosition(charPosition!,with: .character,inDirection: 1) else { return } let location = offset(from: beginningOfDocument,to: charRange.start) let length = offset(from: charRange.start,to: charRange.end) let attrRange = NSMakeRange(location,length) char = attributedText.attributedSubstring(from: attrRange) // If the user has not clicked on anything,exit the function if char.string == " "{ print("User clicked on nothing") return } // Checks the character's attribute,if any // 检查属性标示 isHashtag = char?.attribute("Hashtag",at: 0,longestEffectiveRange: nil,in: NSMakeRange(0,char!.length)) as AnyObject? isAtMention = char?.attribute("Mention",char!.length)) as AnyObject? // Gets the range of the word at the place user taps // 获得点击单词的范围 let wordRange = tokenizer.rangeEnclosingPosition(charPosition!,with: .word,inDirection: 1) /* 单词的范围在下面两种情况下为 nil: 1. 点击在 "#" or "@" 标示上 2. 没有点击在任何字符上。但是这种情况在上面的代码中已经排除了,所有只剩下 1 */ if wordRange != nil { let wordLocation = offset(from: beginningOfDocument,to: wordRange!.start) let wordLength = offset(from: wordRange!.start,to: wordRange!.end) let wordAttrRange = NSMakeRange(wordLocation,wordLength) word = attributedText.attributedSubstring(from: wordAttrRange) wordString = word!.string } else { /* 右移12像素后再获取单词 */ var modifiedPoint = point modifiedPoint.x += 12 let modifiedPosition = closestPosition(to: modifiedPoint) let modifedWordRange = tokenizer.rangeEnclosingPosition(modifiedPosition!,inDirection: 1) if modifedWordRange != nil { let wordLocation = offset(from: beginningOfDocument,to: modifedWordRange!.start) let wordLength = offset(from: modifedWordRange!.start,to: modifedWordRange!.end) let wordAttrRange = NSMakeRange(wordLocation,wordLength) word = attributedText.attributedSubstring(from: wordAttrRange) wordString = word!.string } } if let stringToPass = wordString { // 点击回掉函数 if isHashtag != nil && callBack != nil { callBack!(stringToPass,wordType.hashtag) } else if isAtMention != nil && callBack != nil { callBack!(stringToPass,wordType.mention) } } }
上面的代码处理中,首先使用 .character 检查点击位置的字符,并对无效区域的点击进行了处理。这里之所以使用 .character 而不是后面的 .word 的原因是:后者会将 @、# 这些标示符丢弃,导致一只类似点击到无效区域的情形。当上诉检查通过也就是点击区域有效的时候,我们使用 .word,获取点击区域的单词。为了应对前面标示点击的情形,当区域无效的时候,我们右移12个像素后再获取单词。最后我们根据文本不同类型进行对应处理。
最后
最后我们看一下简单使用示例代码:
let attrView = AttrTextView.init(frame: CGRect.init(x: 0,y: 64,width: view.bounds.size.width,height: view.bounds.size.height - 64),textContainer: nil) self.view.addSubview(attrView) attrView.setText(text: "#PHP 是不是世界上最好的语言? @all ",normalColor: .black,hashtagColor: .red,mentionColor: .blue,normalFont: UIFont.systemFont(ofSize: 10),hashTagFont: UIFont.systemFont(ofSize: 14),mentionFont: UIFont.systemFont(ofSize: 14)) { word,wordType in print(word) }