Practical Dynamic Type: Part 2, Testing
Last year, Livefront’s software developer Chris Sessions wrote a helpful article about practical usage of Dynamic Type. This short follow-up addresses one primary concern: Unit Testing.
Since adjustsFontForContentSizeCategory
cannot be unit tested, let’s make our own!
Here is an example of a view that incorporates Dynamic Type features using Chris’s FontMetrics
wrapper from Part 1:
import Foundation | |
import UIKit | |
// MARK: - Hal9000 | |
class Hal9000: UIView { | |
/// A `UIFont` that will be used as the typeface of Hal9000's messages | |
let staticFont: UIFont = UIFont(name: "GillSans-Light", size: 36.0)! | |
/// A `UILabel` that will render Hal9000's messages | |
lazy var dialog: UILabel = { | |
let label = UILabel() | |
/// FontMetrics will make that font scale based on system settings | |
label.font = FontMetrics().scaledFont(for: staticFont) | |
label.text = "I'm sorry, Dave. I'm afraid I can't do that." | |
label.adjustsFontForContentSizeCategory = true | |
label.translatesAutoresizingMaskIntoConstraints = false | |
label.numberOfLines = 0 | |
return label | |
}() | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
/// Add `dialog` to Hal9000's view | |
addSubview(dialog) | |
NSLayoutConstraint.activate([ | |
dialog.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), | |
dialog.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), | |
dialog.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), | |
dialog.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), | |
]) | |
} | |
/// Although "required," we don't have to implement init(coder:) for now | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) not implemented for this article.") | |
} | |
} |
That is not unit–testable. What are we going to do about it?
The reason that we cannot unit-test whether or not the label responds to Dynamic Type is that it is not possible to write to the system’s Dynamic Type setting: UIScreen.main.traitCollection.preferredContentSizeCategory
. It is a read-only source of the current system-wide setting. One possible solution (and the one we happen to use at Livefront) is to disregard the system’s preferredContentSizeCategory
and use our own “source of truth” for the UIContentSizeCategory
. This works because of two key reasons:
- Our
FontMetrics
can observe the system’s Dynamic Type size change and read the new size. - Crucially, we can also ask
UIFont
to use our owncontentSize
instead of the system’s when calling itspreferredFont()
With those two key components, we can implement our own Dynamic Type but with our own writable preferredContentSizeCategory
.
So what does that actually look like?
Before explaining what the changes to FontMetrics
are, here is the updated class:
import UIKit | |
/// Delegate protocol for `FontMetrics` to respond to updates in UIContentSizeCategory. | |
public protocol UIContentSizeCategoryDelegate: class { | |
func contentSizeCategoryChanged() | |
} | |
/// Notification to use instead of UIContentSizeCategoryDidChange. | |
extension NSNotification.Name { | |
public static let FontMetricsContentSizeCategoryDidChange: NSNotification.Name = Notification.Name("FontMetricsContentSizeCategoryDidChange") | |
} | |
/// A `UIFontMetrics` wrapper class, allowing iOS 11 devices to take advantage of `UIFontMetrics` | |
/// scaling, while earlier iOS versions fall back on a scale calculation. Optionally assignable | |
/// sizeCategory: UIContentSizeCategory to override the system Accessibility settings for unit | |
/// testing. Manually adjusting the accessibility dynamic text slider will reset the manually | |
/// assigned sizeCategory value. | |
/// | |
public class FontMetrics { | |
/// By declaring `default` as a static let, we will be able to use a globally consistent instance of | |
/// `FontMetrics`. It behaves similarly to `NotificationCenter.default` or `UIDevice.current`. | |
public static let `default` = FontMetrics() | |
/// A delegate which will handle the change of the system's actual setting of Dynamic Type size. | |
public weak var delegate: UIContentSizeCategoryDelegate? | |
/// Initializer will take the system's `preferredContentSizeCategory` at startup if it is not provided. | |
/// Therefore, `FontMetrics.default` will be initialized with the system's current Dynamic Type setting. | |
public init(withSizeCategory sizeCategory: UIContentSizeCategory = UIScreen.main.traitCollection.preferredContentSizeCategory) { | |
delegate = self | |
NotificationCenter.default.addObserver(self, selector: #selector(uiContentSizeCategoryChanged), | |
name: .UIContentSizeCategoryDidChange, | |
object: nil) | |
self.sizeCategory = sizeCategory | |
} | |
/// Public variable to hold preferred content size. Assigning a new value will post the | |
/// FontMetricsContentSizeCategoryDidChange notification. | |
public var sizeCategory: UIContentSizeCategory! = .small { | |
didSet { | |
NotificationCenter.default.post(Notification(name: .FontMetricsContentSizeCategoryDidChange)) | |
} | |
} | |
/// By using a delegate instead of directly changing the `sizeCategory` here, a user of `FontMetrics` can later | |
/// define custom behavior when the system's Dynamic Type setting changes instead of being forced to change | |
/// the class itself. | |
@objc private func uiContentSizeCategoryChanged() { | |
delegate?.contentSizeCategoryChanged() | |
} | |
/// A scale value based on the current device text size setting. With the device using the | |
/// default Large setting, `scaler` will be `1.0`. Only used when `UIFontMetrics` is not | |
/// available. | |
private var scaler: CGFloat { | |
return UIFont.preferredFont(forTextStyle: .body, | |
compatibleWith: UITraitCollection(preferredContentSizeCategory: self.sizeCategory)).pointSize / 17.0 | |
} | |
/// Returns a version of the specified font that adopts the current font metrics. | |
/// | |
/// - Parameter font: A font at its default point size. | |
/// - Returns: The font at its scaled point size. | |
/// | |
public func scaledFont(for font: UIFont) -> UIFont { | |
if #available(iOS 11.0, *) { | |
return UIFontMetrics.default.scaledFont(for: font, compatibleWith: UITraitCollection(preferredContentSizeCategory: self.sizeCategory)) | |
} else { | |
return font.withSize(scaler * font.pointSize) | |
} | |
} | |
/// Returns a version of the specified font that adopts the current font metrics and is | |
/// constrained to the specified maximum size. | |
/// | |
/// - Parameters: | |
/// - font: A font at its default point size. | |
/// - maximumPointSize: The maximum point size to scale up to. | |
/// - Returns: The font at its constrained scaled point size. | |
/// | |
public func scaledFont(for font: UIFont, maximumPointSize: CGFloat) -> UIFont { | |
if #available(iOS 11.0, *) { | |
return UIFontMetrics.default.scaledFont(for: font, | |
maximumPointSize: maximumPointSize, | |
compatibleWith: UITraitCollection(preferredContentSizeCategory: self.sizeCategory)) | |
} else { | |
return font.withSize(min(scaler * font.pointSize, maximumPointSize)) | |
} | |
} | |
/// Scales an arbitrary layout value based on the current Dynamic Type settings. | |
/// | |
/// - Parameter value: A default size value. | |
/// - Returns: The value scaled based on current Dynamic Type settings. | |
/// | |
public func scaledValue(for value: CGFloat) -> CGFloat { | |
if #available(iOS 11.0, *) { | |
return UIFontMetrics.default.scaledValue(for: value, compatibleWith: UITraitCollection(preferredContentSizeCategory: self.sizeCategory)) | |
} else { | |
return scaler * value | |
} | |
} | |
} | |
/// FontMetrics is its own delegate right now, but it can also be assigned another implementation of | |
/// UIContentSizeCategoryDelegate. | |
extension FontMetrics: UIContentSizeCategoryDelegate { | |
public func contentSizeCategoryChanged() { | |
/// Overwrite sizeCategory with the system's new setting | |
self.sizeCategory = UIScreen.main.traitCollection.preferredContentSizeCategory | |
} | |
} |
Here is how I arrived at this new code:
First, FontMetrics
needs to use its own source of UIContentSizeCategory
and not the system’s preferredContentSizeCategory
. This is reflected in lines 41–45. Notice that assigning a new value posts a notification for all observers of FontMetric’s ContentSizeCategoryDidChange
.
Secondly, this is not demonstrated in this article, but I changed over any references in my app’s code of the system-provided UIContentSizeCategoryDidChange
notification to our own: FontMetricsContentSizeCategoryDidChange
Third, in case that the user changes his or her Dynamic Type setting on their device while the app is running, FontMetrics
observes for those changes and then changes its own sizeCategory
to match the new system setting for a seamless experience (which then posts a notification). We never want the user to notice that anything is different than how they expect system settings to work.
Fourth, now that sizeCategory
behaves exactly as we would like, we request UIFontMetrics.default.scaledFont()
to use our ContentSizeCategory
instead of the system’s own. This can be seen in lines 59, 69, 87, and 100.
Fifth, although this wasn’t strictly necessary, I created a static constant called default
so that calling FontMetrics.default
would always return the same instance. This is akin to NotificationCenter.default
or UIDevice.current
. It just helps make users of FontMetrics
keep their code clean-looking and bug-free (by avoiding accidentally using a different instance of FontMetrics
. This also meant that any references to FontMetrics()
in my app was replaced with FontMetrics.default
.
Basically, we’ve recreated the mechanism behind UIContentSizeCategoryDidChange
and made our own writable version of preferredContentSizeCategory
.
To take advantage of our new FontMetrics
wrapper, we must update Hal9000
as well!
import Foundation | |
import UIKit | |
class Hal9000: UIView { | |
let font: UIFont = UIFont(name: "GillSans-Light", size: 36.0)! | |
lazy var dialog: UILabel = { | |
let label = UILabel() | |
label.font = FontMetrics.default.scaledFont(for: self.font) | |
label.text = "I'm sorry, Dave. I'm afraid I can't do that." | |
label.translatesAutoresizingMaskIntoConstraints = false | |
label.numberOfLines = 0 | |
return label | |
}() | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
addSubview(dialog) | |
NSLayoutConstraint.activate([ | |
dialog.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), | |
dialog.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), | |
dialog.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), | |
dialog.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), | |
]) | |
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryChanged), | |
name: Notification.Name.FontMetricsContentSizeCategoryDidChange, | |
object: nil) | |
} | |
@objc func contentSizeCategoryChanged() { | |
dialog.font = FontMetrics.default.scaledFont(for: self.font) | |
} | |
required init?(coder aDecoder: NSCoder) { | |
fatalError("init(coder:) not implemented for this article.") | |
} | |
} |
adjustsFontForContentSizeCategory = true
anymore, but now it’s unit testable!Trade Offs
There is an important trade off that was made here: we can’t keep using the convenient property called adjustsFontForContentSizeCategory = true
as there is no way to ask it to observe our own sizeCategory
. Plus, we want to have more control over our text than adjustsFontForContentSizeCategory
offers. At Livefront, we’ve rolled our own UILabel
replacement that is even more convenient to use than setting adjustsFontForContentSizeCategory
to true
. As a side-effect, we rarely have to actually observe the FontMetricsContentSizeCategoryDidChange
notification because our custom UILabel
(and a plethora of other controls) is already doing that for us behind the scenes.
Onto unit tests!
Finally, the part we’ve been waiting for. Let’s test Hal9000
’s Dynamic Type features!
import XCTest | |
@testable import PracticalDynamicTypePart2 | |
class PracticalDynamicTypePart2Tests: XCTestCase { | |
var subject: Hal9000! | |
override func setUp() { | |
super.setUp() | |
subject = Hal9000() | |
} | |
func testDynamicType() { | |
/// pointSize = 31 | |
FontMetrics.default.sizeCategory = .extraSmall | |
let smallSize = subject.dialog.font.pointSize | |
/// pointSize = 36 | |
FontMetrics.default.sizeCategory = .large | |
let defaultSize = subject.dialog.font.pointSize | |
/// pointSize = 101 | |
FontMetrics.default.sizeCategory = .accessibilityExtraExtraExtraLarge | |
let xxxlSize = subject.dialog.font.pointSize | |
XCTAssertGreaterThan(defaultSize, smallSize) | |
XCTAssertGreaterThan(xxxlSize, defaultSize) | |
} | |
} |
Note that while we’re measuring the same subject.dialog.font.pointSize
three times, we’ll get a different result as we’re changing FontMetric.default.sizeCategory
before each measurement!
With this power, there are many things we can choose to do with it. Here are just some ideas:
- Unit test Auto Layout with many different font sizes, especially complex layouts involving UIStackViews that changes axis according to its contents
- Write (and be able to test!) custom controls and views that implement Dynamic Type features
- Design unit-testable interfaces which reduce white spaces at higher levels of Dynamic Type size (e.g. a form with vertical white space between fields that get closer together as the text gets bigger instead of the fields getting further apart)
- Sleep better at night knowing that any regression of Dynamic Type features will be caught! 🎉

Hal9000
is passing!So… What’s next?
At Livefront, we’ve rolled unit-testable Dynamic Type and many other features into our own versions of UILabel
, UIButton
, and others. It has made building new interfaces so much faster and much more reliable, and we encourage you to have your own personalized toolbox that is right for you.
Here, you can download the Xcode project and run the tests yourself!
More Resources
- “Delivering an Exceptional Accessibility Experience” (WWDC 2018)
- “UIKit: Apps for Every Size and Shape” (WWDC 2018)
Thanks to Sean Berry, Collin Flynn, and Mike Bollinger for their editorial feedback!