做iOS开发这几年,凡是涉及“用户协议里点链接”“标签可以点击跳转”“长文本要展开收起”这类需求,最后基本都绕回同一个控件:UITextView。每次接到这种需求,很多人的第一反应是“UILabel设置attributedText不也能显示富文本吗”,等真正实现“点文字有反馈、折行不串行、高度算得准”的时候,才发现坑比想象中多。这篇文章就把UITextView在富文本、点击文字、文字折行这三个方向上的使用心得整理出来,配合能直接用的代码和排查思路,适合准备在项目里做富文本展示、动态文本交互的iOS开发者参考。
1. 为什么是UITextView:UILabel、WKWebView与它的选型纠葛
1.1 UITextView和UILabel,差的不只是“能不能编辑”
UILabel确实能显示NSAttributedString,红色文字、加粗、不同字号、下划线都支持,但它的交互能力几乎为零。UILabel不会因为设置了.link属性就自动识别链接,你就算给它手动加一个UITapGestureRecognizer,也只能拿到一个笼统的点击坐标,还得自己用CoreText或者TextKit去算“点到的是哪个字”,工作量瞬间上来了。
UITextView则不同,它是TextKit框架在UIKit层面的完整封装,内部包含NSTextStorage、NSLayoutManager、NSTextContainer三层结构,天生就带一套文本布局和交互体系。链接点击、长按选择、文本复制、光标移动(编辑模式下)这些能力都是系统内置的,开发者只需要配置好属性、实现对应代理方法就行。在实际项目里,如果只是单纯展示一段带颜色和字号的文案,UILabel完全够用,但一旦出现“某个词能点”“某个词能弹菜单”这种需求,直接用UITextView才是正确起点。
还要注意一个容易踩的坑:很多人把UITextView设成isEditable = false之后,发现链接点不了,然后怀疑是不是系统bug。其实是因为同时需要确保isSelectable = true,链接交互才能正常触发。isEditable控制的是键盘输入和文本修改,isSelectable控制的是文本选择、链接交互、长按菜单这些能力。想做成“纯展示但可交互”的富文本视图,正确组合就是isEditable = false加isSelectable = true。
1.2 为什么不直接上WKWebView
有些同学觉得WKWebView加载HTML也能显示富文本,链接点击天然可用,为什么不选它?我早期也这么干过,但后来发现几个很难忍受的问题。首先是性能和体验割裂:WKWebView是一个独立的浏览器内核渲染进程,页面滚动、手势响应和原生控件有明显差异,尤其和列表嵌套时,那种“不同世界”的错位感很影响体验。其次是字体适配问题:原生App做动态字体适配(辅助功能超大字号)时,WebView里的HTML字体需要额外桥接和同步,做起来很麻烦。最后是事件处理链路长:WebView内部点击、JS回调、原生交互层层传递,调试成本高。
UITextView走的是TextKit渲染管线,底层是CoreText,渲染效率和原生页面是一致的。而且UITextView直接继承自UIScrollView,天然拥有滚动能力,也能放进UITableViewCell或者UICollectionViewCell里参与复用,尺寸计算、手势冲突都在UIKit体系内解决,可控性强得多。
补充一句TextKit的三层结构,去理解它以后排查问题思路会清晰很多:
| 组件 | 职责 | 类比 |
|---|---|---|
| NSTextStorage | 持有全部文本内容和属性,相当于数据源 | 一本带批注的书 |
| NSLayoutManager | 负责将文本按容器宽度折行、生成字形,相当于排版工人 | 排版师傅 |
| NSTextContainer | 定义文本可绘制的区域和规则,如宽度、行数限制 | 纸张边界 |
UITextView把这三者组合好并提供编辑和交互能力。所以如果你有非常冷门的排版需求(比如文字绕排、不规则区域折行),甚至可以直接绕过UITextView,单独操作这三个组件,灵活性远超WebView方案。
2. 富文本地基:NSAttributedString的常用属性与折行的真实规则
2.1 构建富文本最常用的几个属性
不管是点击文字还是折行计算,底层都离不开NSAttributedString。构建富文本时最常用的是这些属性:
let attributedString = NSMutableAttributedString(string: "我已阅读并同意《用户协议》和《隐私政策》") let fullRange = NSRange(location: 0, length: attributedString.length) attributedString.addAttribute(.font, value: UIFont.systemFont(ofSize: 14), range: fullRange) attributedString.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: fullRange) let agreementRange = (attributedString.string as NSString).range(of: "《用户协议》") attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: agreementRange) attributedString.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: agreementRange) attributedString.addAttribute(.link, value: URL(string: "app://agreement")!, range: agreementRange)这里有几个细节值得注意。.font、.foregroundColor、.backgroundColor属于字符级属性,作用于被标记的字符;.link的值可以传URLString也可以传URL对象,系统识别为该区域为可点击链接;如果要对某段文字加行距或段落间距,必须用NSParagraphStyle。
let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = 4 paragraphStyle.paragraphSpacing = 8 paragraphStyle.alignment = .left attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange)不要把.paragraphStyle挂在单个字符上,它应该作用于整个段落,否则不同段落之间可能出现奇怪的间距错乱。这一点我在早期遇到过,某段文案的行距时有时无,最后发现是范围只选到了前半段。
2.2 lineBreakMode在UITextView里的特殊性:和UILabel完全不同
UILabel里控制文字超出后截断或省略,用lineBreakMode就能搞定,NSLineBreakMode.byTruncatingTail是最常见的“末尾省略”。但在UITextView里,这套思路要打个问号。
UITextView的默认折行规则是word wrap,也就是按单词边界折行,这种折行一定会把超过容器宽度的完整单词挪到下一行,保证单词不被切断。真正的“截断”行为由textContainer控制,具体来说是两个属性:
textView.textContainer.maximumNumberOfLines = 3 textView.textContainer.lineBreakMode = .byTruncatingTail注意,textView.textContainer是UITextView在初始化时自动创建的NSTextContainer实例,直接改它的属性就可以了,不需要自己重建。maximumNumberOfLines设置为3表示最多显示3行,超过部分不展示;lineBreakMode设置为byTruncatingTail表示第3行末尾显示省略号。
但是这里有一个非常隐蔽的坑:如果先设置了maximumNumberOfLines,再设置lineBreakMode,在某些iOS版本上截断表现是正常的;反过来先设置lineBreakMode再设置maximumNumberOfLines,就有可能不生效。我自己的习惯是固定写成:
textView.textContainer.maximumNumberOfLines = 3 textView.textContainer.lineBreakMode = .byTruncatingTail另外,textContainer的lineBreakMode只在maximumNumberOfLines大于0时起作用。如果maximumNumberOfLines = 0,表示不限制行数,那lineBreakMode设置了也没意义,这也是新手经常配置了却看不到省略号的原因之一。
还有一个细节:设置maximumNumberOfLines和lineBreakMode后,务必确认textView.textContainer.lineFragmentPadding是否符合预期。默认值是5,相当于右侧会额外留出5pt空白,导致文本区域比视觉上窄一点。在计算折行宽度时,如果忘了扣掉这个padding,结果会有几像素的偏差。
2.3 动态字体和辅助功能适配
富文本最容易忘掉的是系统动态字体适配。如果你写死了UIFont.systemFont(ofSize: 14),用户开了超大字号后,文本不会跟着变大,就违反了iOS辅助功能的基本预期。正确做法是:
let font = UIFont.preferredFont(forTextStyle: .body) let metrics = UIFontMetrics(forTextStyle: .body) let scaledFont = metrics.scaledFont(for: UIFont.systemFont(ofSize: 14)) textView.font = scaledFont textView.adjustsFontForContentSizeCategory = trueadjustsFontForContentSizeCategory是UITextView自带的属性,设置为true后,它的font会跟随系统字体大小变化。但如果你用的是NSAttributedString逐段设置.font,那adjustsFontForContentSizeCategory不会自动帮你缩放属性字符串里的字体,需要自己监听UIContentSizeCategoryDidChangeNotification重新构建富文本,或者给动态字体数据源做监听。这一点在“用户协议、隐私政策”这类页面最容易出问题,因为这类文案大多是富文本拼接出来的。
3. 点击文字:链接跳转、命中检测与手势冲突的完整排查
3.1 最省事也是推荐优先用的NSLink方案
上一节给出了一段带.link属性的富文本,只要再实现代理方法,就能响应链接点击。UITextView代理里和链接点击相关的方法有两代,第一代是iOS 10之前就开始用的:
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { if URL.scheme == "app" { // 处理自定义scheme return false } return true }第二代是iOS 10之后新增的,多了一个interaction参数,可以区分是轻点、长按预览还是辅助操作:
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { if URL.scheme == "app" { switch interaction { case .tap: // 普通点击,做页面跳转 handleInternalURL(URL) case .preview: // 长按预览,可以忽略或做自定义处理 break case .actions: // 弹出操作菜单 break @unknown default: break } return false } return true }实际使用中,我更建议用带UITextItemInteraction的新版代理,这样能精细化区分用户行为。需要注意的是:return false代表“链接交互由你接管,系统不再打开URL”;return true代表“按系统默认方式处理,比如http链接用Safari打开”。如果你接的是自定义scheme,必须return false,否则系统会尝试用openURL打开一个你未必注册过的scheme,轻则弹提示,重则Crash。
还有一种情况:不想实现代理方法,而是想在点击时做一次性的业务埋点。可以保持return true,但在代理方法里先埋点再返回,这样系统行为不打断,埋点也做完了。很多项目上线后的“用户协议点击率”就是这个链路里统计出来的。
3.2 点击“非链接”文字:命中具体字符的检测方法
链接能点之后,下一个需求往往是“点击普通文字也要响应”,比如点一个用户昵称要跳转个人主页,点一个话题标签要进入话题页。这种场景不适合用.link属性,因为你想自定义的“目标”不是一个URL,而是一个业务ID。
我的做法是给这部分文字添加一个自定义属性,比如:
let mentionKey = NSAttributedString.Key("MentionUserID") let nickRange = (text as NSString).range(of: nickName) attributedString.addAttribute(mentionKey, value: userID, range: nickRange) attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: nickRange)然后在自定义的UITapGestureRecognizer里,把点击坐标换算成字符索引,再检查该索引位置有没有对应的自定义属性:
func tappedCharacterIndex(in textView: UITextView, at point: CGPoint) -> Int? { let pointInTextView = CGPoint( x: point.x - textView.textContainerInset.left, y: point.y - textView.textContainerInset.top ) var fraction: CGFloat = 0 let index = textView.layoutManager.characterIndex( for: pointInTextView, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: &fraction ) return index } func handleTap(at point: CGPoint, in textView: UITextView) { guard let index = tappedCharacterIndex(in: textView, at: point), index < textView.attributedText.length else { return } var range = NSRange(location: 0, length: 0) if let userID = textView.attributedText.attribute(mentionKey, at: index, effectiveRange: &range) as? String { // 跳转用户主页 openUserProfile(userID: userID) } }这段逻辑里最关键的偏移量是textContainerInset和lineFragmentPadding,漏了任何一个,点击坐标换算都会偏。characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:)返回的是字符索引,如果用这个索引去获取attribute,careful一点的是需要判断index是否越界,因为点击到文本末尾空白区域时,返回的index可能等于文本长度,直接去取attribute会越界。
这种方法也能用来判断“点击到了哪张图片附件”。回顾第2节提到TextKit三层结构,图片本质上是NSTextAttachment字符,通过布局管理器可以获取附件字符的boundingRect:
let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil) let rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)拿到rect之后,就能判断点击坐标是否落在某张图片的显示区域内,从而响应“点击图片查看大图”之类的交互。
3.3 手势冲突排查实录:自定义手势不触发的完整链路
我在做一个社区类App时遇到过一个困扰很久的问题:UITextView里添加了自己的UITapGestureRecognizer,明明代码逻辑都对,但怎么点都不触发。查了半天才发现,问题出在UITextView内部手势和自定义手势的竞争关系上。
UITextView内部本身带有好几个手势识别器:点击手势(触发链接跳转和光标定位)、长按手势(触发文本选择和菜单)、双击手势等。系统默认情况下,这些内部手势会优先于外部添加的手势识别器。也就是说,你的UITapGestureRecognizer拿不到手势识别结果,因为内部的点击手势声称“我处理了这次触摸”。
排查链路是这样的:
- 先确认自定义手势确实添加到了UITextView上(而不是它的父视图或contentView)。
- 检查是否设置了
gesture.cancelsTouchesInView = false,如果为true,手势识别成功后会取消UITextView内部的触摸处理,可能破坏文本选择等系统行为。 - 实现
UIGestureRecognizerDelegate,让自定义手势和系统手势可以共存:
extension YourClass: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool { if otherGestureRecognizer is UITapGestureRecognizer { return false } return false } }实际项目中,我通常不在UITextView上自建点击手势去处理链接,因为链接已经走代理了;我只用自定义手势处理“非链接区域的点击”,这时更合适的做法是判断点击位置是否落在链接范围之外。检测方法是在第3.2节的handleTap里,先检查该索引处有没有.link属性或有NSLink关联的URL,有就直接放行不处理,没有才执行自己的逻辑。这样最干净,也不会和系统抢交互。
还有一类常见问题:在UITableViewCell里嵌入UITextView,cell的选中手势和textView的手势互相拦截,导致“点一下cell没反应,但点文字有反应”。我们把tableView.allowsSelection = true的情况下,textView会拦截触摸事件,导致cell无法选中。此时可以让cell的selectionStyle保持正常,然后在tableView(_:didSelectRowAt:)里做对应跳转,但需要把UITextView的isUserInteractionEnabled在需要cell点击的场景下设为false。一句话概括:手势排查没有银弹,先缩小范围,再看谁拦截了谁。
4. 文字折行的计算:高度、行数与动态布局的正确姿势
4.1 高度计算的标准姿势:sizeThatFits优先级最高
用UITextView做动态高度Cell时,第一个要解决的问题是“给定宽度,文本到底有多高”。最稳妥、简洁的方案是sizeThatFits:
let maxWidth = textView.bounds.width let fittingSize = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)) let neededHeight = fittingSize.height注意这里传的宽度应该是UITextView自身的宽度。如果要在Cell内部使用,宽度要扣除左右间距、屏幕安全区、以及tableView的separatorInset等,最终文本视图的实际宽度是多少就传多少。
很多人会问,为什么不用boundingRect(with:options:)直接算NSAttributedString的高度?当然可以,但要注意两点:
- UILabel的
textRect(forBounds:limitedToNumberOfLines:)只适用于单行文本,多行富文本计算不准。 boundingRect(with:options:)计算出来的高度,并不总是等于UITextView实际渲染高度。因为UITextView内部还有textContainerInset(上下左右内边距)和lineFragmentPadding,直接把 boundingRect 高度当成UITextView高度,会导致上下被裁切或者多出空白。
我自己实测下来,sizeThatFits表现最稳定,因为它内部已经考虑了textContainer的内边距设置。如果要使用boundingRect,那么UITextView的高度可以近似为:
let textHeight = attributedString.boundingRect( with: CGSize(width: maxTextWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil ).height let textViewHeight = textHeight + textView.textContainerInset.top + textView.textContainerInset.bottom注意.usesFontLeading这个option,它计算时会把字体的leading(行距)系数纳入,忽略它可能导致行高偏小。这点很多人会漏,结果就是“最后一行的字被裁掉一半”。
4.2 行数与“展开全文”:maximumNumberOfLines的动态变化
“展开全文”按钮本质上就是对textContainer.maximumNumberOfLines的切换。收起时设置3行,展开时设置0行(不限制):
func toggleExpand(_ expand: Bool) { textView.textContainer.maximumNumberOfLines = expand ? 0 : 3 textView.textContainer.lineBreakMode = expand ? .byWordWrapping : .byTruncatingTail }这里有一个需要提前判断的边界:如果文本实际只有2行,那么要不要显示“展开”按钮?判断方式是用第4.1节的高度计算,如果算出来的完整高度小于等于3行高度,就不需要按钮。计算行数的标准姿势是遍历布局管理器生成的line fragment:
func lineCount(of textView: UITextView) -> Int { var count = 0 let layoutManager = textView.layoutManager let glyphRange = layoutManager.glyphRange(for: textView.textContainer) layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { _, _, _, _, _ in count += 1 } return count }注意enumerateLineFragments的闭包里返回的是CGRect等参数,这里用_接收即可。这个方法的准确度高于“总高度除以行高”,因为最后一行可能不满整行,高度不一定等于常规行高。
关于“展开全文”还有一个小细节:切换maximumNumberOfLines后,textView的高度约束需要联动更新,否则视觉上要么被截断,要么多出空白。我的做法是给textView设一个高度约束,每次点击展开按钮时重新调用sizeThatFits计算高度,然后UIView.animate更新约束常量:
let newHeight = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)).height heightConstraint.constant = newHeight UIView.animate(withDuration: 0.25) { self.contentView.layoutIfNeeded() }在多行折叠场景下,cell的高度变化天然伴随contentSize的抖动。如果你发现展开动画高度变化但textView的contentSize没同步刷新,可以先调一下textView.layoutIfNeeded()让内部布局落定,再做高度约束动画,顺序反了会闪一下。
4.3 性能取舍:别在主线程反复量文本
富文本高度计算的性能瓶颈集中在字符串测量上。一个列表页如果同时有几十个Cell,每个Cell都要sizeThatFits一次,数据源每次reload都会重复测量,卡顿会很明显。
我的优化策略有三条:
- 缓存计算结果。以“文本内容+字体设置+可用宽度”为key,高度为value,放进内存字典。注意key必须包含字号、字体、行距这些影响高度的属性,否则换字号后取了旧缓存高度会错。
- 尽量不在
tableView(_:heightForRowAt:)里做重量级的富文本重建。如果你已经在数据模型里准备好了NSAttributedString,就直接测量;如果每次进代理才现拼字符串、现设属性,性能一定差。数据源层搭建时就一次性组装好富文本,这里省下来的时间体感很明显。 - 文本内容特别长(超过几千字)时,考虑把测量放到后台队列,或者只对当前可见Cell做精确测量,用估算高度占位,等滚动到附近再填充精确高度。UITableView的
estimatedRowHeight+systemLayoutSizeFitting方案在很多项目里能显著降低首屏耗时。
还有一点:NSTextStorage构建也是有成本的,如果同一段富文本要反复用于展示和高度的测量,尽量持有同一个NSMutableAttributedString实例,不要每次测量都new一遍。
5. 实战进阶:图片混排、@提及、折叠展开的落地指南
5.1 NSTextAttachment实现图片混排
富文本里插图片,用的是NSTextAttachment。一个比较典型的场景是“在昵称后面显示一个小认证图标”,做法是把图标作为附件字符插入富文本:
let attachment = NSTextAttachment() attachment.image = UIImage(named: "verified_icon") attachment.bounds = CGRect(x: 0, y: -3, width: 16, height: 16) let attachmentString = NSAttributedString(attachment: attachment) let mutableString = NSMutableAttributedString(string: nickName) mutableString.append(attachmentString)bounds的y值需要注意:为负值时让图片相对于文字基线稍微下沉,视觉上对齐文字中部。这个值没有固定标准,不同字体大小下会有偏差,建议实测调整。如果不设置bounds,默认图片尺寸会基于原图大小,经常出现“一个比字号还大的图嵌在文字里”的翻车现场。
图片附件如果要支持点击,就用第3.2节讲到的boundingRect(forGlyphRange:in:)找到图片的显示rect,再判断点击坐标是否落在其中。我的实现里会先把所有附件的rect收集起来,点击时遍历判断,效率完全够。
5.2 @提及和话题标签的完整交互方案
社交类App最容易出现“@某某”和“#话题#”的点击需求。这个场景我的推荐组合是:自定义属性 + 点击检测,不使用.link。
为什么不用.link?因为默认的链接点击代理只能拿到URL,而你还需要拿到“@了哪个用户ID”或“话题ID”,这些业务数据不适合编码进URL,分散维护起来也麻烦。自定义属性可以把任意对象挂在range上,检测时直接读取,逻辑更集中。
构建时:
let bodyText = "今天和 @小明 一起聊了 #iOS开发#" let attributedString = NSMutableAttributedString(string: bodyText) let mentionRange = (bodyText as NSString).range(of: "@小明") attributedString.addAttribute(mentionKey, value: "user_id_12345", range: mentionRange) attributedString.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: mentionRange)注意@后面的人名不能提前写死在富文本里,而应该根据数据源动态填充。如果用户昵称含有空格、表情,range(of:)直接按字符串匹配可能会出错,更稳妥的方式是用正则表达式匹配“@[\u4e00-\u9fa5A-Za-z0-9_]+”这类模式,或者干脆从数据模型端就明确每个@片段的range,不靠字符串二次查找。
检测时(见3.2节),先查自定义属性,如果该位置没有自定义属性,再查.link属性,最后fallback给系统默认行为。这样既支持业务属性,也不破坏系统链接。
5.3 折叠展开按钮文案的处理
“展开全文”通常不是普通文字,而是一个可点击的按钮样式。我习惯用附加属性把“展开”两个字标成一个可点击的range,点击后切换展开状态并更新文案为“收起”。
一个经常被忽略的问题是:折叠状态下,点击“展开”操作的是“第3行末尾的省略号后面那段文字”,它的字符索引在原始完整文本中可能位于第100个字符附近,但因为折叠时maximumNumberOfLines = 3,第100个字符在布局上是不可见的。这时通过字符索引判断点击位置会出现矛盾——你在视觉上点到了“展开”,但索引对应的却是第80个字符的位置。
解决办法是使用独立的字符串+独立富文本构建“展开”按钮,不要把“展开”两个字嵌入原始文本的末尾再一起截断。换句话说,把原始文本设置为“正文…展开”,但“展开”两个字单独用一个小范围属性标记,让它的字符位置在折叠时仍然可见。实现上我用的技巧是:创建一段只含有原文+“展开”的富文本,然后对“展开”的range单独加属性,再整体设置给textView。因为maximumNumberOfLines只会影响整体布局的可见行数,并不会真正删除字符,“展开”所在字符本身仍然可以参与点击命中。
如果用纯文本方案实在绕不开,还有一个替代方案:用textView.textContainer.exclusionPaths动态挖掉右下角一块区域,把“展开”按钮做成真正的UIButton盖在右上角。这个方案交互上更接近原生控件,滚动联动也容易处理,但布局计算更繁琐,属于进阶玩法,适合在需要完全自定义的页面上使用。
TextKit的体系确实庞大,但把UITextView的富文本构建、点击命中、折行高度这三块串起来之后,很多需求就变得有章可循了。我自己的体会是,不要在出问题的最外层打补丁,而是回到NSAttributedString和textContainer这一层去找原因,大部分疑难杂症都能在结构上找到答案。
最后分享一个小技巧:调试UITextView布局问题时,可以临时把textView.textContainerInset改大或者加一层背景色,肉眼判断文本范围和预期是否一致,定位到究竟是内边距、lineFragmentPadding还是宽度约束导致的偏差。这套方法在我的项目里帮了不少忙,比反复打日志都快。