Simple Auto Layout in Swift
I haven't had much time to spend with Swift lately but with the release of Swift 1.2 it seemed like a good time to refresh my memory. I have one (very unfinished) project written entirely in Swift that helps me keep track of the changes in the language. This project has come to the point where I can no longer rely on storyboards and nibs for layout so the time had come to look seriously at writing Auto Layout code in Swift.
Now I know there are a few open source libraries that provide convenience methods and/or DSLs for working with Auto Layout in both Swift and Objective C and I'm sure they're mostly brilliant. Regardless, I like to really understand the code I'm writing and as a rule I avoid any library I can write (the subset I require of) myself.
So what are the most common things we need to do when laying out views? This is roughly what I came up with:
- Place a view within a parent pinned relative to the parent (Struts!)
- Place a view within a parent pinned to opposite edges so that it resizes with the parent (Springs!)
- Set the explicit width and or height of a view
- Set the relative width and or height of a view based on a another view
- Place a view relative to another view in the hierarchy
- Align a view with another view within the hierarchy
Of course NSLayoutConstraint
can do all of this (and a lot more) but this seemed to me to be the low hanging fruit, as it were, for simplifying layout code. So with these requirements in mind, and a goal of simplicity and readability above all else, I threw together some code this afternoon.
import UIKit
struct Inset {
let value: CGFloat
let attr: NSLayoutAttribute
init(_ value: CGFloat, from attr: NSLayoutAttribute) {
self.attr = attr;
switch (attr) {
case .Right, .Bottom: self.value = -value
default: self.value = value
}
}
}
extension UIView {
func addSubviews(views: UIView...) {
for view in views {
self.addSubview(view)
}
}
func layoutInside(otherView: UIView, insets: Inset...) -> [NSLayoutConstraint] {
var constraints: [NSLayoutConstraint] = []
for inset in insets {
constraints.append(NSLayoutConstraint(item: self, attribute: inset.attr, relatedBy: NSLayoutRelation.Equal, toItem: otherView, attribute: inset.attr, multiplier: 1.0, constant: inset.value))
}
return constraints
}
func constrainDimension(dimension: NSLayoutAttribute, relation: NSLayoutRelation, constant: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: dimension, relatedBy: relation, toItem: nil, attribute: .NotAnAttribute, multiplier: 1, constant: constant)]
}
func constrainDimension(dimension: NSLayoutAttribute, relation: NSLayoutRelation, toView otherView: UIView, constant: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: dimension, relatedBy: relation, toItem: otherView, attribute: dimension, multiplier: 1, constant: constant)]
}
func constrainWidth(relation: NSLayoutRelation, toWidth constant: CGFloat) -> [NSLayoutConstraint] {
return constrainDimension(.Width, relation: relation, constant: constant)
}
func constrainWidth(relation: NSLayoutRelation, toView otherView: UIView, plus constant: CGFloat) -> [NSLayoutConstraint] {
return constrainDimension(.Width, relation: relation, toView: otherView, constant: constant)
}
func constrainHeight(relation: NSLayoutRelation, toHeight constant: CGFloat) -> [NSLayoutConstraint] {
return constrainDimension(.Height, relation: relation, constant: constant)
}
func constrainHeight(relation: NSLayoutRelation, toView otherView: UIView, plus constant: CGFloat) -> [NSLayoutConstraint] {
return constrainDimension(.Height, relation: relation, toView: otherView, constant: constant)
}
func layoutBelow(otherView: UIView, distance: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: .Top, relatedBy: .Equal, toItem: otherView, attribute: .Bottom, multiplier: 1, constant: distance)]
}
func layoutAfter(otherView: UIView, distance: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: .Left, relatedBy: .Equal, toItem: otherView, attribute: .Right, multiplier: 1, constant: distance)]
}
func alignWith(otherView: UIView, edge: NSLayoutAttribute, offsetBy constant: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: edge, relatedBy: .Equal, toItem: otherView, attribute: edge, multiplier: 1, constant: constant)]
}
func alignBaselineWith(otherView: UIView, offsetBy constant: CGFloat) -> [NSLayoutConstraint] {
return [NSLayoutConstraint(item: self, attribute: .Baseline, relatedBy: .Equal, toItem: otherView, attribute: .Baseline, multiplier: 1, constant: constant)]
}
}
extension Array {
func withPriority(priority: UILayoutPriority) -> [NSLayoutConstraint] {
var members: [NSLayoutConstraint] = []
for member in self {
switch member {
case let constraint as NSLayoutConstraint:
constraint.priority = priority
members.append(constraint)
default: break
}
}
return members
}
}
You might notice that while only one of the functions returns more than one constraint they all return an array. At this point I feel like consistency is more important that any performance hit this may cause but without knowing that much about how Swift allocates arrays and without any real-world benchmarking it's hard to tell. I figure it's very likely I may want to add new functions in the future that return multiple constraints and in my experience constraints are usually built up by combining multiple arrays anyway. Regardless, that's less than 100 lines of code, you can can probably understand most of it it in glance and it makes for some relatively concise and readable layout code:
let v1 = UIView()
v1.setTranslatesAutoresizingMaskIntoConstraints(false)
v1.backgroundColor = UIColor.redColor()
let v2 = UIView()
v2.setTranslatesAutoresizingMaskIntoConstraints(false)
v2.backgroundColor = UIColor.blueColor()
let l1 = UILabel()
l1.setTranslatesAutoresizingMaskIntoConstraints(false)
l1.backgroundColor = UIColor.yellowColor()
l1.text = "Label 1"
let l2 = UILabel()
l2.setTranslatesAutoresizingMaskIntoConstraints(false)
l2.backgroundColor = UIColor.yellowColor()
l2.text = "Label 2"
self.view.addSubviews(v1, v2, l1, l2)
var constraints: [NSLayoutConstraint] = []
constraints += v1.layoutInside(self.view, insets: Inset(120, from: .Top), Inset(60, from: .Left))
constraints += v1.constrainWidth(.Equal, toWidth: 44)
constraints += v1.constrainHeight(.Equal, toHeight: 44)
constraints += v2.constrainWidth(.Equal, toWidth: 64)
constraints += v2.constrainHeight(.Equal, toView: v1, plus: 10).withPriority(500)
constraints += v2.constrainHeight(.Equal, toHeight: 400).withPriority(499) // Is ignored
constraints += v2.alignWith(v1, edge: .Left, offsetBy: 5)
constraints += v2.layoutBelow(v1, distance: 8)
constraints += l1.alignWith(v1, edge: .Left, offsetBy: 0)
constraints += l1.layoutBelow(v2, distance: 40)
constraints += l2.alignBaselineWith(l1, offsetBy: 0)
constraints += l2.layoutAfter(l1, distance: 15)
self.view.addConstraints(constraints)
Which results, roughly, in this. I think that's a pretty big win for a wafer-thin level of abstraction.