Scaled Metric Surprises on iOS & iPadOS

UIKit’s UIFontMetrics.scaledValue(for:) and SwiftUI’s @ScaledMetric property wrapper offer third-party developers a public means to scale arbitrary design reference values up and down relative to dynamic type size changes. Please be aware, however, that scaled values are not scaled proportionally with the default point sizes of the related text style. They scale according to some other scaling function that differs considerably from the related text style. An example will help illustrate this. Consider the following code:

let metrics = UIFontMetrics(forTextStyle: .body)
let size = metrics.scaledValue(for: 17.0)

If the device is set to the .large dynamic type setting, the identity value is returned (size == 17.0). If you then downscale the device’s dynamic type size setting to .medium, one might expect the returned value to be 16.0. After all, the default system .body font size at .large is exactly 17.0, and the default .body font size at .medium is exactly 16.0. But instead, this is what happens:

// current dynamic type size setting is .medium...
let metrics = UIFontMetrics(forTextStyle: .body)
let size = metrics.scaledValue(for: 17.0)
// size == 16.333...

The divergence is even more pronounced the further up/down the dynamic type size range one goes:

The red text in each pairing the above is the system default .body font, and the black text is a system body font obtained using a ScaledMetric:

@ScaledMetric(relativeTo: .body) var scaled = 17.0
var body: some View {
    Text("Quick, brown fox.")
        .foregroundStyle(.red)
        .font(.body)
        .overlay {
            Text("Quick, brown fox.")
                .foregroundStyle(.black)
                .font(.system(size: scaled))
        }
}

So if you need to scale a bit of UI in exact proportion to the .body font size, avoid using ScaledMetric/UIFontMetrics scaling APIs and instead directly obtain the pointSize from a system body font and use that value instead:

UIFont.preferredFont(
    forTextStyle: .body,
    compatibleWith: traitCollection
).pointSize

(EDITED: An earlier version of this post included an erroneous assumption that has been living rent-free in my brain for years. Thanks to Matthias for the clarification.)

|  2 Mar 2024