跳转至

11 Dynamic Type

Dynamic Type is an iOS feature that enables app content to scale according to the user’s font size preference. For the millions of people without perfect vision, having support for Dynamic Type makes all the difference. Without it, your app’s user experience will likely suffer.

Visual impairment is one of the world’s leading disabilities. Yet, most apps on the App Store fail to support Dynamic Type. But there’s no reason your apps have to fall into that category.

In this chapter, you’ll learn about:

  • Reasons for supporting Dynamic Type.
  • Supporting Dynamic Type on an existing app.
  • Preferred font sizes.
  • Growing and shrinking text.
  • Supporting Dynamic Type using custom fonts.
  • Growing and shrinking non-text UI elements.
  • Managing layout changes based on font preferences.

By the end of this chapter, you’ll know how to add support for Dynamic Type in your iOS apps.

Why Dynamic Type?

Dynamic Type makes your app usable by a broader audience, regardless of age or eyesight. In this section, you’ll learn about five reasons to support Dynamic Type.

Readability

Having a readable app is important. Supporting larger font sizes may not make a huge difference to someone with near-perfect vision, but many do not fall into that category. Look at the following image:

img

The blur effect represents how someone with vision problems may see the text. Although the same amount of blur exists on both images, the image on the left is almost unreadable and asks for a tremendous cognitive load to work out the letters and words. If your app is difficult to use, or even unusable, because your users can read your interface, you’ll lose sales.

Temporary or chronic injuries

You may think that users seldom need to change their text size preferences once they find a size that works. However, sometimes a person’s eyesight deteriorates. Whether the deterioration is temporary or chronic, Dynamic Type can help ease the burden with large font size.

Competition differentiation

Building apps accessible for everyone isn’t always easy, but an accessible app differentiates itself from non-accessible apps. A comprehensive feature-packed app isn’t worth much to users if they can’t see what they’re doing. When it comes to downloading apps, users will almost always choose the one that brings more value and is easier to use, so make sure your app is beautiful at all text sizes.

High user retention rate over time

If your app accommodates all users regardless of how well they see, users won’t need to go looking for an alternate solution should they ever need to increase the app’s font size. Consequently, the app retention rate goes up over time.

Monetary gains

You can gain more users by ensuring that your app works equally great for all who use it. So, don’t regularly tax your user’s brainpower, which can make using your app exhausting. Instead, let users access their font preferences so they can use your app with relative ease. This makes for happy users, and happy users can’t wait to come back for more.

Setting the preferred font size

Before you begin, the first step is to change your device’s preferred font size. On iOS 13, follow these steps:

  1. Open the Settings app.
  2. Tap Accessibility ▸ Display & Text Size ▸ Larger Text.
  3. Adjust the slider left or right to increase or decrease the preferred font size.

img

You can enable larger text sizes by toggling Larger Accessibility Sizes.

img

Enabling Larger Accessibility Sizes gives you five additional larger text font size options.

Now that you’ve set your preferred font size, you’re ready to implement Dynamic Type.

Making labels support Dynamic Type

Dynamic Type almost works right out of the box. With Storyboards and text styles, you can size a label’s text.

Open DynamicType.xcodeproj in the starter folder. Build and run.

img

In the Xcode menu bar, select Xcode ▸ Open Developer Tool ▸ Accessibility Inspector.

img

This shows the Accessibility Inspector.

img

There are a lot of benefits to using the Accessibility Inspector, such as setting the preferred font size. Setting the system’s preferred font size here means you don’t have to switch back and forth between the current app and the Settings app.

First, in the top-left corner, select the iOS simulator.

img

To set the system’s preferred font size, click the Settings button.

img

You can now use the Font size slider to scale the font size up or down.

Drag the Font size slider all the way to the right.

img

Notice how the app’s labels look the same as they did before. Currently, the text sizes don’t scale up or down when you change the values on the Font size slider, so you’ll need to fix that.

