Using Trait Collections for Auto Layout and Size Classes

Suppose you have a project with adaptive layout, but you hate storyboards. You’ve coded all your layout and then find something horrible: it only codes well in portrait on an iPhone. You’ve decided that some of the elements need to be in a different place in portrait and completely different places on an iPad.  In this lesson, we’ll learn how to use size classes programmatically.

A Quick Review of Size Classes

Starting in iOS8, Apple included Size Classes in Xcode. This is a short hand way of describing any iOS device and orientation in only two sizes: compact and regular. It simplifies describing devices from using real resolutions. As of iOS 9.1, here is a chart of size classes and devices to give you an idea of the variety:

sizeClassesChartiOS9_1

Size classes is more than devices. As seen in the chart, Multitasking on the iPad also has a series of class sizes for the multitasking windows.

The other change with size classes was in code. in iOS8, Apple deprecated popover and alert views among others and made them all modal views. All modal views use presentViewController, which in turn looks at the horizontal size class and decides how to present the view. This allows popovers on any device instead of only iPad, though they present as a modal .Fullsheet on a compact width iPhone. The .Formsheet modal will do the same. The layout adapts to the size class.

Trait Collections

If you want to control how your phone sets views, you need to know the size class of your device. There is a protocol UITraitCollectionEnvironment adopted by views and view controllers which we use to get this information. The protocol has one property traitCollection and one empty method we can override traitCollectionDidChange:. The property is of type UITraitCollection, which has properties about size and devices. For size classes, it has horizontalSizeClass and verticalSizeClass to describe the device’s size class with an enumeration UIUserInterfaceSizeClass. It describes devices with three properties. The userInterfaceIdiom indicates of the device is an iPhone, iPad or Apple TV. The displayScale is a number indicating the resolution, where 2.0 is a retina display. The newest addition is forceTouchCapability which tells if force touch is available on the device.

The traitCollectionDidChange: runs any time we make a change to the size classes. Most likely this will be during a rotation. We override this method to do any layout we need on rotation to a new layout we decide on from the current trait collection values.

Make a New Project

Make a new Swift project with a Universal device called SwiftTraitCollectionDemo. In the deployment info that appears, check on all the boxes to allow rotation in any orientation.

2015-11-25_09-07-15

Set up the launchscreen.storyboard by adding a label with the text Swift Trait Collection Demo. In the alignment menu for auto layout, center horizontally and vertically by checking on the two checkboxes.

2015-11-04_07-01-51

Select  Items of New Constraints and add the constraints. You don’t have to do set up your launch screen, but it certainly helps keep track of it.

