写 Android 第 13 周那会儿回头翻 TextView 那篇文章,我才意识到一件事:iOS 这边的 UILabel 跟安卓 TextView 名字像兄弟,默认值、扩展点、性能口径几乎全是反的。下面这几件事是我这周读 Apple Developer 文档+逐条对照安卓代码撞出来的,不是什么系统教学,就是踩坑流水账。
写 UILabel 第一行代码就踩了。
ini
let label = UILabel()
label.text = "一段很长的正文,多到肯定要换行的那种......"
label.font = .systemFont(ofSize: 16)
跑起来只显示一行,后面凭空消失。编译器不报警,运行时也不抛错,就是少了一行字------numberOfLines 默认 1。改成 0 才不限行数。Apple 文档原文 "Setting a value of 0 allows the label to use as many lines as necessary"。安卓那边 TextView 默认 singleLine = false 也就是多行,方向是反的。从安卓过来的人 100% 会栽这一下。我自己栽了,调了半天以为是字体的事。
富文本也不像安卓。安卓是给 TextView 设个 SpannableString,文本本体还是 String。iOS 这边 UILabel 是两个独立 属性------text: String? 和 attributedText: NSAttributedString?,后设的覆盖先设的,只设 text 时属性全丢。attributes 字典的 key 是 NSAttributedString.Key,常用 .font、.foregroundColor、.backgroundColor、.underlineStyle。.underlineStyle 收的是 NSUnderlineStyle 枚举的 rawValue,不是布尔值,这种 Foundation 时代留下来的啰嗦感只能习惯。
这段拼装本身不便宜。列表页每个 cell append 一遍,快速滚动就是每帧几百次属性写入。NSAttributedString 是值类型 immutable,缓存零风险------按内容 key 缓存一份,命中直接复用。Android 第 1 周我也是这么干的,两端思路一致。
Apple 在 tvOS 12 加了一个 enablesMarqueeWhenAncestorFocused 属性,行为跟安卓 marquee 几乎一样------label 文本会横向滚动。但只对带焦点的祖先视图的 label 生效。iPhone/iPad 没有焦点引擎,这个属性在这两个平台上是个沉默的属性。所以手机端 iOS 开发,"没有跑马灯"在工程上成立,但要带这条边界。
手机端要滚动只能手写。原理是三件事:
swift
@objc private func step() {
guard isMarqueeActive, textWidth > 0 else { return }
if CACurrentMediaTime() < pauseUntil { return }
// 用真实帧间隔算位移,不是固定挪 1pt------否则 120Hz 屏上速度翻倍
let delta = displayLink?.duration ?? (1.0 / 60.0)
firstLabel.frame.origin.x -= scrollSpeed * delta
secondLabel.frame.origin.x -= scrollSpeed * delta
// 第一份滚出左边界时整体右移一个周期,无缝循环
if firstLabel.frame.maxX <= 0 {
firstLabel.frame.origin.x += textWidth + gap
secondLabel.frame.origin.x += textWidth + gap
}
}
CADisplayLink 比 Timer 跟手,原因是它的回调跟屏幕刷新对齐。视图离开窗口时记得 displayLink.invalidate(),不然页面 pop 了定时器还在空转。安卓 marquee 的 isSelected = true,本质上就是把上面这套机制藏到一个布尔值里------表面简洁,原理被藏了。
阴影两条路,能力不同代价不同。
文字级阴影用 NSShadow:
ini
let shadow = NSShadow()
shadow.shadowColor = UIColor.black.withAlphaComponent(0.35)
shadow.shadowOffset = CGSize(width: 0, height: 2)
shadow.shadowBlurRadius = 4
label.attributedText = NSAttributedString(string: "文字级阴影", attributes: [
.font: .boldSystemFont(ofSize: 20),
.shadow: shadow
])
NSShadow 只在文字 glyph 走的时候生效,没有离屏渲染代价 。但代价是它只跟着文字走,做不了整个卡片的投影。卡片投影还得回 layer.shadow*,加 shadowPath 显式给出路径,不然每帧都重算阴影。
卡片白底容易掉坑:UIStackView 设 backgroundColor 不显示,因为 UIStackView 不参与绘制,是纯布局容器。背景色必须垫一层真实的 UIView。这是我从安卓过来第二次踩------第一次是 numberOfLines,第二次是 StackView。
UILabel 默认不吃触摸事件。UIView.isUserInteractionEnabled 默认 true,但 UILabel 重写了默认值,改成 false------Apple 文档原句 "this class changes the default value of this property to false"。所以给 label 加 UITapGestureRecognizer 没反应,必须显式开:
ini
label.isUserInteractionEnabled = true
开了之后想实现"只点《用户协议》这几个字才响应",得自己算------手指落在 label 的哪个点,对应文本里的第几个字符。答案在 TextKit 三件套里:UILabel 底层排版就是 NSTextStorage + NSLayoutManager + NSTextContainer。手动搭一套和 label 一致的环境:
ini
let storage = NSTextStorage(attributedString: attributedText)
let layoutManager = NSLayoutManager()
let container = NSTextContainer(size: label.bounds.size)
container.lineFragmentPadding = 0
container.maximumNumberOfLines = label.numberOfLines
container.lineBreakMode = label.lineBreakMode
layoutManager.addTextContainer(container)
storage.addLayoutManager(layoutManager)
let index = layoutManager.characterIndex(for: gesture.location(in: label),
in: container, fractionOfDistanceBetweenInsertionPoints: nil)
两个细节文档都写了,但容易忘:
lineFragmentPadding 默认 5.0------NSTextContainer 在 line fragment 前后各加 5pt 内边距,UILabel 不要这个内边距。少抹平这个差值,点右边界的字会算错下标。
characterIndex 返回 NSString 索引(UTF-16 code units),不是 Swift String.Index。中文一字一单位,emoji 一字两单位,旗帜 emoji 一字四单位。Martin R 在 Stack Overflow 上专门写过这个坑。要转成 Swift String.Index 得走 String.utf16.index(...) 桥接。
列表行高走 NSStringDrawing.boundingRect:
less
(text as NSString).boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: [.font: font],
context: nil
).size
options 里两个 flag 都有讲究:usesLineFragmentOrigin 按多行排版算,usesFontLeading 计入字体自带的 leading 行间距。缺一个,算出来的高度跟 UILabel 实际渲染差 1-3pt,列表里就是行间距不均匀。测量是纯函数,同段文本同字号同宽度结果永远相同,没有理由算第二次 ,按 文本哈希 + 字号 + 宽度 做 key 缓存。
价格区的数字宽度跳动也是个常见问题------"¥9.99" 和 "¥10.00" 在普通字体里宽度不同,刷新时左右抖。解决方法是等宽数字字体:给 UIFontDescriptor 加 .featureSettings 特征,指定 kNumberSpacingType + kMonospacedNumbersSelector。每个数字占位相同,价格稳如老狗。
本篇实践来源
UILabel.numberOfLines默认1、0表示不限行数 --- Apple Developer 官方文档UILabel.isUserInteractionEnabled默认false,UILabel 重写了 UIView 的默认值 --- Apple 官方isUserInteractionEnabled文档NSTextContainer.lineFragmentPadding默认5.0--- Apple Developer 官方文档characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:)返回 NSString-based 索引(UTF-16 code units),不是 SwiftString.Index--- Apple 文档 + Martin R 在 Stack Overflow 的解释enablesMarqueeWhenAncestorFocused在 iPhone/iPad 上无效 --- Apple 官方文档标注 "tvOS 12.0+"
边界一:渐变文字的 UIColor(patternImage:) 方案是社区常见写法,Apple 没把它收进官方教程;CAGradientLayer 做 mask 的官方路径在第 22 周讲 Core Animation 时再展开。
边界二:等宽数字字体用 UIFontDescriptor.FeatureKey 加 feature settings 这条路我没在线文档里找到明确公开资料,是 Core Text 公开头文件 + 工程实践的组合,边界处就不写"Apple 推荐"这种话。
技术清零表
| 技术 | 它是什么 | 工程价值 | 常见坑 |
|---|---|---|---|
UILabel |
iOS 文本展示基础组件 | 一切文字页面的起点 | numberOfLines 默认 1 |
NSAttributedString / NSMutableAttributedString |
带样式属性的字符串 | 价格/协议/公告样式混排 | 只设 text 导致属性全丢 |
enablesMarqueeWhenAncestorFocused |
tvOS 12+ 的 marquee 属性 | tvOS 上免手写 | iPhone/iPad 上无效 |
CADisplayLink |
跟随屏幕刷新率的定时器 | 自定义动画驱动源 | 位移不按帧间隔算,120Hz 屏速度翻倍 |
UIColor(patternImage:) |
把图片当颜色用 | 渐变文字绘制(社区方案) | 忘了 super.drawText 文字不画 |
NSShadow |
文字绘制级阴影 | 文字立体感,无离屏渲染代价 | 能力边界窄,做不了卡片投影 |
isUserInteractionEnabled |
UIView 的触摸事件开关 | UILabel 默认 false 必须显式开 |
默认关,手势加上去没反应 |
| TextKit 三件套 | 文本排版引擎 | 富文本交互的地基 | lineFragmentPadding 默认 5.0 要抹平 |
characterIndex(for:in:fractionOfDistanceBetweenInsertionPoints:) |
坐标 → 字符下标 | 协议字点击、长按选词 | 返回 NSString 索引不是 Swift String.Index |
boundingRect(with:options:) |
NSStringDrawing 测量入口 | 列表行高计算 | options 缺一高度差 1-3pt |
UIFontDescriptor 特征字体 |
给字体附加特征 | 等宽数字价格 | 描述对象有构建成本要缓存 |
UIStackView |
自动排布容器 | 快速搭纵向布局 | 设 backgroundColor 无效,需垫 UIView |
下一周写 UITextField / UITextView,那才是迁移难度真正开始上升的地方------EditText 的 InputType 跟 UITextField 的 keyboardType 命名很像但能选的值差很多,焦点链在两端都是手动管理但 API 完全不同,还有安卓那边著名的"软键盘挡住输入框"老大难。UITextField 没有 addTextChangedListener,要靠 textDidChangeNotification 或 UITextFieldDelegate,记法跟安卓不同。这周踩的 numberOfLines=1 那张表下周还会再扩几个条目。