Open Main.storyboard. In the View Controller Scene, select the table view cell’s header label and open the Attributes inspector.

Change the label’s font to Title 1.

img

Build and run.

img

You’ll see that the title label’s font gets larger and smaller as you increase and decrease the font size preference. Your labels utilize the system’s default font: Apple’s SF fonts. Labels using Apple’s SF fonts can easily support Dynamic Type.

On an iOS 13 device, a label with text style font increases and decreases dynamically inside a UITableViewCell at runtime without any further configuration. On a pre-iOS 13 device, you need to configure the label further to achieve the same effect. If you don’t already have an iOS 12 simulator, you need to download one in Xcode since you’ll need to test this feature to make sure it works.

Next to the scheme menu in the toolbar, click the run destination menu. Select Download Simulators.

img

Click the download button next to iOS 12.4 Simulator.

img

Grant Xcode permission to install the simulator on your Mac when prompted. You’ll see a checkmark when the simulator is installed.

img

Close the Components window. Click the run destination menu. You’ll see simulators denoted with different iOS versions.

img

Now, you should be able to test your project against iOS 12 and 13 simulators.

Open Main.storyboard. In the header label’s Attributes inspector, enable the Dynamic Type option by checking the Automatically Adjusts Font checkbox.

img

Right below the header label is the description label. You also want the description label’s font to scale automatically, so do the following:

  1. Set description label’s font to Body text style.
  2. Enable the description label’s Dynamic Type option.

Build and run.

img

Both the header and description labels now scale according to the user’s font size preference. Plus, they’re able to update at runtime dynamically. In other words, every time your users change their font size preference, they don’t need to kill and reopen your app to see label texts scale up/down.

If you place a UILabel inside a UITableViewCell on iOS 13 devices, fonts with a system text style will scale at runtime without needing to enable Automatically Adjusts Font. Pre-iOS 13 devices, on the other hand, will need to enable Automatically Adjusts Font for runtime text-scaling regardless. This is an exception to take note of.

Different iOS versions can handle Automatically Adjusts Font differently. The rule of thumb is to enable Automatically Adjusts Font for runtime text-scaling with backward compatibility support.

Making custom fonts support Dynamic Type

Making custom fonts dynamic requires a few more steps. At the time of writing this chapter, Interface Builder does not support setting up dynamic custom fonts. Consequently, you need to set a dynamic custom font using code.

Open TableViewCell.swift, and add the following value types above the TableViewCelldeclaration:

fileprivate let customFontSizeDictionary: 
  [UIFont.TextStyle: CGFloat] =
  [.largeTitle: 34,
   .title1: 28,
   .title2: 22,
   .title3: 20,
   .headline: 17,
   .body: 17,
   .callout: 16,
   .subheadline: 15,
   .footnote: 13,
   .caption1: 12,
   .caption2: 11]

enum FontWeight: String {
  case regular = "Regular"
  case bold = "Bold"
}

enum CustomFont: String {
  case avenirNext = "AvenirNext"
  case openDyslexic = "OpenDyslexic"
}

Here, you create enums to minimize the chances of mistyping a font name or weight.

Now, add the following extension to the end of TableViewCell.swift:

extension UILabel {
  func set(
    customFont: CustomFont,
    fontWeight: FontWeight,
    textStyle: UIFont.TextStyle = .body
  ) {
    // 1
    let name = "\(customFont.rawValue)-\(fontWeight.rawValue)"
    guard let size = customFontSizeDictionary[textStyle]
      else { return }
    // 2
    guard let font = UIFont(name: name, size: size)
      else { fatalError("Retrieve \(name) with error.") }
    // 3
    let fontMetrics = UIFontMetrics(forTextStyle: textStyle)
    self.font = fontMetrics.scaledFont(for: font)
    // 4
    adjustsFontForContentSizeCategory = true
  }
}