Go to the storyboard. Change the background of the storyboard to  (#FFFFCC). Set up the storyboard by dragging three labels titled Device, Height, and Width to the storyboard, one under the other.

2015-11-25_09-33-39

Select all three labels, and click the stack view button.stackButton

Immediately click the Auto layout Alignment button again and center the stack view like you did the launch screen label. Set the attributes for the stack view to this:

2015-11-25_09-07-15_01

Add a button to the upper left label with the title My Button. Make it with a Blue(#0000CC) background with White(#FFFFFF) lettering. Using the pin menu, pin it 0 up and 0 left. Once again, update the views with the new constraints. In the document outline, control-drag from the button to the stack view.

2015-11-25_09-07-16

Select Equal Widths from the menu that appears, then in the auto layout resolver select Update Frames. Your story board should look like this:

2015-11-25_10-01-09

Adding Outlets for Views and Constraints

Open the Assistant editor. Select the stack view. You can do this either from the document outline or by shift-right clicking over the stack view and selecting Stack View in the menu that appears. Control-drag from the stack view to the assistant editor code. Make make an outlet named rootStackView. Select each of labels and make outlets for deviceLabel, heightLabel, and widthLabel. You’ll have the following outlets.

@IBOutlet weak var rootStackView: UIStackView!
@IBOutlet weak var deviceLabel: UILabel!
@IBOutlet weak var heightLabel: UILabel!
@IBOutlet weak var widthLabel: UILabel!

The next step you may not have done before. In the document outline, open up the constraints

2015-11-25_09-12-00

You’ll find each of the constraints we’ve made between views listed here. Select the My Button Width constraint. Control drag it to the assistant editor and make an outlet for the constraint named myButtonWidth. Yes, you can make outlets for constraints. Instead of coding all our views or setting views by size class in the storyboard, we’ll use the outlets for the constraints to change our layout programmatically depending on size class. Select the Root Stack View Center Y constraint. Control-drag again to the code and add an outlet rootStackViewCenterY. Do the same for the Root Stack View Center X constraint so we have the following three constraints:

@IBOutlet weak var myButtonWidth: NSLayoutConstraint!
@IBOutlet weak var rootStackViewCenterY: NSLayoutConstraint!
@IBOutlet weak var rootStackViewCenterX: NSLayoutConstraint!

Close the assistant editor and open up the Viewcontroller.Swift file.

Showing the Size Class and Device

Our first iteration will update the label to tell us the size class and device. However, the trait collection properties are enumerated values, not strings. We’ll need two methods to convert those values into strings. Add this to your code:

func sizeClassText(sizeClass: UIUserInterfaceSizeClass) ->String {
switch sizeClass{
case .Compact:
return "Compact"
case .Regular:
return "Regular"
default:
return "Unspecified"
}
}

func deviceText(deviceType: UIUserInterfaceIdiom ) ->String {
switch deviceType{
case .Phone:
return "iPhone"
case .Pad:
return "iPad"
case .TV:
return "Apple TV"
default:
return "Unspecified"
}
}

We use the switch...case control structure to return a string for the enumerated value. We’ll make a function to update the three labels by calling one of the functions we just made from one of the trait collections.

func displayTraitCollection(){
deviceLabel.text = "Device:" + deviceText(traitCollection.userInterfaceIdiom)
heightLabel.text = "Height:" + sizeClassText(traitCollection.verticalSizeClass)
widthLabel.text = "Width:" + sizeClassText(traitCollection.horizontalSizeClass)
}

We get the traitCollection of our view controller, and then use one of its properties to the label’s text. Add the following code:

override func traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
displayTraitCollection()
}

The traitCollectionDidChange will run every time we change the orientation of the device. To make sure any other parent views get their chance, we always call super first. Then we run our display method.

We need to initialize our labels with correct values. Change viewDidLoad to this:

override func viewDidLoad() {
super.viewDidLoad()
displayTraitCollection()
}

Select an iPhone 6 Plus or iPhone 6s Plus for a simulator. We’ll use it to test since it acts like an iPad in landscape and an iPhone in portrait due to its size classes. Build and run

 

2015-11-25_10-02-20

We get the device as an iPhone, a compact width, and a regular height. Rotate the device and you get this:

2015-11-25_10-02-29

we get a regular width, but a compact height.

Changing Constraints by Size Class

Since we know size class, using if..else we can change the layout of our app depending on the layout. One of the key layout differences between devices is the horizontal class size. phones in portrait and all but the iPhone plus are compact in width. All iPads and the iPhone plus are regular in width. Your code uses this class size to decide what kind of modal view to present from presentViewController. For our demo app, we’ll move the stack view down if we are in compact for the horizontal. We’ll make the button bigger and move the stack view to the left if we have a regular horizontal class. Change displayTraitCollection to this:

func displayTraitCollection(){
deviceLabel.text = "Device:" + deviceText(traitCollection.userInterfaceIdiom)
heightLabel.text = "Height:" + sizeClassText(traitCollection.verticalSizeClass)
widthLabel.text = "Width:" + sizeClassText(traitCollection.horizontalSizeClass)

if traitCollection.horizontalSizeClass == .Regular{
myButtonWidth.constant = 100
rootStackViewCenterX.constant = -150
rootStackViewCenterY.constant = 0
} else {
myButtonWidth.constant = 0
rootStackViewCenterX.constant = 0
rootStackViewCenterY.constant = 200
}
updateViewConstraints()
}

If the horizontal size class is regular, then we increase the size of the button by 100 points. We also move our stack view 200 points to the left. The constant adds more points to the base value for the constraint. Since this constraint is a center alignment constraint moving to the left is a negative value. If we are either compact or unspecified in our horizontal size class we move down 200 points. After our if...else we update the constraints. You must do this to see any effect. Build and run and now our two orientations look  different.

2015-11-25_10-19-36 2015-11-25_10-19-49

Change our code one more time to change the stack view’s axis from .Horizontal to .Vertical depending on the size class.

if traitCollection.horizontalSizeClass == .Regular{
rootStackView.axis = .Horizontal
myButtonWidth.constant = 100
rootStackViewCenterX.constant = -150
rootStackViewCenterY.constant = 0
} else {
rootStackView.axis = .Vertical
myButtonWidth.constant = 0
rootStackViewCenterX.constant = 0
rootStackViewCenterY.constant = 200
}

Build and run. The portrait does not change, but the landscape changes.

2015-11-25_10-25-17

Notice that the button is even bigger this time. Remember the button starts at the same size as the stack view. The stack view became wider as a horizontal stack view, and the button is 100 points bigger than that due to its constant.

One More Bug: Landscape in iPhone

I’m not using proportional values here, but absolute ones and that could be dangerous. The values we use might not work. Change your simulator to an iPad Air and run the code again.

2015-11-25_10-28-34 2015-11-25_10-28-45

An iPad is regular width in both landscape and portrait. We get a regular width layout for both. Stop the app, and change to an iPhone 5s. Run again.

2015-11-25_10-30-51 2015-11-25_10-30-58

Portrait is fine, but our labels  slipped off the bottom in landscape. We had too big a constant. There’s several ways to fix this. Probably the best is another size class. Add this under the line rootStackViewCenterY.constant = 200

if traitCollection.verticalSizeClass == .Compact{
rootStackViewCenterY.constant = 0
}

If we are compact height and compact width, we are an iPhone in landscape. In that case we’ll keep centered vertically. Run the iPhone 5s in landscape again and it works fine.

2015-11-25_10-33-05

There’s a lot more to cover in size classes. I used some constraints to move my layout around to keep the code small,  but using auto layout in code is certainly an option. Understanding them on the storyboard is a highly suggested idea before working with them in code. For more on size classes on the storyboard, I suggest my book Practical Autolayout for Xcode 7 available on iBooks and Kindle.

The Whole Code

//
// ViewController.swift
// SwiftTraitCollectionDemo
//
// Created by Steven Lipton on 11/23/15.
// Copyright © 2015 MakeAppPie.Com. All rights reserved.
//

import UIKit

class ViewController: UIViewController {
//MARK: Outlets

// View outlets
@IBOutlet weak var rootStackView: UIStackView!
@IBOutlet weak var deviceLabel: UILabel!
@IBOutlet weak var heightLabel: UILabel!
@IBOutlet weak var widthLabel: UILabel!

// Constraint outlets
@IBOutlet weak var myButtonWidth: NSLayoutConstraint!
@IBOutlet weak var rootStackViewCenterY: NSLayoutConstraint!
@IBOutlet weak var rootStackViewCenterX: NSLayoutConstraint!

//MARK: - Instance Methods

//Methods to convert the enumerations to strings
func sizeClassText(sizeClass: UIUserInterfaceSizeClass) ->String {
switch sizeClass{
case .Compact:
return "Compact"
case .Regular:
return "Regular"
default:
return "Unspecified"
}
}

func deviceText(deviceType: UIUserInterfaceIdiom ) ->String {
switch deviceType{
case .Phone:
return "iPhone"
case .Pad:
return "iPad"
case .TV:
return "Apple TV"
default:
return "Unspecified"
}
}

//make the changes to the view based on the class sizes
func displayTraitCollection(){
//Add the traits to the labels
deviceLabel.text = "Device:" + deviceText(traitCollection.userInterfaceIdiom)
heightLabel.text = "Height:" + sizeClassText(traitCollection.verticalSizeClass)
widthLabel.text = "Width:" + sizeClassText(traitCollection.horizontalSizeClass)
//Make Changes to the layout

//Regular horizontal size class -- iPad, iPhone 6(s) Plus landscape
if traitCollection.horizontalSizeClass == .Regular{
rootStackView.axis = .Horizontal
myButtonWidth.constant = 100
rootStackViewCenterX.constant = -150
rootStackViewCenterY.constant = 0

// if an iPhone 6(s) Plus in landscape needs to act like
// all other phones uncomment this code
/*
if traitCollection.verticalSizeClass == .Compact{
rootStackViewCenterY.constant = 0
}
*/
} else {
// Compact width size class - iPhones except above.
rootStackView.axis = .Vertical
myButtonWidth.constant = 0
rootStackViewCenterX.constant = 0
rootStackViewCenterY.constant = 200
// iPhone in landscape
if traitCollection.verticalSizeClass == .Compact{
rootStackViewCenterY.constant = 0
}
}
updateViewConstraints()
}

override func traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
displayTraitCollection()
}
//MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
displayTraitCollection()
}

}

One thought on “Using Trait Collections for Auto Layout and Size Classes”

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s