针对动态字体准备好你的 Apple tvOS App
Prepare your tvOS apps for Dynamic Type
2026年6月10日
一句话判断
tvOS 终于补齐了 Dynamic Type (动态字体) 这块无障碍短板,但这场 Session 真正的价值在于教你如何用 AnyLayout 和 containerRelativeFrame 优雅地重构客厅大屏的响应式布局。
这场 Session 讲了什么
tvOS 27 终于把 iOS 早就有的大字体支持搬到了 Apple TV 上。以前 tvOS 开发者习惯了硬编码字号和固定网格,现在这套行不通了。Apple 明确表态:光把字放大导致截断是半成品,必须配合空间重构。
SwiftUI 给出了标准答案:用 @Environment(\.dynamicTypeSize) 读取当前字体级别,配合 AnyLayout 在横纵布局间平滑切换,或者用 containerRelativeFrame 动态调整网格列数。UIKit 阵营则通过 Trait Changes (特征变化) 监听来切换 UIStackView 的轴向。
这对做媒体、流媒体、内容展示类 tvOS App 的团队影响最大。如果你的 App 在电视上给长辈用,这套适配直接决定了他们能不能看清节目单,顺便还能在 App Store 的 Accessibility Nutrition Labels (无障碍营养标签) 里给自己贴个金。
值得深挖的点
从“放大文字”到“重构空间”的思维转变
很多开发者做 Dynamic Type 适配,思路停留在“把 14pt 改成 24pt”。但在 tvOS 这种大屏场景下,字变大后,原本水平排列的卡片肯定会挤出屏幕。Apple 在这场 Session 里强推 AnyLayout,这是一个非常聪明的设计选择。
在旧方案里,如果要在大字体下把 HStack 换成 VStack,开发者通常会用 if/else 包两层视图。这在 iOS 上勉强能用,但在 tvOS 上是灾难。tvOS 依赖 Focus System (焦点系统) 进行遥控器导航,if/else 会导致视图销毁重建,焦点直接丢失,用户按个方向键焦点就飞了。AnyLayout 让你在同一个视图树上切换布局容器,视图身份 (View Identity) 保持不变,焦点稳稳留在原处。这个 API 在 tvOS 上的价值,远比在 iOS 上大。
网格布局的弹性魔法
处理海报墙这种网格布局时,Session 展示了 containerRelativeFrame 的妙用。以前我们要根据屏幕宽度和字体大小手动计算列数,现在只需声明 count: dynamicTypeSize.isAccessibilitySize ? 4 : 6。
这种声明式 API 把计算逻辑交给了系统,trade-off 是牺牲了极细粒度的控制。它强依赖于外层的 ScrollView 来提供相对尺寸参考。如果你的海报墙嵌套在非常复杂的自定义布局里,或者需要非等宽的列,这个 API 就会显得鸡肋。但对于 90% 的标准流媒体首页推荐流来说,这行代码能省下几百行计算逻辑。
代码片段
1. 用 AnyLayout 实现横纵布局无缝切换
场景:卡片内容在大字体下从图文并排变为上下堆叠,保证焦点不丢失。
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 {
Image(asset.poster)
Text(asset.title)
.font(.headline) // 使用语义化字体样式
}
}
}
坑:千万别在 layout 闭包外面套 if/else 来切换 VStack 和 HStack,那会导致 tvOS 焦点重置。
2. 动态调整网格列数
场景:电影海报墙在标准字体下显示 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: {
PosterView(asset: asset)
Text(asset.title)
}
// 告诉系统根据当前字体级别分配相对宽度
.containerRelativeFrame(
.horizontal,
count: dynamicTypeSize.isAccessibilitySize ? 4 : 6,
spacing: 40
)
}
}
}
}
}
坑:containerRelativeFrame 必须放在 ScrollView 内部才能正确计算相对尺寸,放在普通 VStack 里会失效。
3. UIKit 监听字体特征变化
场景:老项目使用 UIKit 构建,需要监听系统字体偏好变化并调整 UIStackView 方向。
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
}
}
}
坑:别忘了给具体的 UILabel 设置 adjustsFontForContentSizeCategory = true,否则布局变了字还是那么大。
最佳实践
- 全局清洗硬编码:在已有项目中,用正则搜索
.system(size:和UIFont.systemFont(ofSize:,全部替换为.font(.body)或UIFont.preferredFont(forTextStyle: .body)。同时搜索固定的.frame(width: 300),改为.frame(maxWidth: .infinity)让内容自己撑开。 - 焦点测试是灵魂:tvOS 适配大字体后,一定要用 Siri Remote 实际按一按。布局切换时如果焦点跳到了屏幕边缘或者消失,说明你的视图层级在切换时被销毁了,老老实实换回
AnyLayout。 - 跑马灯兜底:如果某些电影标题实在太长,大字体下无论怎么调布局都会截断,别硬塞。Apple 建议实现自定义的 Marquee (跑马灯) 效果,让文字在焦点停留时滚动显示。
还有什么值得关注
- Accessibility Nutrition Labels (无障碍营养标签):现在可以在 App Store Connect 里勾选支持大字体,这会让你的 App 在无障碍筛选搜索中获得更高权重。
- 语义化样式的红利:使用
.caption或.headline等 Semantic text styles (语义文本样式),系统不仅会帮你缩放大小,还会自动调整字重和行高,比手动算比例省心得多。