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:
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.
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.
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.
Select all three labels, and click the stack view button.
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:
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.
Select Equal Widths from the menu that appears, then in the auto layout resolver select Update Frames. Your story board should look like this:
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
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
We get the device as an iPhone, a compact width, and a regular height. Rotate the device and you get this:
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.
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.
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.
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.
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.
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() } }
Leave a Reply