1. UITextView 基础认知与选型思路:什么时候用、怎么初始化最省事
1.1 三个文本控件的分工:什么时候必须用 UITextView
很多初学者对 UILabel、UITextField、UITextView 三个控件的边界比较模糊。简单说:UILabel 是纯展示、不可编辑;UITextField 是单行输入;UITextView 是多行输入、可滚动、可富文本展示。需求里一旦涉及“展示大量富文本内容”、“文字需要可点击跳转”、“支持多行编辑或展示”,UITextView 几乎是不二之选。
举个最常见的场景:App 里的隐私政策页面、用户协议、帖子详情、聊天记录气泡、评论正文,这些都是 UITextView 的典型使用场景。我见过不少项目在这类页面上硬用 UILabel 加一堆自定义手势去做文字点击,结果手势冲突、命中区域难算,最后兜兜转转还是换成了 UITextView。也不是说 UILabel 完全不能做,而是当需求同时包含“富文本 + 点击 + 多行”这三个要素里的两个以上时,直接上 UITextView 能少走很多弯路。
当然,也不是所有多行文本都适合 UITextView。如果只是固定几个字的提示文案,UILabel 更轻量,性能和内存都更好。关键判断标准其实是:是否需要交互、是否需要动态样式、是否需要编辑。至少满足一项,就可以考虑 UITextView。
1.2 初始化一个 UITextView 时最容易漏掉的东西
UITextView 继承自 UIScrollView,默认自带滚动、内容缩进、链接检测等能力,但它默认不会关闭编辑状态。如果你只是想用 UITextView 展示富文本,第一件事就是设置editable = false。很多人把这个控件直接放进 tableView 里展示帖子正文,结果用户一长按就弹出编辑光标,体验非常糟糕。
另一个容易漏的是selectable。UITextView 在editable = false后,如果还想让用户能选择文字、复制文字,必须把isSelectable保持为 true。很多开发者为了禁止编辑直接把editable和selectable一起关了,结果文字链接也不能点击,复制能力也没了。用户想复制一段验证码都做不到,这种“过度关闭”在实际项目里经常发生。
我在实际项目里用的最多的一个只读配置是这样:
textView.isEditable = false textView.isSelectable = true textView.isScrollEnabled = true textView.dataDetectorTypes = [.link, .phoneNumber]这里dataDetectorTypes很关键。设置之后,文本里的网址和电话号码会被自动识别成可点击区域,不需要额外做富文本处理。当然它也有副作用,识别出来的电话号码和网址是系统样式,想完全自定义样式还得靠富文本的link属性。后面我会专门讲这部分。
基础属性中,textContainerInset也值得单独说。它控制的是文本内容距 UITextView 边框的内边距,默认值大概是上下 8 左右 0。做高度自适应计算时,这个值经常被忽略,导致算出来的高度和实际渲染高度对不上。到后面折行和高度计算那部分,你还会再见到它。
2. 富文本实战:用 NSAttributedString 控制文字样式与排版
2.1 从属性字典开始理解 NSAttributedString
NSAttributedString 本质是“字符串 + 一段段样式”。每个字符位置都可以对应一组属性。常见属性包括字体(NSFontAttributeName)、前景色(NSForegroundColorAttributeName)、背景色(NSBackgroundColorAttributeName)、下划线(NSUnderlineStyleAttributeName)、删除线(NSStrikethroughStyleAttributeName)、段落样式(NSParagraphStyleAttributeName)等等。
生产级代码里,我一般不会直接在初始化 NSAttributedString 时传一大串字典,而是封装一个便捷构造方法。这样做的好处是调用方代码很干净,以后要统一调整字体或颜色,也只在入口处改一处:
extension NSAttributedString { static func richText( _ text: String, font: UIFont = UIFont.systemFont(ofSize: 16), color: UIColor = .label, paragraph: NSMutableParagraphStyle? = nil, link: URL? = nil ) -> NSAttributedString { var attrs: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: color, ] if let paragraph = paragraph { attrs[.paragraphStyle] = paragraph } if let link = link { attrs[.link] = link } return NSAttributedString(string: text, attributes: attrs) } }封装的方式有很多,有人喜欢用NSMutableAttributedString做链式添加,有人喜欢用属性数组。核心是:不要在业务代码里到处写属性字典,否则后续改设计稿的时候,你会被无数的“魔法字典”折磨。
2.2 段落样式与行距:排版质量的“隐形关键”
很多人设置完字体颜色就觉得富文本做好了,但实际看效果还是跟设计稿差很远。问题大多出在行距和段间距上。
NSMutableParagraphStyle提供lineSpacing、paragraphSpacing、firstLineHeadIndent、headIndent、alignment等。我举一个真实例子:某项目展示用户协议,设计师给出的行距是 6,段间距 12。如果直接用默认 NSParagraphStyle,文字挤成一团,设计稿上是松散的阅读体验,代码则是这样:
let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 6 paragraph.paragraphSpacing = 12 paragraph.alignment = .left paragraph.lineBreakMode = .byWordWrapping let attributed = NSAttributedString( string: "这里是正文内容...", attributes: [ .font: UIFont.systemFont(ofSize: 15), .paragraphStyle: paragraph, ] )这里我要提醒一个点:lineBreakMode有两层设置。一个是 NSAttributedString 的段落样式里的lineBreakMode,另一个是 UITextView 的textContainer.lineBreakMode。它们是两层东西,富文本级别的段落样式控制的是这个段落如何换行,而 textContainer 控制的是整个容器如何截断内容。如果两者不一致,最终表现以容器居多,所以我的建议是:优先设置 textContainer 的lineBreakMode,段落样式里的 lineBreakMode 维持默认byWordWrapping就好。
2.3 链接、图片附件、部分文字加色:富文本的进阶玩法
富文本不只是调行距。常见的需求还有几类:部分文字变为可点击链接、在文本中插入图片(NSTextAttachment)、指定某段文字颜色和字体不同。
先看链接。给某段文字加上link属性后,在 UITextView 里会自动变成可点击,颜色默认是蓝色,可以通过linkTextAttributes修改。我一般会在初始化 UITextView 时统一设置:
textView.linkTextAttributes = [ .foregroundColor: UIColor.systemBlue, .underlineStyle: NSUnderlineStyle.single.rawValue ]部分文字加色、加字体就更直接了:
let content = "请阅读并同意《用户协议》后继续操作" let attributed = NSMutableAttributedString(string: content) let range = (content as NSString).range(of: "《用户协议》") attributed.addAttributes([ .foregroundColor: UIColor.systemBlue, .link: someURL ], range: range) textView.attributedText = attributed注意range用的NSString.range(of:),不要直接用 String 的 NSRange 转换,避免 emoji 或多字节字符导致位置错乱。这个坑我踩过,中英文混排加 emoji 时字符串长度和字符位置很容易对不上。
图片附件 NSTextAttachment 一般用在评论区表情或图文混排。使用方式是把 attachment 包装成 NSAttributedString 插入到目标位置。这里踩过的坑是attachment.bounds要手动设置,否则图片会悬浮在文字底部不齐。设置bounds的origin.y为负值,可以让图片和文字垂直对齐:
let attachment = NSTextAttachment() attachment.image = image attachment.bounds = CGRect(x: 0, y: -4, width: 18, height: 18) let attachString = NSAttributedString(attachment: attachment) result.append(attachString)到这部分为止,富文本单看还不难,一旦跟点击交互、动态高度叠加在一起,才开始真正出问题。
3. 文字折行与动态高度:布局里的硬骨头
3.1 折行到底由谁决定
UITextView 默认情况下在内容超过一行时会自动折行,这是 UIScrollView 和文本容器天然支持的。但实际你可能会遇到几个常见问题。
第一个情况:一整段英文或 URL 特别长,默认按词折行导致单词超长不换行。这时需要用.byCharWrapping强制按字符折行,否则超长 URL 会直接破出控件边界。
第二个情况:设置了maximumNumberOfLines后,文本被截断但 UITextView 依然可以滚动,看起来非常诡异。这是因为isScrollEnabled仍然为 true,加上maximumNumberOfLines不等于 0,内容区域被裁切但滚动能力还在。
第三个情况:富文本里设置了 lineBreakMode,和 textContainer 的冲突导致样式不符,这个上面已经提过。
实际处理上,我给 UITextView 设定折行逻辑通常会写在配置函数里:
textView.textContainer.maximumNumberOfLines = 0 // 不限行数 textView.textContainer.lineBreakMode = .byWordWrapping如果业务中确认会出现长单词或超长 URL,我建议改成:
textView.textContainer.lineBreakMode = .byCharWrapping这样可以保证无论 URL 多长,都不会把左右边界撑破。注意,不要把maximumNumberOfLines设置为 1 又想让高度自适应,这是自相矛盾的。
3.2 动态高度计算:sizeThatFits 和 boundingRect 的两个坑
在 UITableViewCell 里放 UITextView,或者在一个固定宽度的容器里动态展示文本,动态高度是最常见的需求。
我用的方案是借助 UITextView 的sizeThatFits:
let width = textView.frame.width let fittingSize = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) textView.heightAnchor.constraint(equalToConstant: fittingSize.height).isActive = true这里有几个细节需要重点注意。
第一,宽度不要直接用屏幕宽度,要减去左右约束或者使用一个提前算好的最大宽度。如果 UITextView 的宽度由约束决定,可以先用一个临时固定 frame 去测量,比如let size = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude)。
第二,计算时要注意textContainerInset。sizeThatFits返回的高度已经包含textContainerInset和行高,但如果你手动设置了较大的lineFragmentPadding,结果可能会多算或者少算。我统一在初始化里做一件事:textView.textContainer.lineFragmentPadding = 0,左右 padding 全部用textContainerInset控制。这样高度计算和点击坐标换算都少一个变量。
第三,如果isScrollEnabled = true,sizeThatFits的结果会异常,有系统版本甚至会返回一个很大的值。动态高度场景务必先设置为isScrollEnabled = false。
实际项目中我还会封装一个静态方法,用于计算某个 attributedText 在固定宽度下的高度:
static func height(for attributedText: NSAttributedString, width: CGFloat, insets: UIEdgeInsets = .zero) -> CGFloat { let textView = UITextView(frame: CGRect(x: 0, y: 0, width: width - insets.left - insets.right, height: 0)) textView.attributedText = attributedText textView.isScrollEnabled = false textView.textContainer.lineFragmentPadding = 0 textView.textContainerInset = insets let size = textView.sizeThatFits(CGSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude)) return size.height }注意,这个方法里创建一个临时的 UITextView 去测量,其实挺重。如果是在滚动列表里每个 cell 都调一次,性能会有压力。更轻量的是直接用boundingRect计算:
let bounds = attributedText.boundingRect( with: CGSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil ) let height = ceil(bounds.height) + insets.top + insets.bottom但这个方式的坑也不少。比如中文字体有 fallback 字体时,boundingRect经常低估高度;比如没有把.usesFontLeading加进 options 时,行距计算又会不准确。所以我的经验是:在简单的单一字体场景下,boundingRect够用;一旦涉及多语言混排、自定义字体或大量富文本,直接使用临时 UITextView +sizeThatFits最稳。
3.3 富文本折行的特殊场景
如果富文本里包含了大图片附件,或者特别长的链接,折行和高度计算都会变得更加复杂。大图片附件会让每行高度变高,这就不是单纯的行数乘以行高了。这也是为什么我建议动态高度场景优先用sizeThatFits而不是手动按行数计算。
一个曾经让我头疼的问题是,中英文混排的时候,折行位置经常出现在句首标点。苹果的默认排版会尽量把标点带出,但如果你做了自定义的 lineBreakMode 或者将firstLineHeadIndent和headIndent混用,就可能出现标点悬挂异常。这种问题只能靠调整段落样式解决,比如设置paragraphStyle.hyphenationFactor = 1(但 iOS 支持程度有限),或者把标点类字符强制视为行尾不可行首。实际项目中,我的经验是不要过度依赖系统的标点处理,设计上允许中文标点单独出现在行尾基本不影响阅读。
4. 点击文字交互实战:从链接点击到自定义手势
4.1 先理清三种“点击文字”的场景
UITextView 的“点击文字”大致可以分为三类。
第一类是点击 URL、电话号码、地址等系统识别内容。通过dataDetectorTypes或者富文本link属性触发。
第二类是点击富文本中的某段指定文字,执行自定义逻辑。比如点击“《用户协议》”打开协议页,点击“立即登录”跳转登录页。这需要给那段文字添加自定义 scheme 的链接。
第三类是点击文本任意位置触发某个动作。比如点击空白区域收起键盘,或者点击整个消息气泡跳转到详情页。
这三类需求的实现方式完全不同,千万不要混在一起做。否则很容易出现手势和系统链接识别互相抢事件的情况。
4.2 链接点击:靠 delegate 就能解决大部分
使用链接点击最规范的方式是让 UITextView 遵循 delegate 协议,并实现shouldInteractWith系列方法:
extension ViewController: UITextViewDelegate { func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { // 返回 false 表示不跳转,自己处理 if url.scheme == "app" { handleCustomAction(url: url) return false } // 其他链接直接用系统默认跳转 return true } }这里我有一个习惯:App 内部页面链接统一写成自定义 scheme,比如app://protocal/xxx,然后在 delegate 拦截里判断 scheme,返回 false 自己去处理。返回 true 时,文本里的http/https链接默认会交给系统打开 Safari。如果你希望 App 内嵌 WebView 打开,也要在这里返回 false 并自己做跳转。
另外提一下版本兼容:iOS 15 之后新增了textView(_:primaryActionFor:defaultAction:)方法,新的项目推荐用新 API,但在低版本系统上你仍然需要实现旧的 delegate 方法。我在新项目里会两个都写上,用系统版本判断走哪一个,确保行为统一。
4.3 自定义点击:富文本 range 匹配 + 手势
自定义点击的常见做法是,给指定的文字范围添加一个特殊属性,比如给那段文字加上link属性(自定义 scheme)。推荐使用link方案,因为可以完整复用系统的链接点击交互——高亮效果、触摸反馈、delegate 回调都不需要自己写。
但如果你需要点击某个没有 link 属性的区域,也有办法。比如点击整段文字中的某几个字,而这些字并没有链接样式。这时不能靠 delegate,需要自己解析点击位置。步骤是:
- 在 UITextView 上添加一个 UITapGestureRecognizer。
- 利用
layoutManager.characterIndex(for:in:anchorFraction:)方法,把点击坐标转换成字符 index。 - 判断该 index 是否落入目标 range。
let layoutManager = textView.layoutManager let characterIndex = layoutManager.characterIndex( for: tapLocation, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil )注意这个tapLocation需要转换成 UITextView 的坐标空间。如果你给 UITextView 加了contentInset或textContainerInset,坐标换算时要把这些 inset 考虑进去。我的经验是,手势添加之后要设置gestureRecognizer.cancelsTouchesInView = false,防止手势把链接点击或长按选择事件吞掉。
对于更推荐的 link 方案,辅助工具就是写一个富文本 builder。我常用这样一个 extension:
extension NSAttributedString { static func makeTapableText( _ fullText: String, tapString: String, scheme: String, id: String ) -> NSAttributedString { let attrString = NSMutableAttributedString(string: fullText, attributes: [ .font: UIFont.systemFont(ofSize: 15), .foregroundColor: UIColor.label ]) let nsRange = (fullText as NSString).range(of: tapString) if nsRange.location != NSNotFound { attrString.addAttributes([ .foregroundColor: UIColor.systemBlue, .underlineStyle: NSUnderlineStyle.single.rawValue, .link: URL(string: "\(scheme)://\(id)")! ], range: nsRange) } return attrString } }这样处理下来,点击文字的高亮效果、命中范围识别都不需要自己写,极大减少工作量,肉眼观察到的交互体验也最接近系统原生。
4.4 点击与选择、长按之间的冲突排查
一个非常常见的坑是:设置了editable = false、selectable = true,并添加了 tap gesture 之后,长按会先触发文本选择器,而不是触发你的 tap。这个问题不能靠把selectable设为 false 来解决,因为那样又失去了复制能力。
正确做法是给手势设置delaysTouchesBegan = false,并实现gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:),让 tap 和系统的长按选择可以并存:
extension ViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } }如果两者互相干扰严重,还有一个备选方案是在长按回调里通过state判断是否进入选择模式,然后决定是否响应对应逻辑。这个方案比较复杂,非必要不用。
5. 常见坑位与问题排查实录
5.1 高度计算不准确
sizeThatFits和boundingRect算出来高度不一致,这类问题非常常见。先按顺序检查:
- 是否把
textContainerInset算进去了。 - 是否把
lineFragmentPadding设成 0。 - 字体是否使用系统默认字体。如果中文字体带 fallback,
boundingRect可能低估高度。 - 调
boundingRect时是否传入了正确的 attributes。不要用空字典调用。 - 比较时是否做了向上取整。高度必须用
ceil,否则偶发像素差会导致 cell 阴影截断。
我的建议是:在 App 里没有引入复杂字体时,优先使用sizeThatFits。引入可变字体、多语言混排之后,boundingRect更不可靠,还是临时 UITextView 测量最稳。
5.2 链接点击偶尔不生效
如果设置了dataDetectorTypes,同时又通过富文本设置了link,这两个机制可能会互相覆盖。我项目中遇到过一次:文本中电话号码显示成可点击,但 delegate 就是不回调。排查后发现是dataDetectorTypes识别出来的phoneNumber走了系统拨号,而富文本的 URL scheme 是自定义,两个都注册了同一个 range,系统优先处理前者。
解决办法是二选一:如果富文本中已经处理了链接,dataDetectorTypes就不要重复设置.all或.phoneNumber。把系统检测关掉,统一走自己的富文本 link。
5.3 富文本刷新不生效
有同学反馈,设置attributedText后页面没有变化。原因常常是用了同一个NSMutableAttributedString实例,在多个 textView 上复用并且后续修改。NSAttributedString 本身是不可变的,但 NSMutableAttributedString 会共享内部存储布局。把它传给 UITextView 时,UITextView 往往拷贝了内容,后续你修改了原对象,如果不重新给textView.attributedText赋值,界面自然不刷新。
一定要在修改后重新赋值。简单说:
attrString.addAttributes(someStyle, range: range) textView.attributedText = attrString // 必须重新赋值5.4 性能与内存
如果一个页面放了大量 UITextView,比如聊天列表,性能会比较紧张。常用的优化思路:
- 尽量复用 cell,不要为每条消息都创建 UITextView。
- 高度计算避免每次都新建 UITextView,如果文本结构固定,可以在模型层缓存计算后的高度。
- 大量富文本展示可以考虑 TextKit 2 的布局缓存能力(iOS 15+),但低版本系统限制比较多。
实际项目中,我有时会用 UILabel + attributedText 做纯展示,因为 UILabel 更轻量,但它没有内建的 link 交互。所以折中方案是:需要点击的时候才用 UITextView,不需要点击的时候一律 UILabel。用户协议这种必须交互的页面才用 UITextView,普通文案展示直接用 UILabel 就好。
5.5 键盘遮挡
UITextView 在多行输入时,键盘遮挡是经典问题。原生方案是监听键盘通知,把contentInset调整到键盘高度。很多新手用 frame 平移处理,容易在 iPad 浮动键盘上出问题,建议直接调整 textView 的contentInset和scrollIndicatorInsets:
override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil ) } @objc private func keyboardWillShow(_ notification: Notification) { guard let info = notification.userInfo, let rect = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardHeight = rect.height textView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: keyboardHeight, right: 0) textView.scrollIndicatorInsets = textView.contentInset }当然现在键盘避让也可以交给约束和 UIScrollView 的contentInsetAdjustmentBehavior去做,但直接把键盘高度同步到contentInset仍然是最直观、最不容易出问题的旧方案。
最后再说一个我自己的习惯:凡是展示型 UITextView,我都会把textContainer.lineFragmentPadding设成 0,然后用textContainerInset控制内边距。这样一来,高度计算、点击坐标换算都少一个变量,排查问题快很多。对于多行输入的 UITextView,暗色模式下要注意 placeholder 的实现,目前原生还是没有内置 placeholder,个人建议用 UILabel 叠加实现,不要用 attributedText 的空字符方案,否则容易出现光标偏移和辅助功能朗读错位的问题。这个小点不复杂,但确实影响日常使用体验。