Keehun Nam

Senior Systems Engineer at Cloudflare

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

A gif demonstrating that Attributed Strings resize properly on iOS 12.
UILabel on iOS 11 correctly responds to adjustsFontForContentSizeCategory.

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

A gif demonstrating that Attributed Strings does not resize properly on iOS 10.
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.")
}
}
(Note that you will need to pull the 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
}
A gif demonstrating that Attributed Strings now resize properly on iOS 10.
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!

img

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.