Practical Dynamic Type Part 3: Attributed Strings

Ensuring that applications remain accessible for all is one of our highest priorities at Livefront, and we believe that a key component in achieving that goal is to flawlessly support Dynamic Type in all of our applications. Part 1 of our Practical Dynamic Type series focused on supporting iOS 10 and Part 2 focused on unit testing. This third installment will focus on Attributed Strings.
You can access the complete demo app for this article, here.
You may be asking: “Isn’t there already adjustsFontForContentSizeCategory
?” You’re right! Setting aside for a moment the fact that it is not unit testable (as discussed in Part 2), let’s see what happens when we assign an Attributed String to an UILabel
with adjustsFontForContentSizeCategory = true
.
On iOS 11 and above, everything works as expected

adjustsFontForContentSizeCategory
.But, on iOS 10, an AttributedString
in an UILabel
does not respond to adjustsFontForContentSizeCategory
.

UILabel
on iOS 10 does not respond to adjustsFontForContentSizeCategory
due to the lack of UIFontMetrics
on iOS 10.Let’s fix this
It’s clear from attempting to use adjustsFontForContentSizeCategory
on iOS 10 that we will have to manually handle updating the font size, just as we’ve done in Part 1 and Part 2.
First, let’s define a UIView
that hosts a UILabel
displaying multiple attributed strings:
import Foundation | |
import UIKit | |
// MARK: - AttributedStringView | |
class AttributedStringView: UIView { | |
/// `FontSetter` is a closure type used to recalculate the font size. | |
typealias FontSetter = () -> UIFont | |
/// A `UILabel` that will render the attributed string with Dynamic Type. | |
lazy var display: UILabel = { | |
let fontSetter1: FontSetter = { UIFont.customFont(weight: .bold, points: 18.0) } | |
let string1 = NSAttributedString( | |
string: "Jived fox nymph grabs quick waltz. ", | |
attributes: [.font: fontSetter1()] | |
) | |
let fontSetter2: FontSetter = { UIFont.customFont(weight: .light, points: 8.0) } | |
let string2 = NSAttributedString( | |
string: "Glib jocks quiz nymph to vex dwarf. ", | |
attributes: [.font: fontSetter2()] | |
) | |
let fontSetter3: FontSetter = { UIFont.customFont(NeutrafaceSlabTextFont.self, | |
weight: .demi, | |
points: 18.0, | |
maximumPointSize: 24.0) } | |
let string3 = NSAttributedString( | |
string: "Sphinx of black quartz, judge my vow. ", | |
attributes: [.font: fontSetter3()] | |
) | |
let fontSetter4: FontSetter = { UIFont.customFont(weight: .heavy, | |
points: 48.0) } | |
let string4 = NSAttributedString( | |
string: "How vexingly quick daft zebras jump!", | |
attributes: [.font: fontSetter4()] | |
) | |
var totalAttributedString = NSMutableAttributedString(attributedString: string1) | |
totalAttributedString.append(string2) | |
totalAttributedString.append(string3) | |
totalAttributedString.append(string4) | |
let label = UILabel(numberOfLines: 0) | |
label.attributedText = totalAttributedString | |
label.adjustsFontForContentSizeCategory = true | |
return label | |
}() | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
addSubview(display) | |
NSLayoutConstraint.activate([ | |
display.topAnchor.constraint(equalTo: topAnchor), | |
display.bottomAnchor.constraint(equalTo: bottomAnchor), | |
display.leadingAnchor.constraint(equalTo: leadingAnchor), | |
display.trailingAnchor.constraint(equalTo: trailingAnchor), | |
]) | |
} | |
/// 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.") | |
} | |
} |
FontMetrics
class from Part 2.)Second, let’s hook into FontMetric
’s notification FontMetricsContentSizeCategoryDidChange
:
// ... | |
label.adjustsFontForContentSizeCategory = true | |
NotificationCenter.default.addObserver(self, | |
selector: #selector (uiContentSizeCategoryChanged), | |
name: .FontMetricsContentSizeCategoryDidChange, | |
object: nil) | |
// ... | |
// ... | |
// MARK: Event Handlers | |
/// Update the sizes of all contained fonts whenever the Dynamic Type size changes. | |
@objc func uiContentSizeCategoryChanged() { | |
/// Handle contentSizeCategoryDidChange, here. | |
} | |
// ... |
What’s next?
Whereas we’ve been able to set the font size property on the entire UILabel
inside the uiContentSizeCategoryChanged()
before, now we will have to set the type size on each individual formatted segment within the AttributedString
as each formatting segment may have different type size. Therefore, in response to ContentSizeCategoryDidChange
, we will have to enumerate through each formatting segment of the overall AttributedString
and update each formatted segments' type sizes.
Let’s first enumerate over the AttributeString
to access each of the four sub-attributed strings we created above:
@objc func uiContentSizeCategoryChanged() { | |
/// Only continue if the `UILabel` has non-nil attributed text. | |
guard let attributedString = display.attributedText else { | |
return | |
} | |
/// Get the NSRange of the plaintext string. | |
let fullTextRange = NSRange(location: 0, length: attributedString.string.count) | |
/// Enumerate over each of the sub attributed strings. | |
mutableText.enumerateAttributes(in: fullTextRange, options: []) { attributes, range, stop in | |
print(attributes[.font]) | |
} | |
/// Optional(<UICTFont: 0x7f8a26429720> font-family: ".SFUIText-Bold"; font-weight: bold; font-style: normal; font-size: 18.00pt) | |
/// Optional(<UICTFont: 0x7f8a26403c80> font-family: ".SFUIText-Light"; font-weight: normal; font-style: normal; font-size: 8.00pt) | |
/// Optional(<UICTFont: 0x7f8a26429c30> font-family: "NeutrafaceSlabText-Demi"; font-weight: bold; font-style: normal; font-size: 18.00pt) | |
/// Optional(<UICTFont: 0x7f8a2642aa30> font-family: ".SFUIText-Heavy"; font-weight: bold; font-style: normal; font-size: 48.00pt) | |
} |
In order to calculate the new type sizes, we will need to have separate FontSetter
closures that the enumeration block in uiContentSizeCategoryChanged()
can use on each attributed substring (see lines 11, 20, 31, and 40
below). This allows the individual attributed substring to get its type
size regenerated with its own FontSetter
.
/// `FontSetterAttributeKey` is used to keep track of the custom Font assigned to a range within | |
/// an `AttributedString`. | |
static let FontSetterAttributeKey = NSAttributedString.Key.init("FontSetterAttributeKey") | |
/// A `UILabel` that will render the attributed string with Dynamic Type. | |
lazy var display: UILabel = { | |
let fontSetter1: FontSetter = { UIFont.customFont(weight: .bold, points: 18.0) } | |
let string1 = NSAttributedString( | |
string: "Jived fox nymph grabs quick waltz. ", | |
attributes: [AttributedStringView.FontSetterAttributeKey : fontSetter1, /// <= Attaching the FontSetter for this string | |
.font: fontSetter1() | |
] | |
) | |
let fontSetter2: FontSetter = { UIFont.customFont(weight: .light, points: 8.0) } | |
let string2 = NSAttributedString( | |
string: "Glib jocks quiz nymph to vex dwarf. ", | |
attributes: [AttributedStringView.FontSetterAttributeKey : fontSetter2, /// <= Attaching the FontSetter for this string | |
.font: fontSetter2() | |
] | |
) | |
let fontSetter3: FontSetter = { UIFont.customFont(NeutrafaceSlabTextFont.self, | |
weight: .demi, | |
points: 18.0, | |
maximumPointSize: 24.0) } | |
let string3 = NSAttributedString( | |
string: "Sphinx of black quartz, judge my vow. ", | |
attributes: [AttributedStringView.FontSetterAttributeKey : fontSetter3, /// <= Attaching the FontSetter for this string | |
.font: fontSetter3() | |
] | |
) | |
let fontSetter4: FontSetter = { UIFont.customFont(weight: .heavy, | |
points: 48.0) } | |
let string4 = NSAttributedString( | |
string: "How vexingly quick daft zebras jump!", | |
attributes: [AttributedStringView.FontSetterAttributeKey : fontSetter4, /// <= Attaching the FontSetter for this string | |
.font: fontSetter4() | |
] | |
) | |
/// ... | |
}() |
And now, all we have to do is get a new UIFont
with updated sizes by re-executing the FontSetter
closure for each substring and then assigning those new fonts to the preexisting string:
// MARK: Event Handlers | |
/// Update the sizes of all contained fonts whenever the Dynamic Type size changes. | |
@objc func uiContentSizeCategoryChanged() { | |
/// Only continue if the `UILabel` has non-nil attributed text. | |
guard let attributedString = display.attributedText else { | |
return | |
} | |
/// Get the preexisting formatted string as a `NSMutableAttributedString` since we will | |
/// modify the attributes directly in the `enumerateAttributes` loop. | |
let mutableText = NSMutableAttributedString(attributedString: attributedString) | |
/// Get the full range of the attributed text. | |
let fullTextRange = NSRange(location: 0, length: mutableText.string.count) | |
/// Enumerate over the full range of the formatted string and modify its attributes. | |
mutableText.enumerateAttributes(in: fullTextRange, options: []) { attributes, range, stop in | |
/// Get the `FontSetter` for this attributed substring | |
guard let currentFontSetter = attributes[AttributedStringView.FontSetterAttributeKey] | |
as? FontSetter else { | |
fatalError("Could not read the FontSetter being enumerated over") | |
} | |
/// Execute the `FontSetter` block to regenerate the appropriate type size. | |
let determinedFont = currentFontSetter() | |
/// Check the new type size | |
print("New font size (\(determinedFont.pointSize.description)) in range \(range)") | |
/// Update the type size of the current substring with the new, appropriate type size. | |
mutableText.addAttributes([.font : determinedFont], range: range) | |
} | |
/// Assign the new, updated formatted string to the `UILabel`. | |
display.attributedText = mutableText | |
} |

