4、动态类型与字体适配:UIFontMetrics、preferredContentSizeCategory、自定义字体适配

动态类型,说白了就是让用户自己决定字体大小。

你想想看,视力好的年轻人可能觉得默认字体刚好,但上了年纪的用户呢?或者有低视力障碍的朋友?他们可能需要把字体调大两倍甚至三倍。如果我们的 App 不支持动态类型,那这些用户就只能干瞪眼——或者直接卸载你的 App。

我个人习惯,从 iOS 11 开始就把动态类型作为每个项目的标配。嗯,今天我们就来聊聊怎么做好这件事。

4.1 UIFontMetrics:动态字体的核心工具

UIFontMetrics 是 iOS 11 引入的类。它的作用很简单:根据用户设定的字体大小类别,自动缩放你的字体。

我以前在做一个阅读类 App 时,一开始没用 UIFontMetrics,结果用户反馈说「字体调大了但界面还是那么小」。后来我才意识到,光调 UILabel 的字体大小没用,得用系统提供的缩放机制。

核心用法:

// 获取默认的字体度量对象
let metrics = UIFontMetrics.default

// 基于系统字体创建可缩放字体
let bodyFont = UIFont.preferredFont(forTextStyle: .body)
let scaledFont = metrics.scaledFont(for: bodyFont)

// 应用到控件
label.font = scaledFont
label.adjustsFontForContentSizeCategory = true

这里有个关键点:adjustsFontForContentSizeCategory 这个属性必须设为 true。不然你手动缩放了一次,用户再改字体大小,你的控件不会自动更新。

小技巧: 如果你用的是自定义字体,UIFontMetrics 同样支持。只需要传入你的 UIFont 实例即可。

4.2 preferredContentSizeCategory:监听字体变化

有时候我们需要知道用户当前选择了哪个字体大小类别。比如,当字体特别大时,你可能想调整布局——从水平排列改成垂直排列。

preferredContentSizeCategory 就是干这个的。它是一个 UIContentSizeCategory 类型的值,从 iOS 7 就有了。

// 获取当前字体大小类别
let currentCategory = UIApplication.shared.preferredContentSizeCategory

switch currentCategory {
case .extraSmall:
    print("极小字体")
case .large:
    print("默认大小")
case .extraExtraExtraLarge:
    print("极大字体")
    // 这里可以调整布局
default:
    break
}

我记得有一次,一个用户反馈说在超大字体模式下,我们的按钮文字被截断了。排查后发现,是因为我们没有监听字体变化事件。

正确的做法是注册通知:

NotificationCenter.default.addObserver(
    self,
    selector: #selector(contentSizeCategoryDidChange),
    name: UIContentSizeCategory.didChangeNotification,
    object: nil
)

@objc func contentSizeCategoryDidChange() {
    // 更新所有依赖字体大小的控件
    updateFonts()
    // 必要时调整布局
    updateLayout()
}

注意: 不要忘记在 deinit 中移除观察者。虽然 iOS 9 以后系统会自动处理,但手动移除是个好习惯。

4.3 自定义字体适配:别让设计稿白费

很多设计师喜欢用自定义字体,觉得系统字体太普通。但自定义字体有个坑:它不会自动支持动态类型。

说白了,你用了自定义字体,就得自己处理缩放逻辑。

我建议的做法是封装一个工具方法:

extension UIFont {
    static func customScaledFont(name: String, textStyle: UIFont.TextStyle) -> UIFont {
        // 获取系统为该文本样式建议的字体大小
        let systemFont = UIFont.preferredFont(forTextStyle: textStyle)
        let pointSize = systemFont.pointSize
        
        // 创建自定义字体
        guard let customFont = UIFont(name: name, size: pointSize) else {
            return systemFont
        }
        
        // 使用 UIFontMetrics 进行缩放
        let metrics = UIFontMetrics(forTextStyle: textStyle)
        return metrics.scaledFont(for: customFont)
    }
}

这样,你只需要调用 UIFont.customScaledFont(name: "YourFontName", textStyle: .body) 就能得到一个支持动态类型的自定义字体。

避坑指南: 我曾经在项目中直接用自定义字体的固定字号,结果在 iPad 上被用户骂惨了。因为 iPad 的默认字体大小和 iPhone 不一样,固定字号在 iPad 上显得特别小。从那以后,我再也不敢忽略 UIFontMetrics 了。

4.4 实战:一个完整的适配示例

我们来看一个完整的例子。假设你有一个标题和一段正文,都用了自定义字体:

class DynamicTypeViewController: UIViewController {
    
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var bodyLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupDynamicType()
        
        // 监听字体变化
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(contentSizeDidChange),
            name: UIContentSizeCategory.didChangeNotification,
            object: nil
        )
    }
    
    func setupDynamicType() {
        // 标题使用大标题样式
        titleLabel.font = UIFont.customScaledFont(
            name: "YourTitleFont",
            textStyle: .largeTitle
        )
        titleLabel.adjustsFontForContentSizeCategory = true
        
        // 正文使用正文样式
        bodyLabel.font = UIFont.customScaledFont(
            name: "YourBodyFont",
            textStyle: .body
        )
        bodyLabel.adjustsFontForContentSizeCategory = true
    }
    
    @objc func contentSizeDidChange() {
        // 字体变化时,UILabel 会自动更新
        // 但如果你有自定义布局,需要在这里调整
        adjustLayoutForCurrentCategory()
    }
    
    func adjustLayoutForCurrentCategory() {
        let category = UIApplication.shared.preferredContentSizeCategory
        
        if category >= .accessibilityMedium {
            // 当字体达到无障碍大小时,改为垂直布局
            // 比如把图片和文字从水平排列改成垂直排列
        }
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

4.5 常见问题与解决方案

问题 原因 解决方案
字体不随系统变化 忘了设置 adjustsFontForContentSizeCategory 设置为 true
自定义字体缩放后模糊 字体文件本身不支持缩放 使用可变字体(Variable Font)或换用系统字体
布局在超大字体下错乱 没有根据字体大小调整约束 使用 UIStackView 或动态约束
UITextView 不支持动态类型 默认不开启 设置 adjustsFontForContentSizeCategory = true

我的经验: 在 Xcode 的 Interface Builder 中,你可以直接预览不同字体大小下的效果。在 Assistant Editor 中,选择 Preview,然后点击左下角的「+」添加不同的字体大小预览。这样不用跑模拟器就能看到效果,省时省力。

动态类型这件事,说白了就是「尊重用户的选择」。用户觉得字体小,你就给他放大;用户觉得字体大,你就给他缩小。别替用户做决定。

嗯,做好动态类型适配,你的 App 在无障碍方面就已经及格了。下一章我们会聊聊 VoiceOver 的支持,那才是真正的进阶内容。