10 Adaptive Layout¶
Adaptability is all about guaranteeing a good user experience across all iOS devices and screen sizes. With so many options, it can be challenging to develop apps that look good on everything. Unfortunately, creating storyboards and views for each screen size and orientation doesn’t scale well, so it’s critical to build your apps with adaptive user interfaces that use adaptive layouts.
Adaptive apps rely on the trait system and trait collections. A trait collection is a set of traits and their respective values. A trait describes the current environment for your app. For example, traits can include layout direction, dynamic type size and size classes.
The main goal of adaptive layout is to allow you to create apps for all iOS devices without the need for device-specific code. In this chapter, you’ll learn how to create adaptable apps by using size classes and adaptive images. Throughout this chapter, you’ll use the tools that UIKit already provides. For more adaptive layout content, read Chapter 15, “Dynamic Type,” and Chapter 16, “Internationalization and Localization.”
One storyboard to run them all¶
Depending on the complexity of your app, you can use different strategies to accomplish adaptability. By using the right constraints, your screens can adapt gracefully to different screen sizes and orientations.
Go to the starter project and open the MessagingApp project. Select the iPhone 8simulator, then build and run.
The Profile screen looks good in portrait mode. Now, press Command-Right Arrow to switch the simulator orientation to landscape.
Notice the views aren’t using all of the available width. Go back to Xcode, and open Profile.storyboard. In the document outline, look for Profile Scene and select Main Stack View. Open the Size inspector.
Look at the constraints; there’s one for the width to make sure it’s equal to 375. Select that constraint and press delete to remove it. Since the available screen size isn’t always going to be 375, it doesn’t make sense to keep this constraint.
Select Main Stack View in the document outline, and Control-drag to View, which is located at the top of the document outline. On the modal window that pops up, select Equal Widths.
Now, build and run.
Rotate the device from portrait to landscape and then back to portrait. Notice how the screen adapts to the available width after deleting the width constraint.
Setting up the storyboard¶
Go to Main.storyboard, and press Command-Option-1 to show the File inspector. On the Interface Builder Document section, make sure that Use Trait Variations and Use Safe Area Layout Guides are both checked. Note that these options are selected by default in the latest versions of Xcode.
Here’s what these options do:
- Use Trait Variations allows the storyboard to store data for more than one device family. You need this option enabled to create adaptive layouts.
- Use Safe Area Layout Guides makes the apps use the available space to draw layouts, respecting things like the notch on the latest iPhone devices.
Previewing layouts¶
On the bottom bar in Interface Builder, click View as:
The Device Configuration Bar allows you to see how the user interface will look on different devices and conditions. In the Devices section, select iPhone 4S, which is the right-most icon shown in the Device area. Almost immediately, all of the screens in the storyboard will resize to represent how the interface will look on the selected device.
In the Orientation area, select Landscape. Choose the About Scene, and the layout will look like this:
Notice how the text is getting cut off, and the layout isn’t using all of the available space. To fix this problem and to make sure the interface looks good on multiple devices in either orientation, you’ll use size classes.
Size classes¶
When creating layouts, you should always think in terms of available space. Size classes make it possible to know how much available space there is by taking into account the device and the environment in which the app is running. For example, apps running on an iPhone 6 won’t have the same available space as apps running on an iPad.
Size classes are attributes that represent the content area available using two traits: horizontalSizeClass and verticalSizeClass. These two traits can be either regular or compact. The regular value represents expansive space, meaning there’s a fair amount of space available. The compact value represents constrained space, meaning there’s not much space available. Using these two traits, you can have four possible combinations.
The use of size classes allows you to have more flexibility and awareness while creating user interfaces, which significantly reduces the need for device-specific code.
Here’s a list of the values of size classes on different devices:
Multitasking and size classes¶
As you can see in the previous table, for the iPad, you usually have regular width and height. This changes when the system is using a split view since there’s less space available.
The following example shows how a split view can change the size classes:
Working with size classes¶
Go back to Xcode. Open Main.storyboard and select the About Scene. The view contains two elements: an image view and a label — neither have constraints. Build and run to see the About Scene.
Go to the About tab, and rotate the device using Command-Right Arrow.
Notice the user interface isn’t correct. As it turns out, in this specific case, adding constraints isn’t enough because the final product should adapt depending on the orientation of the device.
On the Device Configuration Bar, set the device back to iPhone 8 in portrait. Click Vary for Traits from the popup that appears and select both options: width and height. The bar will turn blue, like this:
On the left side, you can see an icon for the device you’ve selected on the View as. Click this icon to see the list of devices that will be affected by the constraints you’re about to create.
Now you can add constraints to the image. Select the image and then use Align to select Horizontally in Container:
Next, set the top constraint to 20, and the width to 120. Make sure Constrain to marginis unchecked, and click Add 2 Constraints.
Now, select the label. Click the Pin menu. Make the top, leading and trailing constraints equal to 20, and set the bottom to 60. Make sure Constrain to margin is unchecked.
Add those four constraints, and then click Done Varying on the bottom bar.
Select the image view, and using the Pin menu, create a constraint for the aspect ratio, and click Add 1 Constraint.
Since this constraint was created without any traits specified, it will affect all size classes.
Verify that the value for the aspect ratio is 1:1. You can do this by selecting the constraint in the document outline and modifying the multiplier value in the Size inspector.
Build and run.
Excellent, the app now looks good in portrait mode; however, try switching to landscape using Command-Right Arrow, and you’ll see that the views disappear.
The constraints you created are installed only for Compact Width and Regular Height size classes. The iPhone 8 in portrait mode has that size class, but in landscape, the device screen changes to Compact Width and Compact Height.
Open Main.storyboard, and on the Device Configuration Bar, select landscape orientation.
The constraints in the document outline look faded; this means they’re not installed for the current size class. Move the image view and the label, so they’re side-by-side horizontally.
On the right side of the Device Configuration Bar, click Vary for Traits. From the popup that appears, select both options: width and height.
Select the image view and create the constrains using the Pin menu. Make sure Constrain to margin is unchecked, and then create the following constraints for the leading edge and width. When you’re done, click Add 2 Constraints:
Using the Align menu, select Vertically in Container for the image view.
Now, select the label and create and set the top, leading, and trailing constraints, so they’re equal to 20. Set the bottom constraint equal to 60 using the Pin menu.
Click Done Varying on the bottom bar.
Build and run.
The app now adapts perfectly no matter the orientation.
Changing properties¶
Apart from constraints, you can also change the value of some properties using size classes.
On Main.storyboard, go to the About Scene and select the label. Now, look at the Attributes inspector.
The Color and Font properties both have the plus sign on the left side, which means they can have variations depending on the size classes.
Click the plus button on the left side of the Font property. Select compact for width and height variations, and then Add Variation. This creates a new element containing the value for the font property for the specified variation. Change the font size to 12. The Attributes inspector will look like this:
Build and run. Rotate the device and see how the font size for the label changes depending on the orientation.
Trait environment and trait collections¶
Every time a device changes its orientation, traits containing the new configuration are propagated throughout the app from the screen to the presented view.
You can respond to these changes in code using traitCollectionDidChange(_:)
; this is called on any object that conforms to UITraitEnvironment
.
Open AboutViewController.swift and add the following code inside of the class:
private func setupContactUsButton(
verticalSizeClass: UIUserInterfaceSizeClass
) {
//1
NSLayoutConstraint.deactivate(contactButtonConstraints)
//2
if verticalSizeClass == .compact {
//3
contactUsButton.setTitle("Contact Us", for: .normal)
//4
contactButtonConstraints = [
contactUsButton.widthAnchor.constraint(
equalToConstant: 160),
contactUsButton.heightAnchor.constraint(
equalToConstant: 40),
contactUsButton.trailingAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.trailingAnchor,
constant: -20),
contactUsButton.bottomAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.bottomAnchor,
constant: -10),
]
} else {
//5
contactUsButton.setTitle("", for: .normal)
//6
contactButtonConstraints = [
contactUsButton.widthAnchor.constraint(
equalToConstant: 40),
contactUsButton.heightAnchor.constraint(
equalToConstant: 40),
contactUsButton.centerXAnchor.constraint(
equalTo: view.centerXAnchor),
contactUsButton.bottomAnchor.constraint(equalTo:
view.safeAreaLayoutGuide.bottomAnchor,
constant: -10),
]
}
//7
NSLayoutConstraint.activate(contactButtonConstraints)
}
With this code, you:
- Deactivate all the constraints in
contactButtonConstraints
. - Check if
verticalSizeClass
is equal to.compact
. - Change the
contactUsButton
button title to Contact Us. - Make
contactButtonConstraints
equal to the set of constraints determined for this configuration. In this case, width equal to 160, height equal to 40, trailing equal to -20 fromsafeAreaLayoutGuide.trailingAnchor
, and bottom equal to -10 fromsafeAreaLayoutGuide.bottomAnchor
. - In case
verticalSizeClass
is not equal to.compact
. Set the title to an empty string. - Make
contactButtonConstraints
equal to the set of constraints determined for this configuration. - Activate the constraints in
contactButtonConstraints
.
Below setupContactUsButton(verticalSizeClass:)
, add the following code:
override func traitCollectionDidChange(_
previousTraitCollection: UITraitCollection?
) {
//1
super.traitCollectionDidChange(previousTraitCollection)
//2
if traitCollection.verticalSizeClass !=
previousTraitCollection?.verticalSizeClass {
//3
setupContactUsButton(
verticalSizeClass: traitCollection.verticalSizeClass)
}
}
Here’s what you did:
- Call
super.traitCollectionDidChange(previousTraitCollection)
so that elements in the higher hierarchy stay up to date with the changes. - Check if the
verticalSizeClass
for the currenttraitCollection
is different from the previous one. - Call
setupContactUsButton(verticalSizeClass:)
.
Go to viewDidLoad()
, and add these two lines at the end:
//1
view.addSubview(contactUsButton)
//2
setupContactUsButton(
verticalSizeClass: traitCollection.verticalSizeClass)
With this code, you:
- Add
contactUsButton
to theview
so that it can be part of the view hierarchy. - Call
setupContactUsButton(verticalSizeClass:)
so thatcontactUsButton
is properly set up the first time the view appears.
Build and run. Rotate the device, and check how the contact us button at the bottom is displayed differently depending on the device configuration.
Here it is in portrait mode:
Here it is in landscape mode:
Adaptive presentation¶
A view controller can be presented in different ways, depending on the environment. By default, the system will try to accommodate the view controller, but you can decide how you want your view controller to adapt.
Build and run.
Go to the Messages tab and tap Options.
A sheet with some options appears; you can hide it by dragging the view to the bottom. Now, tap Options again and put the device in landscape using Command-Right Arrow.
There’s no way for you to dismiss the view controller while the device is in landscape — time to fix that.
Right-click over the Controllers folder and select New file…. Choose Cocoa Touch Classand click Next. Set Class to SettingsTableViewController. Set Subclass of to UITableViewController. Set the Language to Swift. Also, verify that create XIB files is unchecked, then click Next. Finally, click Create.
Open SettingsTableViewController.swift and remove everything except the class declaration. When you’re done, your code will look like this:
import UIKit
class SettingsTableViewController: UITableViewController {
}
Type this inside the class:
//1
override func awakeFromNib() {
super.awakeFromNib()
//2
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done,
target: self,
action: #selector(dismissModal))
//3
modalPresentationStyle = .popover
//4
popoverPresentationController!.delegate = self
}
Here’s what you did:
- Override
awakeFromNib
. You want to execute code before the view controller loads. - Set
leftBarButtonItem
ofnavigationItem
equal to an instance ofUIBarButtonItem
with an action that callsdismissModal()
. - Set the value of
modalPresentationStyle
to.popover
so that you have a default presentation style. - Set the view controller as the
popoverPresentationController
delegate.
Below the code you just added, add this:
@objc private func dismissModal() {
dismiss(animated: true)
}
This new function gets called when the button on the navigation bar is tapped. It calls dismiss(animated: true)
on the view controller.
Next, add the following new extension outside of the class:
extension SettingsTableViewController:
UIPopoverPresentationControllerDelegate {
//1
func adaptivePresentationStyle(
for controller: UIPresentationController
) -> UIModalPresentationStyle {
//2
switch (
traitCollection.horizontalSizeClass,
traitCollection.verticalSizeClass) {
//3
case (.compact, .compact):
return .fullScreen
default:
return .none
}
}
//4
func presentationController(
_ controller: UIPresentationController,
viewControllerForAdaptivePresentationStyle
style: UIModalPresentationStyle
) -> UIViewController? {
//5
return UINavigationController(
rootViewController: controller.presentedViewController)
}
}
Here’s what you did:
- Implement
adaptivePresentationStyle(for:)
to choose the modal presentation style depending on the size classes. - Create a switch statement using the horizontal and vertical size classes of
traitCollection
. - When both size classes are
compact
, returnfullscreen
as the presentation style; otherwise, returnnone
so that the default presentation style is used, which in this case, ispopover
as indicated inawakeFromNib
. - Implement
presentationController(_:viewControllerForAdaptivePresentationStyle:)
. This allows you to return a different view controller than the one being presented. - Return a
UINavigationController
whose root view controller is thepresentedViewController
. Thanks to this, the controller will have a navigator bar, where theUIBarButtonItem
you created inawakeFromNib
will appear. Note that this navigation view controller is ignored when the presentation mode ispopover
.
Open Main.storyboard and look for the table view controller containing the options — it’s connected through a segue to the Contacts Scene.
Select Table View Controller, and in the Identity inspector, set Class to SettingsTableViewController. Now the view controller uses the class you set up with the UIPopoverPresentationControllerDelegate
.
Build and run.
Go to the Messages tab. Tap Options and the sheet appears just like before. Tap outside of the popover to dismiss it, then put the simulator in landscape mode using Command-Right Arrow. Tap Options again.
There’s now a navigation bar at the top, and you can close the view controller by tapping Done.
UIKit and adaptive interfaces¶
UIKit provides tools to make adaptable user interfaces. Some of these tools include:
- Split view controller
- Layout guides
- UIAppearance proxy
The split view controller¶
The split view controller acts as a container view controller that manages two child view controllers. If you’ve used the Settings app on an iPad, you may have noticed that the experience is different from what you have on an iPhone 6, for example. The app changes to display the information in a master-detail configuration. This happens thanks to the split view controller.
Go to Main.storyboard. Press Command-Shift-L, and type split into the search bar.
Drag a Split View Controller into the storyboard.
Remove all of the newly added view controllers except the Master View Controller.
Remove the connection between the Tab Bar Controller and the Navigation Controllerconnected to the Contacts Scene.
Control-drag from the Tab Bar Controller to the Master View Controller, and select view controllers.
Control-drag from the Master View Controller to the Navigation Controller Controllerconnected to the Contacts Scene, and select master view controller.
Look for the Messages Scene and remove the segue going to it. Embed the scene in a navigation view controller using Editor ▸ Embed In ▸ Navigation Controller.
Control-drag from the Master View Controller to the navigation controller you just created, and select detail view controller on the popup that appears.
In the Project navigator, right-click over the Controllers folder and click New file…. Select Cocoa Touch Class, and click Next. Set Class to SplitViewController. Set Subclass of to UISplitViewController, and the Language to Swift. Also, ensure that create XIB files is unchecked, then click Next. Finally, click Create.
Open SplitViewController.swift, and replace everything in the class with the following code:
override func viewDidLoad() {
super.viewDidLoad()
//1
guard
let leftNavController =
viewControllers.first as? UINavigationController,
let masterViewController =
leftNavController.viewControllers.first
as? ContactListTableViewController,
let detailViewController = (viewControllers.last
as? UINavigationController)?.topViewController
as? MessagesViewController
else { fatalError() }
//2
let firstContact = masterViewController.contacts.first
detailViewController.contact = firstContact
//3
masterViewController.delegate = detailViewController
//4
detailViewController.navigationItem
.leftItemsSupplementBackButton = true
detailViewController.navigationItem
.leftBarButtonItem = displayModeButtonItem
}
With this code, you:
- Get the master view controller and detail view controller from the
viewControllers
property of the split view controller. - By default, the split view controller will show the first contact. You obtain it by calling
first
on thecontacts
array of themasterViewController
. - Set
masterViewController
delegate todetailViewController
. - Make
detailViewController
replace its left navigation item with a button that will toggle the display mode of the split view controller. This button will be visible only on iPad; you’ll get a button in the top left to toggle the table view display.
Go to Main.storyboard. Select Master View Controller and press Command-Option-4 to show the Identity inspector. Set Class to SplitViewController. In the document outline, select the tab bar item.
In the Attributes inspector, set Title to Messages, and set Image to chat-tab.
In the Tab Bar Controller, click-drag the Messages tab bar item to the middle.
There’s only one thing missing: You need to wire the master and detail view so that when a user taps a contact, the corresponding messages appear.
Open ContactListTableViewController.swift, and add this code before the class declaration:
protocol ContactListTableViewControllerDelegate: class {
func contactListTableViewController(
_ contactListTableViewController:
ContactListTableViewController,
didSelectContact selectedContact: Contact
)
}
You’ll implement this protocol on the detail view controller so that it can display the proper messages when the selected contact changes.
Add the following code to the top of the class:
weak var delegate: ContactListTableViewControllerDelegate?
This code declares the delegate
property.
Now, replace the tableView(_:didSelectRowAt:)
implementation with the following:
override func tableView(
_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath
) {
//1
guard
let messagesViewController =
delegate as? MessagesViewController,
let messagesNavigationController =
messagesViewController.navigationController
else {
return
}
//2
let selectedContact = contacts[indexPath.row]
messagesViewController.contactListTableViewController(
self,
didSelectContact: selectedContact)
//3
splitViewController?.showDetailViewController(
messagesNavigationController,
sender: nil)
}
This code:
- Checks that the delegate is not
null
and gets a reference to the navigation controller containingmessagesViewController
. - Calls
contactListTableViewController(_:didSelectContact:)
on the delegate passing the selected contact. - Calls
showDetailViewController
onsplitViewController
, passingmessagesNavigationController
. This makes the split view controller show the detail view.
You can remove prepare(for:sender:)
since you won’t need it anymore.
Open MessagesViewController.swift, and at the bottom, add the following extension:
extension MessagesViewController:
ContactListTableViewControllerDelegate {
func contactListTableViewController(
_ contactListTableViewController:
ContactListTableViewController,
didSelectContact selectedContact: Contact
) {
contact = selectedContact
}
}
Here, you implement ContactListTableViewControllerDelegate
. This implementation ensures the contact
property gets the selected contact. The property contact
has a didSet
observer that will reload the table view with the corresponding data.
Build and run.
Select the messages tab on the tab bar. By default, the split view controller shows the first detail item. Tap the back button so that you can see the master view with the contacts list.
You can tap any of the contacts and see how it works.
Stop the simulator, and select the iPad Pro (11-inch) as the simulator. Build and run.
Go to the messages tab. Tap the back button and see how you get a different user interface. Rotate the device using Command-Right Arrow. Now you get a master-detail view similar to the one used in the Settings app or the Mail app.
This functionality is all possible thanks to the split view controller — and you got it all almost for free. Pretty cool! :]
Use your layout guides¶
The system comes with predefined layout guides that can make apps adapt better to different devices. One clear example is the Safe Area Layout Guide that helps prevent content from getting behind the iPhone X notch. You can learn more about this in Chapter 7, “Layout Guides”.
UIAppeareance¶
UIAppeareance
serves as a proxy to have access to the mutable appearance of some classes, like UINavigationBar
, UIButton
and UIBarButtonItem
. By changing the attributes for these classes, you can create consistent themes that you can use throughout the app.
Open AppDelegate.swift, and in application(_:didFinishLaunchingWithOptions:)
, before return true
, add this:
//1
let verticalRegularTrait =
UITraitCollection(verticalSizeClass: .regular)
//2
let regularAppearance =
UINavigationBar.appearance(for: verticalRegularTrait)
let regularFont = UIFont.systemFont(ofSize: 20)
//3
regularAppearance.titleTextAttributes =
[NSAttributedString.Key.font: regularFont]
//4
let verticalCompactTrait =
UITraitCollection(verticalSizeClass: .compact)
//5
let compactAppearance =
UINavigationBar.appearance(for: verticalCompactTrait)
let compactFont = UIFont.systemFont(ofSize: 14)
//6
compactAppearance.titleTextAttributes =
[NSAttributedString.Key.font: compactFont]
This code:
- Creates a new trait collection with a regular vertical size class.
- Grabs a reference to the appearance for the
NavigationBar
for the previously declared trait collectionverticalRegularTrait
. - Sets
titleTextAttributes
so that it uses theregularFont
. This will make the font size 20, when the screen has a lot of space available vertically — for example, an iPhone 8 in portrait mode. - Creates a new trait collection with a compact vertical size class.
- Grabs a reference to the appearance for the
NavigationBar
forverticalCompactTrait
. - Sets
titleTextAttributes
so that it used thecompactFont
. This will make the font size 14 when the screen has little space available vertically — for example, an iPhone 8 in landscape mode.
Build a run. Make sure to select the iPhone 8 as the simulator.
Rotate the device to see how the navigation bar title looks bigger in portrait mode and smaller in portrait mode.
Adaptive images¶
Image assets should be adaptive, too. In this section, you’ll use Asset Catalogs to manage images and provide different versions of them depending on the size class. Also, you’ll explore how the alignment and slicing tool can help you select parts of an image and indicate how an image should resize when necessary.
Images and traits¶
Asset catalogs give you the possibility of having multiple images depending on the trait environment. You can have different image assets for different size classes.
Back in Xcode, open Assets.xcassets and select about-logo. In the Attribute inspector, set Height Class to Any & Compact.
This will add three slots on your image set. These new slots allow you to add images for when the height trait is compact.
The already existing images have the label Any Height; this means they’ll be used for any other configurations.
Look for the assets folder — it’s in the same directory as the starter and final folders. Drag all of the images, individually, to their designated slots.
Build and run.
Go to the About tab and rotate the device. When the device is in landscape, the images are bigger; when in portrait, the images are smaller.
Here’s how the app looks in portrait:
Here’s how the app looks in landscape:
Great! You’ve used the power of asset catalogs and size classes to create a better user interface.
Alignment insets and slicing¶
Using the Asset Catalog, you can indicate which parts of an image you want to use so that your app looks good in different scenarios. For this, you need to use alignment insets and slicing.
Alignment allows you to take part of an image by specifying margins. Meanwhile, slicinggives you the ability to have images that resize nicely, stretching just the parts you want. This is commonly used when you want to give a view a background, but the view can have different sizes.
Go to AboutViewController.swift, and in the lazy var contactUsButton
, and change the title color to white, Add the following before the return
statement.
button.setBackgroundImage(
UIImage(named: "button-background"),
for: .normal)
Build and run.
Go to the About tab and put the device in landscape. Notice the background looks distorted.
In Assets.xcassets, select button-background. Choose the image in the 2x slot, and in the Attribute inspector, go to the bottom and you’ll reach the Slicing section.
Set Slices to Horizontal. Set a value of 44 for Left and Right. And, set Center to Stretches.
Click Show Slicing on the bottom bar, and you’ll see a visual representation of what you did.
You can also use this to change the values by dragging the dotted lines.
Build and run, and go to the About tab. Now, rotate the device so that you can see how the button background adapts for different orientations.
When in portrait:
When in landscape:
Excellent! You now have a new set of tools for creating layouts and making them adaptive. Using these tools, you can make your apps look great on any device and orientation.
Challenge¶
The About screen currently has constraints for Compact Width/Regular Height and Compact Width/Compact Height traits. Your challenge is to add constraints using the Regular Width and Regular Height traits, so the user interfaces look good on more devices.
Key points¶
- Size Classes determine how the user interface is laid out.
- You can modify how view controllers are presented using
UIPopoverPresentationControllerDelegate
. - UIKit provides tools that help you create adaptive interfaces such as
UISplitViewController
,UIAppeareance
proxy and Layout Guides. - You can have different images for specific size classes.
- Split View Controller is a handy tool that comes with UIKit; you can use it to display master-detail like layouts.
- Use the
UIAppeareance
proxy when you want to have a consistent user interface attributes across the entire app. - You can use Alignment and Slicing to control how your images stretch and to show specific portions when desired.