Dynamic Type 入门指南
Get started with Dynamic Type
2024年6月10日
一句话判断
Dynamic Type 不只是”让字体变大”,它是一套从文字样式到布局结构到交互控件的完整自适应体系——用 SwiftUI 的 dynamicTypeSize 环境变量做布局切换,用 Large Content Viewer 兜底无法缩放的控件,是 2024 年做无障碍适配的最优解。
这场 Session 讲了什么
Dynamic Type 是 Apple 平台的文字大小自适应机制。用户可以在设置里选择 7 种常规尺寸和 5 种辅助功能大字号,应用需要适配所有尺寸。这场 Session 从四个层面讲适配策略:文字缩放、布局调整、图片符号处理、Large Content Viewer 兜底。
文字缩放的核心是使用系统内置的 Text Styles(body、headline、title 等),而不是硬编码字号。SwiftUI 里用 .font(.title) 指定样式,UIKit 里用 preferredFont(forTextStyle:) 并设置 adjustsFontForContentSizeCategory = true。Text Styles 自动维护视觉层级——标题永远比正文大,即使在最大字号下也一样。
布局调整是重头戏。当字号大到一定程度,水平排列的内容可能放不下,需要切换为垂直布局。SwiftUI 提供了 dynamicTypeSize 环境变量来判断当前字号范围,配合 AnyLayout 做动态切换。UIKit 则用 UIStackView 的 axis 属性,通过 isAccessibilityCategory 判断是否需要切换。
图片和 SF Symbols 在大字号下需要特殊处理:装饰性图片不应该跟着放大,功能性图标需要适当缩放但不要占满屏幕。文字应该换行绕过不缩放的图片。
最后,有些控件(比如 tab bar 里的图标)没法跟着字号放大,这时候用 Large Content Viewer:用户长按控件时弹出一个放大的展示视图,显示完整的文字和图标。
值得深挖的点
用 AnyLayout 做动态布局切换
这是这场 Session 最实用的技术点。在 SwiftUI 里,你可以根据 dynamicTypeSize 环境变量动态选择布局容器:
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var dynamicLayout: AnyLayout {
dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout()) : AnyLayout(HStackLayout())
}
这种写法的好处是:布局切换逻辑集中在一个属性里,body 里的代码保持干净。而且 AnyLayout 是值类型,性能开销可以忽略。
但更优雅的做法是用 .layoutPriority() 和自定义 Layout 协议。不过对于大多数场景,HStack/VStack 的动态切换已经够用。Session 里展示的联系人海报编辑器就是一个典型场景:小字号时选项水平排列(节省纵向空间),大字号时切换为垂直排列(每个选项占满宽度)。
图片和文字的共存策略
Session 对图片处理给了一个清晰的优先级原则:功能性内容优先缩放,装饰性元素保持不变。
这个原则体现在三个层次。第一,和文字直接关联的功能性图标(比如列表项前的状态图标)应该跟着字号适度缩放,但不能比文字大。第二,纯装饰性图片(比如 Settings 列表项左侧的彩色图标)不应该缩放,文字应该换行绕过图片以充分利用屏幕宽度。第三,如果装饰性图片在大字号下严重挤压了文字空间,可以考虑直接隐藏——毕竟用户开大字号是因为要看文字。
SF Symbols 在大字号下的行为值得单独注意。SF Symbols 天然支持 Dynamic Type,但你需要选择合适的渲染模式(monochrome vs multicolor)和缩放行为。在文字行内的符号用 .baselineOffset 对齐,独立展示的符号用 imageScale 匹配文字层级。
代码片段
SwiftUI 动态布局切换
struct FigureCell: View {
let title: String
let imageName: String
@Environment(\.dynamicTypeSize) var dynamicTypeSize
// 根据字号动态选择内部布局
var internalLayout: AnyLayout {
dynamicTypeSize.isAccessibilitySize
? AnyLayout(HStackLayout()) // 大字号:水平排列(图标在左,文字在右)
: AnyLayout(VStackLayout()) // 常规字号:垂直排列(图标在上,文字在下)
}
var body: some View {
internalLayout {
Image(systemName: imageName)
.imageScale(.large)
Text(title)
.font(.body)
}
}
}
场景:单个卡片组件在大字号下自动切换排列方向。坑:isAccessibilitySize 在 Dynamic Type 超过一定阈值后变为 true,但具体的视觉表现需要在每个尺寸下实测——Xcode Preview 的 Dynamic Type Variants 功能可以一键生成所有字号预览。
UIKit 响应字号变化
class MyViewController: UIViewController {
let stackView = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
// 用 isAccessibilityCategory 判断当前是否是辅助功能大字号
updateStackAxis()
// 监听字号变化
NotificationCenter.default.addObserver(
self, selector: #selector(contentSizeCategoryDidChange),
name: UIContentSizeCategory.didChangeNotification, object: nil
)
}
@objc func contentSizeCategoryDidChange() {
updateStackAxis()
}
private func updateStackAxis() {
stackView.axis = traitCollection.preferredContentSizeCategory.isAccessibilityCategory
? .vertical : .horizontal
}
}
场景:UIKit 项目中响应字号变化的布局切换。坑:didChangeNotification 在应用前台运行时才会触发,后台不会——如果你的布局依赖初始字号,viewDidLoad 里也要调用一次。
Large Content Viewer 兜底
// SwiftUI 中为无法放大的控件添加 Large Content Viewer
TabView {
HomeView()
.tabItem {
Label("首页", systemImage: "house")
}
.accessibilityShowsLargeContentViewer {
// 长按时显示的放大版本
Label("首页", systemImage: "house")
.font(.title)
}
}
场景:Tab Bar 图标没法跟着字号放大,用 Large Content Viewer 做兜底。坑:Large Content Viewer 需要用户长按触发,不是所有用户都知道这个手势——最好在应用引导里提一下。
最佳实践
已有项目: 打开 Xcode Preview,点击 Dynamic Type Variants,检查所有核心页面在大字号下的表现。优先修复文字截断和溢出问题(用 .lineLimit(nil) 或设置 numberOfLines = 0),然后再处理布局切换。不需要一次性把所有页面都做完美的动态布局,先解决”用不了”的问题。
新项目: 从第一天就用系统 Text Styles,不要硬编码字号。布局设计时预留”水平变垂直”的切换能力——在 SwiftUI 里用 AnyLayout,在 UIKit 里用 UIStackView。纯装饰性图片一律不缩放,让文字环绕显示。
还有什么值得关注
- Xcode 15+ 的 Accessibility Audit 功能可以自动检测文字截断、缺少标签、对比度不足等问题,比手动逐页检查效率高得多。
UIFontMetrics可以让自定义字体也参与 Dynamic Type,不需要自己算缩放比例。- visionOS 上的 Dynamic Type 行为和 iOS 一致,但空间计算环境下的文字缩放对布局影响更大——窗口尺寸是用户可调的。