Highlight
tvOS 27 首次引入系统级大字体支持,开发者只需将硬编码字体替换为语义化文本样式、将固定约束改为弹性布局,即可让 App 自动适配从 Large 到 Accessibility XXXL 的七档字体大小,同时可在 App Store 的 Accessibility Nutrition Labels 中标注支持大字体以触达有需求的用户。
核心内容
想象你正在沙发上用 Apple TV 浏览电影。你父母走过来,眯着眼说:“这字太小了,我看不清。“你打开设置,把字体调到最大,结果界面上的标题被截断了一半,按钮标签挤成一团,整个 App 变得没法用。
这就是 tvOS 27 之前所有 App 的常态。iOS 早在多年前就支持 Dynamic Type,但 Apple TV 一直缺席。tvOS 27 终于补齐了这个缺口,把系统级字体缩放带到了客厅大屏。
用户可以在「设置 > 辅助功能 > 显示与文字大小 > 文字大小」中,从 Large 一路调到 Accessibility XXXL。标准 UIKit 和 SwiftUI 组件——Label、Button、Navigation TabBar——会自动响应这个设置。你的任务只剩下一件事:找出那些自定义的、硬编码的文本和布局,把它们改成弹性的。
Apple 还提供了一个额外的曝光渠道:你可以在 App Store 的 Accessibility Nutrition Labels 中勾选 tvOS 的 Larger Text 支持。这会让专门寻找无障碍 App 的用户更容易发现你的作品。
三个必须排查的问题
当你把 App 跑在大字体模式下,重点找三类问题。
第一类是硬编码字号。比如一段标题用了 .system(size: 28),这个字无论用户怎么调设置都不会变。它在大字体模式下会显得格格不入,甚至比其他文字还小。
第二类是固定宽高约束。一个 Text 视图被 frame(width: 300) 锁死,文字放大后只能截断。或者一个 HStack 的间距写死了 40pt,字变大了但空间没变,内容就挤在一起。
第三类是布局没有随字体调整。一个海报墙在标准字体下能并排显示 6 张,字放大后每张海报下面的标题变长,6 列就塞不下了。如果不减少列数,标题会被截断,整个界面显得局促。
详细内容
从硬编码到语义化样式(04:58)
Session 用一个媒体 App 的「注册信息」界面来演示。原始代码长这样:
VStack(spacing: 20) {
Text("Signup information")
.font(.caption.bold())
.lineLimit(1)
.foregroundStyle(.secondary)
.frame(width: 300, alignment: .leading)
HStack(alignment: .top, spacing: 40) {
// ...
}
}
这里有三个问题:.font(.caption.bold()) 虽然用了语义化样式,但整个视图结构里还有其他硬编码字号;.frame(width: 300) 把宽度锁死;spacing: 40 在字变大后可能不够。
修复第一步:把固定宽度改成弹性约束。
VStack(spacing: 20) {
Text("Signup information")
.font(.caption.bold())
.lineLimit(1)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(alignment: .top, spacing: 40) {
// ...
}
}
关键点:
maxWidth: .infinity告诉 SwiftUI:这个 Text 需要多少宽度就给多少,不要截断。- 语义化样式
.caption会自动随 Dynamic Type 缩放,不需要你手动计算比例。
UIKit 中的 Dynamic Type(05:55)
如果你用 UIKit,做法类似,但多一个开关:
// 硬编码字号——不要这样做
titleLabel.font = UIFont.boldSystemFont(ofSize: 28)
// 正确的做法
titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
titleLabel.adjustsFontForContentSizeCategory = true
关键点:
UIFont.preferredFont(forTextStyle:)返回与用户设置匹配的字体大小。adjustsFontForContentSizeCategory = true让 Label 在用户在设置中调整字体大小时自动更新,不需要重启 App。
用容器相对帧调整网格列数(07:09)
海报墙是 tvOS App 最常见的界面。标准字体下 6 列很宽敞,大字体下需要减少到 4 列,给每张海报的标题留足空间。
struct MovieShelf: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 40) {
ForEach(Asset.allCases) { asset in
Button {
// ...
} label: {
asset.portraitImage
Text(asset.title)
}
.containerRelativeFrame(
.horizontal,
count: dynamicTypeSize.isAccessibilitySize ? 4 : 6,
spacing: 40)
}
}
}
}
}
关键点:
@Environment(\.dynamicTypeSize)读取当前字体大小级别。dynamicTypeSize.isAccessibilitySize判断用户是否开启了 Accessibility 级别的大字体(AX1 到 AX5)。containerRelativeFrame(.horizontal, count:spacing:)把 ScrollView 的可用宽度均分给指定数量的子视图,自动处理间距。- 当
isAccessibilitySize为 true 时显示 4 列,否则显示 6 列。
如果标题特别长,还可以考虑自定义 Marquee 滚动效果,让文字在获得焦点时横向滚动显示完整内容。
用 AnyLayout 实现条件布局切换(08:07)
内容卡片通常把图片和文字并排摆放。大字体下,并排布局会导致文字被压缩。解决方案:在无障碍字体下切换为垂直堆叠。
struct CardContentView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var asset: Asset
var body: some View {
let layout = dynamicTypeSize.isAccessibilitySize ?
AnyLayout(VStackLayout(alignment: .leading, spacing: 10)) :
AnyLayout(HStackLayout(alignment: .top, spacing: 10))
layout {
asset.image
Text(asset.title)
Text(asset.subtitle)
.foregroundStyle(.secondary)
}
}
}
关键点:
AnyLayout是 SwiftUI 的类型擦除布局容器,允许你在运行时切换不同的布局策略。- 使用
AnyLayout而不是if/else切换VStack/HStack,可以保留视图的身份标识,避免 tvOS 焦点系统在布局切换时丢失当前焦点。 VStackLayout和HStackLayout是布局协议的具体实现,可以被 AnyLayout 统一包装。
UIKit 中响应内容尺寸变化(08:31)
UIKit 通过 Trait Changes 机制监听字体大小变化:
class AdaptiveLayoutViewController: UIViewController {
let stackView = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
updateLayout()
let sizeTraits: [UITrait] = [UITraitPreferredContentSizeCategory.self]
registerForTraitChanges(sizeTraits, action: #selector(updateLayout))
}
@objc private func updateLayout() {
if traitCollection.preferredContentSizeCategory.isAccessibilityCategory {
stackView.axis = .vertical
} else {
stackView.axis = .horizontal
}
}
}
关键点:
UITraitPreferredContentSizeCategory.self是要监听的特征类型。registerForTraitChanges(_:action:)在 iOS/tvOS 17+ 中替代了旧的NSNotification监听方式,类型更安全。traitCollection.preferredContentSizeCategory.isAccessibilityCategory判断当前是否处于 Accessibility 字体级别。UIStackView.axis在.horizontal和.vertical之间切换,实现与 SwiftUIAnyLayout相同的效果。
核心启发
做一个长辈友好的电视购物 App
做什么:检测到大字体时,把商品卡片从横向排列改成纵向堆叠,价格信息用 .headline 样式自动放大,商品描述从一行缩略改成多行展开。
为什么值得做:长辈用户最需要大字体,但固定布局在大字体下会截断或重叠。弹性布局让界面在任何字体大小下都能正常显示,扩大目标用户群体。
怎么开始:用 @Environment(\.dynamicTypeSize) 读取当前字体大小,用 AnyLayout 在标准字体下用 HStackLayout、大字体下切换为 VStackLayout。
为视频点播 App 做自适应海报墙
做什么:标准字体下 6 列海报墙很美观,大字体下用 containerRelativeFrame 降到 4 列,给长标题留空间。如果标题仍然超长,给获得焦点的海报启动 Marquee 滚动。
为什么值得做:电影标题长度差异很大,固定列数在大字体下会导致标题截断。自适应列数让界面在不同字体大小下都保持可读性。
怎么开始:用 @Environment(\.dynamicTypeSize) 检测字体大小,containerRelativeFrame(.horizontal, count:spacing:) 在 isAccessibilitySize 时显示 4 列,否则 6 列。
在设置页标注无障碍支持
做什么:在 App Store Connect 的 Accessibility Nutrition Labels 中为 tvOS App 勾选 Larger Text 支持。
为什么值得做:这是零代码的改动,但能让你的 App 在无障碍筛选中获得曝光。专门寻找无障碍 App 的用户更容易发现你的作品。
怎么开始:登录 App Store Connect,找到 App 信息页的 Accessibility 部分,勾选 tvOS 的 Larger Text 支持选项。
用正则批量清洗遗留代码
做什么:老项目里搜索 UIFont.systemFont(ofSize: 和 .system(size:,全部替换为 UIFont.preferredFont(forTextStyle:) 和语义化样式。再搜索 frame(width: 和 frame(height:,评估哪些可以改成弹性约束。
为什么值得做:遗留代码中硬编码字号和固定约束是 Dynamic Type 支持的最大障碍。批量替换是最短路径,让旧项目快速获得大字体适配能力。
怎么开始:用 IDE 全局搜索上述模式,逐个替换为语义化样式和 maxWidth: .infinity 或 minHeight: 等弹性约束。
为 tvOS 焦点系统做字体适配测试
做什么:在 Apple TV 上把字体调到最大档,遍历所有界面验证焦点框是否完整包裹内容、文字是否被截断、布局是否错位。
为什么值得做:tvOS 的焦点导航系统对布局变化敏感,大字体下焦点框可能偏移或包裹不完整。真实设备测试比模拟器更能发现问题。
怎么开始:在 Apple TV 设置中将文字大小调到 Accessibility XXXL,用遥控器遍历每个界面,重点检查按钮标签、海报标题和详情页正文。
关联 Session
- 219: What’s new in accessibility for reading — 了解 iOS 和 macOS 上的阅读辅助新特性,与 tvOS 大字体形成跨平台无障碍策略
- 220: Create custom accessibility controls — 学习如何为 tvOS 自定义控件添加无障碍支持,配合 Dynamic Type 打造完整的辅助功能体验
- 278: UIKit modernization — UIKit 现代化改进,包含
registerForTraitChanges等新 API 的更多用法 - 269: What’s new in SwiftUI — SwiftUI 新特性总览,
AnyLayout和containerRelativeFrame的完整能力介绍
评论
GitHub Issues · utterances