UILabel
on iOS 10 now responds to Dynamic Type.Unit Testing
Using this technique, it is possible to unit test the Dynamic Type pointSize
as well!
import XCTest | |
@testable import AttributedDynamicLabelDemo | |
class AttributedDynamicLabelDemoTests: XCTestCase { | |
var subject: AttributedStringView! | |
override func setUp() { | |
super.setUp() | |
subject = AttributedStringView() | |
} | |
func testAttributedStringDynamicType() { | |
var extraSmallSizes: [CGFloat] = [] | |
var largeSizes: [CGFloat] = [] | |
var biggestSizes: [CGFloat] = [] | |
let fullTextRange = NSRange(location: 0, | |
length: subject.display.attributedText!.string.count) | |
/// Test extraSmall content size | |
/// String 1: 14.8 | |
/// String 2: 6.6 | |
/// String 3: 14.8 | |
/// String 4: 39.5 | |
FontMetrics.default.sizeCategory = .extraSmall | |
NSMutableAttributedString(attributedString: subject.display.attributedText!) | |
.enumerateAttributes(in: fullTextRange, | |
options: []) { attributes, range, stop in | |
let gottenFont = attributes[.font] as! UIFont | |
extraSmallSizes.append(gottenFont.pointSize) | |
} | |
/// Test large content size | |
/// String 1: 18.0 | |
/// String 2: 8.0 | |
/// String 3: 18.0 | |
/// String 4: 48.0 | |
FontMetrics.default.sizeCategory = .large | |
NSMutableAttributedString(attributedString: subject.display.attributedText!) | |
.enumerateAttributes(in: fullTextRange, | |
options: []) { attributes, range, stop in | |
let gottenFont = attributes[.font] as! UIFont | |
largeSizes.append(gottenFont.pointSize) | |
} | |
/// Test accessibilityExtraExtraExtraLarge content size | |
/// String 1: 56.1 | |
/// String 2: 24.9 | |
/// String 3: 24.0 | |
/// String 4: 149.6 | |
FontMetrics.default.sizeCategory = .accessibilityExtraExtraExtraLarge | |
NSMutableAttributedString(attributedString: subject.display.attributedText!) | |
.enumerateAttributes(in: fullTextRange, | |
options: []) { attributes, range, stop in | |
let gottenFont = attributes[.font] as! UIFont | |
biggestSizes.append(gottenFont.pointSize) | |
} | |
for (index, smallSize) in extraSmallSizes.enumerated() { | |
XCTAssert(smallSize < largeSizes[index]) | |
XCTAssert(largeSizes[index] < biggestSizes[index]) | |
} | |
} | |
} |
Which, as expected, passes!
Now, you can support Dynamic Type with AttributedString
on pre-iOS 11 devices, and unit test it, too! 🍰
You can access the complete demo app for this article here. (Please note that Neutraface Slab Text font shown throughout the article is not included, and it has been replaced with Courier.)
Keehun finds ways to unit test everything at Livefront.
Thanks to Collin Flynn , Chris Sessions , Sean Berry , and Mike Bollinger for their editorial feedback.