Programmatic layout is great, but Apple’s Auto Layout is a bit heavy. Apples take on this:
Whenever possible, use Interface Builder to set your constraints.
Not the best advice considering the previously discussed benefits of programmatic layouts.
Many frameworks exist to alleviate the problem. SnapKit, PureLayout and Anchorage are all viable options.
In most cases introducing relevant dependencies is fine and has many benefits but the negatives are:
- requiring dependency management (SPM, CocoaPods, Carthage)
- adding additional imports
- unneeded code bloat, such as importing Alamofire for one simple network request
I build many little projects. Most of my layouts are simple. So I don’t want the burden of an external dependency.
I wondered if I could build a simple, one file, auto layout DSL (Domain Specific Language) that I could copy and paste into my projects.
If you want to skip to the result check out the file.
Goal
The goal is to replace lengthy repetitive code with short concise code. Take the below example where I add a subview and pin all its edges to its superview.
view.addSubview(subview)
subview.translatesAutoresizingMaskIntoConstraints = false
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
subview.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
I’d prefer to do this.
subview.place(on: view).pin(.allEdges)
It declutters Apples API. Where they use words like “constraint” and “anchor”, I use “pin” and “edge”. The words are largely interchangeable but I like the fact “pin” is six fewer characters than “constrain”. (Ironically Apple’s documentation often talks about “pinning edges” rather than “constraining to anchors”).
I also plan to account for some common scenarios such as:
- Padding
- Pinning to views that aren’t the superview
- Pinning to layout guides
- Pinning top edges to bottom edges or leading edges to trailing edges
- Fixing heights and widths
I’ll omit more complex scenarios for now.
NSLayoutConstraint
NSLayoutConstraint
is our starting point. Its initialiser takes many parameters. I wrapped the initialiser in a convenience initialiser to reduce code and focus on the parameters I care about.
Here’s how it looks with comments laying out (pardon the pun) what’s happening in a constraint.
extension NSLayoutConstraint {
convenience init(item: Any, attribute: Attribute, toItem: Any? = nil, toAttribute: Attribute = .notAnAttribute, constant: CGFloat) {
self.init(item: item, // first views
attribute: attribute, // leading, trailing etc.
relatedBy: .equal, // is equal to
toItem: toItem, // superview or other views
attribute: toAttribute, // leading, trailing
multiplier: 1, // default is 1
constant: constant) // with padding
}
}
Building Blocks
The building block of my DSL is an enum with associated values. The associated values align with the convenience initialiser I created. Meaning I can build constraints from my enum.
enum Constraint {
case relative(NSLayoutConstraint.Attribute, CGFloat, to: Anchorable? = nil, NSLayoutConstraint.Attribute? = nil)
case fixed(NSLayoutConstraint.Attribute, CGFloat)
case multiple([Constraint])
}
I also introduced the protocol Anchorable
, i.e. something that can be anchored to. Originally this was just UIView
but to increase the scope of my DSL I extend UIView
and UILayoutGuide
to conform to Anchorable
. This allows for code such as
view.pin(to: superview.safeAreaLayoutGuide)
Back to the constraint enum. Looking at it case by case.
relative
has associated values for twoNSLayoutConstraint.Attributes
. This allows for pinning an edge of one type to that of another type (e.g. leading to trailing or top to bottom). The second attribute is optional. If it isnil
I pin the same attribute type i.e. leading to leading.relative
also has associated valuesAnchorable?
, in case I want to pin to a view other than superview, and aCGFloat
for padding.fixed
has associated values for an attribute andCGFloat
. It is used to pin fixed widths and heights, which don’t need the additional attributes provided byrelative
.multiple
has the associated value of an array ofConstraint
s. It will provide the power to create convenient helpers like.pin(to: .allEdges)
that use multiple constraints.
The Constraint
means we can create more concise versions of NSLayoutConstraint
.
.relative(.top, 10, to: view, .bottom)
Creating the above instance from inside a UIView
would provide all the data we need to build a full NSLayoutConstraint
and apply it.
Place
The place method is in extension on UIView
. It looks like this
@discardableResult
func place(on view: UIView) -> UIView {
view.addSubview(self)
self.translatesAutoresizingMaskIntoConstraints = false
return self
}
It is a simple alias for adding a subview and turning off translatesAutoresizingMaskIntoConstraints
, which we want for programmatic layouts. We return the view so that we can chain the pinning method e.g.
view.place(on: superview).pin(.fixedHeight(50))
Using @discardableResult
means the place method can be used alone while preventing the Xcode warning
Result of call to 'place(on:)' is unused
Pin
The pin method is also in an extension on UIView
. It looks like this.
@discardableResult
func pin(_ constraints: Constraint...) -> UIView {
self.translatesAutoresizingMaskIntoConstraints = false
apply(constraints)
return self
}
It is another opportunity to turn off translatesAutoresizingMaskIntoConstraints
just in case addSubview
was used instead of place(on:)
before pinning.
The pin method takes one variadic parameter of type Constraint
. The variadic parameter takes 0 or more values and creates an array of those values in your function body. The benefit of using a variadic parameter instead of an array is that consumers of the API need not include angular braces.
// array
view.pin([.top, .leading, .trailing, .bottom])
// variadic parameter
view.pin(.top, .leading, .trailing, .bottom)
The pin method also returns the view after constraints are applied just in case someone is aggressively verbose e.g. view.pin(.top).pin(.bottom)
.
The main point of the pin method is to call apply(constraints)
. apply
is private to the UIView
extension. It loops through an array of provided constraints, creating actual NSLayoutConstraint
s and making them active.
private func apply(_ constraints: [Constraint]) {
for constraint in constraints {
switch constraint {
case .relative(let attribute, let constant, let toItem, let toAttribute):
NSLayoutConstraint(item: self,
attribute: attribute,
toItem: toItem ?? self.superview!,
toAttribute: toAttribute ?? attribute,
constant: constant).isActive = true
case .fixed(let attribute, let constant):
NSLayoutConstraint(item: self,
attribute: attribute,
constant: constant).isActive = true
case .multiple(let constraints):
apply(constraints)
}
}
}
Each Constraint
case provides the necessary data to build the NSLayoutConstraint
. When certain optionals are omitted we fall back to defaults. If no toItem
is provided, it assumes the consumer means the superview. When no toAttribute
is provided, it assumes the consumer means to pin attribute to attribute, e.g. leading to leading.
This is what gives the consumer convenient ways to create simple constraints.
view.pin(.relative(.top, 10))
view.pin(.relative(.top, 10, to: anotherView))
view.pin(.relative(.top, 10, to: anotherView, .bottom))
Factory Functions
The final pieces of the puzzle are the convenient factory functions that create specific constraints. All the factory functions create various instances of the Constraint
enum. Here is an example.
static func top(to anchors: Anchorable? = nil, padding: CGFloat = 0) -> Constraint {
.relative(.top, padding, to: anchors)
}
static func bottom(to anchors: Anchorable? = nil, padding: CGFloat = 0) -> Constraint {
.relative(.bottom, -padding, to: anchors)
}
The factory functions make the API more concise.
One problem is if consumers omit all parameters the resultant API looks like .pin(to: .top())
. The parentheses in top()
are superfluous. That’s why static constants are included that call our functions with no parameters like so
static let top: Constraint = .top()
static let bottom: Constraint = .bottom()
Now consumers can omit the ()
.
view.pin(.top)
This pins the top of the view to its superview.
Explicit Padding
Note that our static functions force consumers to use the parameter name padding
. I originally omited this but found that Xcode’s predictive typing was pretty poor with multiple unnamed parameters. By adding the padding parameter name, Xcode’s autocomplete provides padding
and looks for CGFloat
s. This is useful when something like let padding = CGFloat(20)
exists as Xcode will predict it.
Conclusion
I am pretty happy with it. It’s 147 lines of predominantly factory-style code that will save me a lot of time creating simple layouts and mean I won’t need to download dependencies.
The file is stored in this repo. Here’s a snapshot of some things it can do.
// place subview on view an pin all edges i.e. top, bottom, leading, trailing
subview.place(on: view).pin(.allEdges)
// as above with padding
subview.place(on: view).pin(.allEdges(padding: 20))
// as above with no padding and using specific parameters
subview.place(on: view).pin(.top, .leading, .trailing, .bottom)
// if you don't want to chain constraints after placement, simply place first
subview.place(on: view)
// pin top edge to a different views bottom with padding
subview.pin(.top(to: anotherView, .bottom, padding: 10))
// pin horizontal edges to superview
subview.pin(.horizontalEdges)
// pinning top and bottom edges to layout guide and to superview leading and trailing
subview.pin(.top(to: view.safeAreaLayoutGuide),
.bottom(to: view.safeAreaLayoutGuide),
.leading,
.trailing)
// pinning fixed height and width in center of superview
subview.pin(.fixedHeight(50), .fixedWidth(50), .centerX, .centerY)