With this code, you:

  1. Generate the font name. Then, decide the font size multiplier based on textStyle.
  2. Safely unwrap the custom font. This ensures that the custom font is accessible to the app. If it isn’t, the app throws a fatal error describing the missing custom font.
  3. Set the label’s font to an automatic scaling font from the custom font. The automatic scaling font generates from UIFontMetrics.
  4. Have the label automatically update the font according to the device’s content size category, instead of handling font changes manually with UIContentSizeCategoryDidChangeNotification.

Add the following code to TableViewCell:

private func setupDynamicCustomFont(_ customFont: CustomFont) {
  headerLabel.set(
    customFont: customFont,
    fontWeight: .regular,
    textStyle: .title1)
  descriptionLabel.set(
    customFont: customFont,
    fontWeight: .regular)
}

Here, you set the header and description label fonts to scale using the extension method you created earlier. You also pass in font customization parameters.

Finally, add the following code to the end of configureCell(text:index:setCustomFont):

setupDynamicCustomFont(.openDyslexic)

This code puts dynamic custom fonts into effect for TableViewCell’s labels.

Build and run.

At the largest accessibility font preference, you’ll see the following:

img

Nice, you’ve created a dynamically scaling custom font.

Managing layouts based on the text size

In addition to having readable text, you need your app to look good when users change their text size. In code, you’ll need to identify the user’s preferred font size to make that happen. The first way to identify the user’s preferred font size is to get the view’s trait collection.

Typically, you’d override traitCollectionDidChange(_:) to make further layout changes based on the user’s text size preference — even on a UITableViewCell. However with iOS 13, traitCollectionDidChange(_:) doesn’t get called in a UITableViewCell. For this reason, and to ensure backward compatibility, you need to take a different approach.

Add the following code to TableViewCell:

func updateTraitCollectionLayout() {
  // 1
  let preferredContentSizeCategory =
    traitCollection.preferredContentSizeCategory
  // 2
  let scaledValue = UIFontMetrics.default
    .scaledValue(for: profileImageViewWidth)
  profileImageViewWidthConstraint.constant = scaledValue
  // 3
  topStackView.axis =
    preferredContentSizeCategory.isAccessibilityCategory
    ? .vertical : .horizontal
  // 4
  profileImageStackView.isHidden = preferredContentSizeCategory
    == .accessibilityExtraExtraExtraLarge
}

And, add a call to your new method in configureCell(text:index:setCustomFont:)before the guard statement:

updateTraitCollectionLayout()

So far, you’ve done the following:

  1. Retrieved the current font size preference.
  2. Scaled the profile image view width using UIFontMetrics’s default font, bodytext style.
  3. When the user’s preferred font size is in the accessibility sizes range, set the axis orientation of topStackView to vertical. Otherwise, made the axis orientation horizontal.
  4. For the extra, extra, extra-large font size preference, you hid the profile image view.

In ViewController.swift, add the following code after viewDidAppear(_:):

override func traitCollectionDidChange(
  _ previousTraitCollection: UITraitCollection?
) {
  super.traitCollectionDidChange(previousTraitCollection)
  guard traitCollection.preferredContentSizeCategory !=
    previousTraitCollection?.preferredContentSizeCategory
    else { return }
  let indexPaths = (0..<messages.endIndex)
    .map { IndexPath(row: $0, section: 0) }
  DispatchQueue.main.async { [weak self] in
    self?.tableView.reloadRows(at: indexPaths, with: .none)
  }
}

When the user changes the preferred font size, you use ViewController.traitCollectionDidChange(_:) to update the table view cells.

Build and run.

img

Now, you’ll see the app’s layout changes based on the user’s preferred font size.

Supporting Dynamic Type with UITableView

Dynamic Type works right out of the box with the standard UITableView. By default, the header and cell height dynamically scale to fit the header and cell content.

After dragging a UITableView into the Interface Builder, the default Size inspectorsettings are as follows:

img

The standard UITableView enables self-sizing table view cells. To enable self-sizing headers and footers, you need to set their respective height and height estimate to automatic. As long as you pin the views to the edges of your UITableViewCell’s content view, your table view cell height will size accordingly. You can read about the details behind self-sizing UITableViewCell in Chapter 6, “Self-Sizing Views”.

