Apple has announced they expect third party apps to support Dynamic Type. However, if you have tried to implement it in your apps, you know there are some unexpected land mines along the way (such as static table view cells and custom cell styles). In this article, you will learn how Dynamic Type works under the hood and how to get it working properly in a variety of scenarios. You will also get some Swift code that makes it easier to implement Dynamic Type in your apps.
What is Dynamic Type?
In iOS 7, Apple introduced Dynamic Type technology to iOS devices. It allows users to specify their preferred font size (for apps that support Dynamic Type) in the Settings app.
This makes it easy for visually impaired users to increase the size of the text, or, on the other end of the spectrum, those with sharp vision to decrease the font size to fit more information on each screen.
To change the Dynamic Type setting, in the Settings App, select General > Accessibility > Larger Text, which displays the Accessibility screen (Figure 1). The user can move the slider to the left to decrease the font size or to the right to increase it. To get even larger font sizes, the user can turn on the Larger Accessibility Sizes option at the top of the screen.
Figure 1 - The Larger Text accessibility setting. |
The left side of Figure 2 shows the smallest font size in the Contacts app. The right side shows the largest non-accessibility font setting.
Figure 2 - Small and large font sizes in the Contacts app. |
Here are some of the built-in apps that currently support Dynamic Type:
- Messages
- Calendar
- Maps
- Notes
- Health
- Reminders
- Contacts
- Weather
Since these built-in apps support Dynamic Type, users are beginning to expect the custom apps you create to support it as well. Let's dive in and see what it takes.
Running the Sample App
Let's begin by checking out an existing project. You can download the sample app from this link.
- In Xcode, open the iDeliverMobile.xcodeproj file.
- Before running the app, in the menu, select Xcode > Open Developer Tool > iOS Simulator.
- In the Simulator's menu, select Hardware > Device > iPhone 5s.
- Next, launch the Settings app and navigate to General > Accessibility > Larger Text. Drag the slider all the way to the right to select the largest text.
- Go back to Xcode, and in the Scheme control at the top left of the Xcode screen, select iPhone 5s from the list of devices.
- Click Xcode's Run button. When the app appears in the Simulator, you can see that the font is still small. Obviously, we need to make a change to support Dynamic Type.
- Go back to Xcode and click the Stop button.
Working with Text Styles
Currently, all the user interface controls in the sample app have hard-coded font names and sizes. To support Dynamic Type, you need to change these hard-coded fonts to text styles.
Text styles are similar to styles in a word processing app. They allow you to specify the relative size and weight of the font used for each text element in your app. Figure 3 contains a list of the font styles to choose from.
Figure 3 - The Dynamic Type text styles |
Let's give some of these styles a try.
- In the Project Navigator, select the Main.storyboard file.
- In the Deliveries scene, select the Feng Wong label, then go to the Attributes Inspector and change the Font to Headline (Figure 4).
Figure 4 - Set the Font to Headline. |
- Select the address label and set its Font to Subhead.
- To see the full effect of the Dynamic Type change set the address label's Autoshrink setting to Fixed Font Size (Figure 5). This causes the address label text to be cut off, but we'll fix that later.
Figure 5 - Set the address label's Font to Subhead and Autoshrink to Fixed Font Size. |
- Click Xcode's Run button. When the app appears in the Simulator, the font size has increased to match the new setting (Figure 6).
Figure 6 - Dynamic Type at work! |
Also, notice the row height has increased slightly to accommodate the larger font size.
What happens if the user changes the font size in Settings while the app is running? Let's find out.
- If the iDeliverMobileCD app is not currently running, click the Run button in Xcode.
- When the app appears in the Simulator, press the Shift+Command+H keys to go to the Home screen.
- Launch the Settings app and navigate to the General > Accessibility > Larger Text screen. Drag the slider to the far left to decrease the text size.
- Press Shift+Command+H to go back to the Home screen and launch the iDeliverMobileCD app again. Notice the labels' fonts have change to match the new smaller font (Figure 7).
Figure 7 - The fonts change dynamically. |
- Go back to Xcode and click the Stop button.
Dynamic Type and Prototype Cells
The behavior you are seeing with the labels in this table view is unique to table views with:
- Table view Content set to Dynamic Prototype
- Cells using one of the built-in styles
As you can see in Figure 5, the table view in the sample app is using prototype cells.
If you select the table view cell in the Deliveries scene and go to the Attributes Inspector, you can see the Style is set to Subtitle (Figure 8).
Figure 8 - The Style is set to Subtitle. |
As you will learn, table views with static and custom cells behave differently.
Line Wrapping Label Text
In some of the built-in iOS apps, Apple allows text to be truncated when the user increases the font size. You can see this in the email address of the Contacts app shown in Figure 9.
Figure 9 - The email address is truncated. |
You can truncate text in your own apps, or you can have the text wrap to the next line. Let's see how wrapping the text works.
In the Deliveries scene, select the detail text label then go to the Attributes Inspector and set the number of lines to zero. This causes the address text to wrap to the next line (Figure 10).
Figure 10 - The labels' text exceeds the row's height. |
Unfortunately, iOS is no longer able to properly determine the height of the row. This brings us to a bigger discussion of dynamic row heights.
Dynamic Row Heights
When implementing Dynamic Type in a table view, its row height must be dynamic to accommodate changes in font size. Apple provides three strategies to achieve this:
- Set the table view's rowHeight property.
- Implement the tableView:heightForRowAtIndexPath: delegate method.
- Self-sizing cells
Using the rowHeight Property
Even though your table view row heights need to be dynamic, you can still use the classic table view rowHeight property. Whenever the font size changes (you will learn how to be notified of this later in this article), you can recalculate a new height and store it in the table view's rowHeight property.
The advantage of using the rowHeight property is speed. It provides the best scrolling performance because no calculations need to be performed on the fly while the user is scrolling.
The downside of this approach is that you have to perform your own calculations to determine the proper height for rows. In addition, all rows must be the same height.
In iOS 7, the default height of a table view was 44 points In iOS 8, the default height of a table view is UITableViewAutomaticDimension (a constant that equates to -1). If you want to use the rowHeight property, you need to set its initial value in the Attributes Inspector or in your view controller's viewDidLoad method.
Implementing heightForRowAtIndexPath
You can use the tableView:heightForRowAtIndexPath: view controller method to individually size each row based on your own calculations.
There is one big disadvantage to this approach. The height of all rows is requested up front, even before the rows have been created. If you have tens of thousands of rows, this can be a real performance drag.
Self-Sizing Table View Cells
When using self-sizing table view cells, rather than setting the rowHeight property, you either set the table view's estimatedRowHeight property or implement the tableView:estimatedHeightForRowAtIndexPath: delegate method.
Here are the basic steps performed by the runtime to create a self-sizing cell:
- Before a row comes on screen, it's sized using the estimatedRowHeight property or the associated delegate method.
- When the row is scrolled onto the screen, the cell is created.
- The cell is asked how bit it should be.
- If the height is different from the estimated height, it's used to adjust he content size of the table view cell.
- The cell is displayed on screen.
In step 3, there are two ways a cell can communicate the height it needs to be:
- Auto Layout
- Manual sizing code
The table view calls systemLayoutSizeFittingSize on your cell. This method knows if you have implemented constraints on your cell, and if so, the Auto Layout engine specifies the cell size.
If you haven't implemented Auto Layout constraints, the table view calls the sizeThatFits method on your cell. You can return a cell height from this method based on your own calculations—the cell width is already calculated for you.
Using Auto Layout with Dynamic Type
Let's use Auto Layout in our sample project to see how it works with Dynamic Type. We first need to make sure Auto Layout is turned on for the project's storyboard.
- Click on the white background of the storyboard to select it.
- Next, go to the File Inspector by selecting the button on the far left in the Inspector toolbar. Under the Interface Builder Document section, make sure Use Auto Layout is selected (Figure 11).
Figure 11 - Select Use Auto Layout |
- Now we need to change the Deliveries scene's table view cell to use a custom style. To do this, click on the table view cell to select it, go to the Attributes Inspector and change the Style to Custom. This removes both labels from the cell.
- Let's change the design-time height of the cell to make it easier to lay out in Interface Builder. Click in the gray area below the table view to select it. Next, go to the Size Inspector by clicking the second button from the right in the Inspector toolbar. Set the Row Height to 60.
- Next, drag a label from the Object Library and position it in the cell so you can see the horizontal and vertical guide lines shown in Figure 12.
Figure 12 - Add a label to the cell. |
- Grab the resizing handle on the right side of the label and drag it to the right side of the cell until the vertical guidelines shown in Figure 13 appears.
Figure 13 - Resize the label's width |
- With the label still selected, in the Attributes Inspector, set Font to Headline and Tag to 1.
- Drag a second label from the Object Library and position it below the first label so you see the guide lines shown in Figure 14.
Figure 14 - Add a second label to the cell. |
- Grab the resizing handle on the right side of the label and drag it to the right side of the cell until the vertical guidelines appear, just as with the first label.
- With the label still selected, in the Attributes Inspector set Font to Subhead, Lines to 0 and Tag to 2. Setting Lines to 0 causes the label to wrap to the next line when displaying long text strings.
- Click on the Headline label at the top of the cell to select it in the design surface.
- At the bottom right of the Interface Builder editor, click the Pin button (Figure 15). In the popup dialog, uncheck the Constrain to margins check box, and then click on each of the four red I-bars at the top of the dialog. This pins the label's top, bottom, left, and right sides to their nearest neighbors. Next, select the Height check box, and then click the Add 5 Constraints button at the bottom of the dialog.
Figure 15 - Add constraints to the top label. |
- Select the Subhead label at the bottom of the cell and click the Pin button again to display the Constraints popup dialog.
- You need to select the same constranst as you did with the Headline label: Uncheck the Constraints to margin check box, select all four I-bars at the top of the dialog, select the Height check box, and then click the Add 5 constraints button at the bottom of the dialog.
- With the lower Subhead label still selected, go to the Size Inspector by clicking the second button from the right in the Inspector toolbar.
Click the Height constraint's Edit button (Figure 16) and change the operator to "greater than or equal to". This allows the height of the button to grow to accommodate multiple lines of text.
Figure 16 - Change the Height's constraint operator |
- Now you need to do the same for the Heading label. Click the Heading label in the design surface to select it. In the Size Inspector, click the Height constraint's Edit button and change the operator to "greater than or equal to".
- Now you need to change the code in the table view controller to work with the new custom labels.
Select DeliveriesViewController.swift in the Project Navigator. In the tableView:cellForRowAtIndexPath: method, change the code that configures the cell to the following:
This code uses the viewWithTag method to get a reference to the new labels using the Tag numbers you set for each. The last line of highlighted code is necessary to get around a "feature" that sometimes prevents labels from wrapping to the next line properly. Setting a label's prefferedMaxLayoutWidth property to the label's current width does the trick.
- There is one more change we need to make. Scroll to the top of the DeliveriesViewController.swift file and add the following code to the bottom of the viewDidLoad method:
Setting the table view's estimatedRowHeight property specifies the approximate height of the cell. Setting the rowHeight property of the table view to UITableViewAutomaticDimension tells iOS that you want it to automatically resize the cells for you. Let's give it a try!
- Go to the Simulator, launch the Settings app and set the text size to the largest setting. Next, click Xcode's Run button. When the app appears in the Simulator, the address wraps to the next line (Figure 17)!
Figure 17 - The address wraps to the next line. |
Type Changes with Custom Cells
Currently, when the Deliveries scene first loads, the labels in the table view take on the font size the user has specified in the Settings app. Previously, when the cell was set to the built-in Subtitle style, if the user changed the font size while the app was running, the font size of the labels changed dynamically. Unfortunately, this is not the case when using a custom cell style. Let's try it.
- Click Xcode's Run button to the run the app in the Simulator.
- When the app appears in the Simulator, press the Shift+Command+H keys to go to the Simulator's Home screen.
- Launch the Settings app and navigate to the General > Accessibility > Larger Text screen. Drag the slider to the far left to decrease the text size.
- Press Shift+Command+H to go back to the Home screen and launch the iDeliverMobileCD app again. As you can see, the label's font size hasn't changed. Go back to Xcode and click the Stop button and we'll address this issue.
To get labels (or any other text-based control) in a custom cell to dynamically change font size in response to changing the font size in the Settings app, you must:
- Register with the NSNotificationCenter for the UIContentSizeCategoryDidChangeNotification in your view controller's init or viewDidLoad method.
- In the code that responds to the font change notification, store the label's style setting back into the font property. For example:
- Unregister for notifications in the view controller's deinit method.
Let's try this with the Deliveries scene.
- In the DeliveriesViewController.swift file, add the following code to the bottom of the viewDidLoad method:
This code asks the Notification Center to call the handleDynamicTypeChange method when the user changes the Dynamic Type setting.
- Add the following handler method below the viewDidLoad method:
This handler method reloads the table view's data.
- Now add the following code to the bottom of the tableView:cellForRowAtIndexPath: method:
This code resets the label's font style.
- Finally, add the following deinit method below the viewDidLoad method:
- Let's try this code to see how it works at run time. Click Xcode's Run button. When the app appears in the Simulator, you should see the small text from when you previously changed font size. Press the Shift+Command+H keys to go to the Simulator's Home screen.
- Launch the Settings app and navigate to the General > Accessibility > Larger Text screen. Drag the slider to the far right to increase the text size.
- Press Shift+Command+H keys to go to the Simulator's Home screen and launch the iDeliverMobileCD app again. As you can see, the label's font size has increased without having to restart the app!
- Go back to Xcode and click the Stop button.
What's Bad About This Model
Here are a few things that make this model undesirable:
- You have to set the font for all controls twice. Once in the Attributes Inspector and a second time in code.
- You have to create outlets for all text-based controls, even if you don't need them for any other purpose.
- You have to add this same code to all view controllers in your app.
When you come across situations where you must add redundant code in multiple places in your app, it's time to create a universal solution you can reuse in all your projects.
I have create a set of class you can add to any project that makes it easy to implement Dynamic Type handing in your app. Before giving it a try, let's get rid of the code you added in the previous section.
- Delete the following code from the viewDidLoad method:
- Delete the following handler method below the viewDidLoad method:
- Delete the following code from the bottom of the tableView:cellForRowAtIndexPath: method:
- Remove the deinit method located below the viewDidLoad method:
A Better Solution
Now let's try a better solution.
- Right-click the Main.storyboard file in the Project Navigator and select Add Files to iDeliverMobileCD... from the shortcut menu.
- In the Add Files dialog, uncheck Copy items if needed.
- In the project's folder, select the mmDynamicTypeExtensions.swift file and click Add. We'll take a closer look at this code later, but let's first see how the code works at design time and run time.
- Select the Main.storyboard file in the Project Navigator. In the Deliveries scene, select the Heading label at the top of the cell.
- Go to the Attributes Inspector. Notice there is a new Type Observer attribute (Figure 18).
Figure 18 - The Type Observer attribute |
The code you just included in this project added this Type Observer property to the label.
- Change the Type Observer setting to On.
- Select the Subhead label at the bottom of the cell. In the Attributes Inspector, set the Type Observer attribute to On.
- That's it! Let's give it a try. Click Xcode's Run button. When the app appears in the Simulator, you should see the large text from when you previously changed the font size. Press the Shift+Command+H keys to go to the Simulator's Home screen.
- Launch the Settings app and navigate to the General > Accessibility > Larger Text screen. Drag the slider to the far left to decrease the text size.
- Press Shift+Command+H to go to the Home screen and run the iDeliverMobileCD app again. As you can see, the label's font size has changed!
- Go back to Xcode and click the Stop button.
Dynamic Type Handling
Let's take a closer look at the code that makes this work.
- Select the mmDynamicTypeExtensions.swift file in the Project Navigator.
- At the top of the file is a protocol that declares a single typeObserver property of type Bool. This is the property you set to On for the table view labels:
- Just below the protocol declaration is an extension of the UILabel class:
This extension adopts the DynamicTypeChangeHandler protocol and implements the typeObserver property. The @IBInspectable attribute is what makes the property appear in the Attributes Inspector. The property setter makes a call to a Dynamic Type Manager's registerControl method.
- Scroll a little further down in the code file and you can see the DynamicTypeManager is declares as a Singleton:
The Singleton design pattern restricts the number of instances of a class to one. When an instance is requested, one is created if it doesn't already exist. If an instance exists, it returns a reference to that object.
Figure 19 contains a sequence diagram that depicts the architecture I have created for handling Dynamic Type changes.
Figure 19 - Dynamic Type handling sequence diagram |
Here are the key steps:
- User interface controls register themselves with the Dynamic Type Manager when their typeObserver property is set to true (On in the Attribute Inspector), passing a path to their font property.
- When the first control registers itself, an instance of the Dynamic Type Manager is created and registers itself with the NSNotificationCenter for Dynamic Type changes.
- A reference to the control and its font style is stored in an NSMapTable. A map table is a special kind of dictionary that holds weak references to objects, so entries are removed automatically when either the key or value is deallocated. This is a perfect fit for this scenario where we don't want to hold strong references to user interface controls. When the UI controls are released (for example, when the user navigates to a different scene and the view controller is deallocated), the control reference are automatically removed from the NSMapTable (Thanks to the folks at Big Nerd Ranch for this tip!).
- When the user changes the font size in the Settings app, NSNotificationCenter alerts the Dynamic Type Manager.
- The Dynamic Type Manager iterates through the list of user interface controls in the map table. For each control, it sets the font style and calls the control's sizeToFit method.
What is there to like about this architecture?
- It uses extensions rather than subclasses. This allows you to use the out-of-the-box UIKit controls.
- When you add the mmDynamicTypeExtensions.swift code file to your project, it "just works".
- There is no need to create outlets for UI controls. You simply set a property in the Attributes Inspector.
- This architecture is loosely coupled. The UI controls provide information about themselves to the Dynamic Type Manager. This means you can register your own custom controls (or new controls that Apple releases in the future) without changing the Dynamic Type Manager.
- Since you don't need to use this feature for prototype table view cells that are set to one of the default styles, this "opt in" model lets you choose which controls you want to register with the Dynamic Type Manager.
Dynamic Type and Static Text
Let's check out how Dynamic Type works with static table view cells.
- In the iDeliverMobileDynamicType project, select the Main.storyboard file and scroll over to the Deliveries scene (Figure 20).
Figure 20 - The Shipment scene. |
- The table view in this cene contains dynamic protototype cells, just as the Delivieries scene. The main difference is the Shipment scene contains both dynamic and static text. The blue text (Phone, Text, and ID) as well as the Status text is static. This means the text doesn't change as you examine different shipments. The rest of the text is dynamic, changing for each shipment.
- To get the labels on thi scene to adapt to Dynamic Type, select each label in the design surface, then go to the Attributes Inspector and change the Font to one of the iOS text styles. Here are some recommendations:
- Name - Headline
- Address Line 1 - Body
- Address Line 2 - Subhead
- Phone labels - Body
- Text labels - Body
- Status labels - Body
- ID labels - Body
- iPod Touch label - Body
- In the ShipmentViewController.swift file, add the following code to the bottom of the viewDidLoad method:
Remember, this code tells the table view to use self-sizing cells.
- Let's see how this works at run time. Click Xcode's Run button, and when the app appears in the Simulator, select the shipment in the Deliveries scene to navigate to the Shipment scene. You should see the small type that was last specified in the Settings app.
- Now let's see if the app responds to Dynamic Type change while the app is running. Go to the Settings app and select the largest font size. Afterward go back to the iDeliverMobileDynamicType app.
As shown in Figure 21, all the static text is missing! This is an iOS bug, and unfortunately, it still isn't fixed in the latest version of Xcode 6.2. I'm hoping Apple will fix it, but for now you can get around the issue without resorting to creating custom cells. You just need to add code to the tableView:cellForRowAtIndexPath: method that resets the static text.
Figure 21 - All static text is missing! |
One other problem is that the name text in the first cell is no longer centered. We can also work around this problem by adding code to the same method.
- In the tableView:cellForRowAtIndexPath: method of the file, add the following highlighted code:
- Click Xcode's Run button and when the app appears in the Simulator, navigate to the Shipment scene.
- Go to the Settings app and set the font size to the smallest setting. Go back to the iDeliverMobileDynamicType app and you should see the static text is back (Figure 22)! This works because the table view's reloadData method is automatically called when a Dynamic Type change is made.
Figure 22 - Static text is restored! |
Conclusion
Last year, my company had a booth at the MacWorld trade show, promoting my iOS app development book series. A visually impaired attendee approached the booth and asked if I was teaching new developers how to create apps that can be used by the visually impaired. Starting with this article, I can finally say yes, and I hope the information presented here helps you to do the same!