第 1 周:别小看 `UILabel`,它跟 Android TextView 不只是名字不同

写 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
    }
}

CADisplayLinkTimer 跟手,原因是它的回调跟屏幕刷新对齐。视图离开窗口时记得 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 显式给出路径,不然每帧都重算阴影。

卡片白底容易掉坑:UIStackViewbackgroundColor 不显示,因为 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 默认 10 表示不限行数 --- 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),不是 Swift String.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,那才是迁移难度真正开始上升的地方------EditTextInputTypeUITextFieldkeyboardType 命名很像但能选的值差很多,焦点链在两端都是手动管理但 API 完全不同,还有安卓那边著名的"软键盘挡住输入框"老大难。UITextField 没有 addTextChangedListener,要靠 textDidChangeNotificationUITextFieldDelegate,记法跟安卓不同。这周踩的 numberOfLines=1 那张表下周还会再扩几个条目。

相关推荐
游戏开发爱好者81 天前
iOS 推送怎么配置,APNs 推送证书、设备库与群发
android·ios·小程序·https·uni-app·iphone·webview
Anhty2 天前
2026 实测 4 款 AI 变声器|QQ 聊天伪装声线,告别僵硬假声
人工智能·功能测试·ios·智能手机·安卓
唔662 天前
flutter web iOS 在浏览器加载中文慢的问题
前端·flutter·ios
ZacJi2 天前
我为 DeepSeek Harness 写了一个 dsh-apple-mode 插件
ios·deepseek·vibecoding
2501_915921433 天前
抓包鹰 Traceeagle 解除证书绑定,SSL Pinning 解除
网络·网络协议·网络安全·ios·adb·https·ssl
人月神话Lee3 天前
我做了个不要账号、不要定位权限的旅行 App,聊聊那些「不做」的决定
ios·ai编程·产品
2501_916007474 天前
Bundle ID 注册与管理 App ID 创建指南 命名规则 权限开关与删除注意事项
android·ios·小程序·https·uni-app·iphone·webview
2501_915909064 天前
iOS 证书创建与管理 类型限制 p12 密码与云备份实操
android·ios·小程序·https·uni-app·iphone·webview