Supporting Dynamic Type with UICollectionView

The standard UICollectionView requires more work to support self-sizing cells. Specifically, when you use a collection view for non-line based layouts, you’ll need to set up a custom UICollectionViewLayout.

To achieve self-sizing collection view cells with a custom UICollectionViewLayout, here’s what you’ll do:

  1. Handle the collection view cell layout attributes caching.
  2. Set the collection view content size.
  3. Implement layout invalidation logic.
  4. Return the layout attributes at each cell item’s index path.
  5. Load the layout attributes for cells that are within the collection view

The final result will look like this:

img

Open Main.storyboard. CollectionViewController’s collection view prototype cell contains an image view and a label inside a stack view. The stack view’s edges are pinned to the edges of the cell’s content view. This is the Auto Layout setup for CollectionViewCell.

To set a collection view’s custom layout, do the followings:

  1. Select CollectionViewController.
  2. Open the Document outline.
  3. Select Collection View.
  4. In the Attributes inspector, set Layout to Custom and Class to CollectionViewLayout.

img

Next, you’ll prepare the presentation of the custom collection view layout.

Preparing custom collection view layouts

Open CollectionViewLayout.swift. Add the following properties to CollectionViewLayout:

// 1
weak var delegate: CollectionViewLayoutDelegate?
// 2
private var columns: Int {
  return UIDevice.current.orientation ==
    .portrait ? 1 : 2
}
// 3
private let cellPadding: CGFloat = 8
// 4
private var contentWidth: CGFloat = 0
private var contentHeight: CGFloat = 0
// 5
private var contentBounds: CGRect {
  let origin: CGPoint = .zero
  let size = CGSize(
    width: contentWidth,
    height: contentHeight)
  return CGRect(origin: origin, size: size)
}
// 6
private var cachedLayoutAttributes:
  [UICollectionViewLayoutAttributes] = []

Here’s what each property handles:

  1. The delegate feeds the custom view layout with cell sizing information. This includes the image view height and the label’s text at each collection view cell’s index path.
  2. Returns the columns per row count based on the device’s orientation: one column for portrait, and two columns for landscape.
  3. Declares a cell padding value.
  4. The content width and height will make the collection view’s content size.
  5. This computed property defines the collection view’s content bounds made up of its content position and size.
  6. The layout information for populating your collection view cells.

Next, override the following method in CollectionViewLayout:

override func prepare() {
  super.prepare()
  // 1
  guard let collectionView = collectionView
    else { return }
  cachedLayoutAttributes.removeAll()
  // 2
  let size = collectionView.bounds.size
  let safeAreaContentInset = collectionView.safeAreaInsets
  collectionView.contentInsetAdjustmentBehavior = .always
  contentWidth = size.width -
    safeAreaContentInset.horizontalInsets
  contentHeight = size.height -
    safeAreaContentInset.verticalInsets
  // 3
  makeAttributes(for: collectionView)
}

Here’s what you did with the code:

  1. Safely unwrap the collection view property for the collection view customization. Remove all of the existing cached layout attributes.
  2. Set the collection view’s content width and initial height to the collection view’s bounds size. During the bounds size calculation, you’ll account for the safe area content inset. As makeAttributes(for:) generates layout attributes, it’ll determine the final collection view content height.
  3. Call makeAttributes(for:) to create the layout attributes that will position and size the collection view cells.

prepare() is the place to do all of the overhead work for your collection view. The overhead work includes handling layout attributes caches, defining the collection view’s content size and populating the collection view cells.

Creating collection view layout attributes

Add the following code to makeAttributes(for:):

// 1
guard let delegate = delegate else { return }
let itemWidth = contentWidth / CGFloat(columns)
// 2
var xOffsets: [CGFloat] = []
(0..<columns).forEach {
  xOffsets.append(CGFloat($0) * itemWidth)
}
// 3
var column = 0
// 4
var yOffsets = [CGFloat](repeating: 0, count: columns)
// 5
let items = 0..<collectionView.numberOfItems(inSection: 0)

