Prepare your tvOS apps for Dynamic Type
Accessibility & Inclusion 进阶 10m

针对动态字体准备好你的 Apple tvOS App

Prepare your tvOS apps for Dynamic Type

2026年6月10日

在 Apple 官方观看视频

一句话判断

tvOS 终于补齐了 Dynamic Type (动态字体) 这块无障碍短板,但这场 Session 真正的价值在于教你如何用 AnyLayoutcontainerRelativeFrame 优雅地重构客厅大屏的响应式布局。

这场 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 来切换 VStackHStack,那会导致 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 (语义文本样式),系统不仅会帮你缩放大小,还会自动调整字重和行高,比手动算比例省心得多。
tvOS Dynamic Type SwiftUI Accessibility UIKit