Here’s what you did:

  1. Safely unwrap delegate. You’ll need this delegate to get the cell’s content for sizing.
  2. Each cell needs an x position within the collection view. You’ll position your cell closer or further from the content bounds origin based on the column index.
  3. Initialize a column index property for reference.
  4. Initialize an array of zeroes with elements count equal to columns.
  5. Initialize a Range<Int> from zero to the number of items in the collection view at section 0.

Add the following code to the end of makeAttributes(for:):

for item in items {
  // 1
  let indexPath = IndexPath(item: item, section: 0)
  // 2
  let itemHeight = makeItemHeight(
    atIndexPath: indexPath,
    itemWidth: itemWidth,
    withCollectionView: collectionView,
    delegate: delegate)
  // 3
  let frame = CGRect(
    x: xOffsets[column], 
    y: yOffsets[column],
    width: itemWidth, 
    height: itemHeight)
  // 4
  let insetFrame = frame.insetBy(
    dx: cellPadding, 
    dy: cellPadding)
  // 5
  let layoutAttributes =
    UICollectionViewLayoutAttributes(
      forCellWith: indexPath)
  layoutAttributes.frame = insetFrame
  cachedLayoutAttributes.append(layoutAttributes)
  // 6
  contentHeight = max(contentHeight, frame.maxY)
  // 7
  yOffsets[column] += itemHeight
  // 8
  column = column < columns - 1 ? column + 1 : 0
}

Looping through the collection view items, here’s what you do at each iteration:

  1. Initialize the cell’s index path using item.
  2. Get the item’s height using makeItemHeight(atIndexPath:itemWidth:withCollectionView:delegate:).
  3. Generate the cell’s initial frame using x and y offsets at the current column index.
  4. Using frame, create another CGRect accounting for the cell’s padding.
  5. Initialize the collection view layout attributes with the cell’s index path. Set the layout attributes frame. Append the layout attributes into the cached layout attributes array.
  6. Set the collection view’s content height to the greater value between the current content height and the frame’s max-y.
  7. Advance the y-offset value by the item height.
  8. When the column index reaches the last column, reset the column index to zero. Otherwise, increment the column index.

The cell’s width depends on the device’s orientation; however, the cell’s height depends on the cell’s content combined height, which you’ll work on next.

Replace the body of makeItemHeight(atIndexPath:itemWidth:withCollectionView:delegate:) with the following code:

// 1
let imageHeight = delegate.collectionView(
  collectionView,
  heightForImageAtIndexPath: indexPath)
// 2
let labelText = delegate.collectionView(
  collectionView, 
  labelTextAtIndexPath: indexPath)
let maxLabelHeightSize = CGSize(
  width: itemWidth,
  height: CGFloat.greatestFiniteMagnitude)
let boundingRect = labelText.boundingRect(
  with: maxLabelHeightSize,
  options: [.usesLineFragmentOrigin],
  attributes:
    [NSAttributedString.Key.font:
     UIFont.preferredFont(forTextStyle: .headline)],
  context: nil)
let labelHeight = ceil(boundingRect.height)
// 3
let itemHeight = cellPadding * 2 + imageHeight + labelHeight
return itemHeight

With this code, you:

  1. Get the image height.
  2. Using the label’s text and font attribute, calculate the label’s height.
  3. Combine the cell paddings, label height and image height. Return the item’s height.

Overriding layout attributes methods

Add the following method overrides to CollectionViewLayout:

// 1
override func layoutAttributesForItem(
  at indexPath: IndexPath)
  -> UICollectionViewLayoutAttributes? {
    return cachedLayoutAttributes[indexPath.item]
}
// 2
override func layoutAttributesForElements(
  in rect: CGRect)
  -> [UICollectionViewLayoutAttributes]? {
    return cachedLayoutAttributes.filter {
      rect.intersects($0.frame)
    }
}

Here’s what you did:

  1. When the collection view asks for the cell layout attributes at an index path, return the cached layout attributes using the index path.
  2. When scrolling, the cells for display on a screen can change as you scroll. The collection view layout will calculate the layout attributes for cells that are within the collection view frame.

Setting the collection view content size

Add the following property and method overrides to CollectionViewLayout:

// 1
override var collectionViewContentSize: CGSize {
  return contentBounds.size
}
// 2
override func shouldInvalidateLayout(
  forBoundsChange newBounds: CGRect) -> Bool {
  guard let collectionView = collectionView
    else { return false }
  return newBounds.size
    != collectionView.bounds.size
}

With the code added, here’s what you did:

  1. Set the collection view content size to the content bounds size. The size is made up of the content width and height in prepare().
  2. Re-query for the layout geometry information only when the collection view’s new bounds size differs from the current collection view bounds size.

Setting up the collection view layout delegate

Open CollectionViewController.swift.

To adopt and conform to CollectionViewLayoutDelegate, add the following code to the end of the file:

extension CollectionViewController: CollectionViewLayoutDelegate {
  func collectionView(
    _ collectionView: UICollectionView,
    heightForImageAtIndexPath indexPath: IndexPath
  ) -> CGFloat {
    return shapes[indexPath.item].image.size.height
  }

  func collectionView(
    _ collectionView: UICollectionView,
    labelTextAtIndexPath indexPath: IndexPath
  ) -> String {
    return shapes[indexPath.item].shapeName.rawValue
  }
}

With this code, you return the respective shape’s image height and label text at the cell’s index path.

Add the following code to setupCollectionViewLayout():

guard let collectionViewLayout =
  collectionView.collectionViewLayout
    as? CollectionViewLayout else { return }
collectionViewLayout.delegate = self

Here, you set the CollectionViewController as the object to provide the layout geometry information.

Invalidating collection view layout for trait collection

Finally, add the following code to CollectionViewController:

override func traitCollectionDidChange(
  _ previousTraitCollection: UITraitCollection?
) {
  super.traitCollectionDidChange(previousTraitCollection)
  guard previousTraitCollection?.preferredContentSizeCategory
    != traitCollection.preferredContentSizeCategory 
    else { return }
  collectionView.collectionViewLayout.invalidateLayout()
}

With the code added, the collection view re-queries its layout information when the text size preference changes.

Build and run, and tap the Shapes button.

With the largest preferred text size, you’ll see the following in the portrait orientation:

img

And here’s what you’ll see in landscape orientation:

img

Now, the collection view cells dynamically adjust their size based on the cell content at runtime.

Challenges

Build and run. Tap the Text Styles bar button item, and you’ll see InfoViewController. Your challenge is to make InfoViewController support Dynamic Type.

You’ll need to fulfill the following requirements with InfoViewController:

  • In portrait orientation, set each label’s font to the corresponding font in labelTextStyleTuples.
  • In landscape orientation, set each label’s font to Avenir Next.
  • Make all labels support Dynamic Type fonts.
  • Make labels scale when a user changes the font size preferences at runtime.

While working through this challenge, make use of labelFontTextStyleTuples in InfoViewController.

Key points

  • The Dynamic Type feature allows users to scale content sizes based on the device’s font size preference.
  • Supporting Dynamic Type makes your app usable for a broader audience, which comes with benefits, including being different from the competition and increasing app retention rate.
  • Apps using Apple’s SF font can easily support Dynamic Type.
  • You can implement custom fonts with Dynamic Type.
  • You can scale non-text user interface elements according to the font size preferences.
  • Stack view makes it easy to make layout changes according to the font preferences.
  • When a cell uses Auto Layout, the standard UITableView supports Dynamic Type out of the box.
  • For collection views using non-line based layouts, you can support Dynamic Type using custom collection view layouts.