Building a Lightweight Auto Layout DSL for iOS Development
Creating user interfaces programmatically in iOS offers significant advantages over Interface Builder, but Apple's Auto Layout API can be quite verbose and cumbersome to work with. While Apple recommends using Interface Builder whenever possible, many developers prefer the flexibility and maintainability that comes with programmatic layouts.
Several third-party frameworks like SnapKit, PureLayout, and Anchorage have emerged to address this verbosity issue. However, these solutions come with their own drawbacks: dependency management overhead, additional imports, and potential code bloat when you only need simple layout functionality.
For smaller projects with straightforward layout requirements, I wanted to explore creating a minimal, self-contained Auto Layout DSL that could be easily copied into any project without external dependencies.
The Problem We're Solving
Consider this typical Auto Layout code that adds a subview and constrains all its edges to match 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
This verbose approach becomes unwieldy quickly. The ideal would be something more concise:
subview.place(on: view).pin(.allEdges)
This simplified syntax eliminates much of Apple's verbose terminology, replacing "constraint" and "anchor" with more intuitive terms like "pin" and "edge."
Foundation: Wrapping NSLayoutConstraint
The starting point is NSLayoutConstraint itself, which has a complex initializer with many parameters. I created a convenience initializer to focus on the essential parameters:
extension NSLayoutConstraint {
convenience init(item: Any,
attribute: Attribute,
toItem: Any? = nil,
toAttribute: Attribute = .notAnAttribute,
constant: CGFloat) {
self.init(item: item, // first view
attribute: attribute, // leading, trailing etc.
relatedBy: .equal, // is equal to
toItem: toItem, // superview or other view
attribute: toAttribute, // leading, trailing
multiplier: 1, // default is 1
constant: constant) // with padding
}
}
Core Architecture: The Constraint Enum
The foundation of this DSL is an enum with associated values that align with our convenience initializer:
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 Anchorable
protocol to expand beyond just UIView:
protocol Anchorable {}
extension UIView: Anchorable {}
extension UILayoutGuide: Anchorable {}
This enables constraints to layout guides like view.pin(to: superview.safeAreaLayoutGuide)
.
Each enum case serves a specific purpose:
relative
: Handles relationships between two views or attributes, including cross-attribute constraints (top to bottom, leading to trailing)fixed
: Sets absolute dimensions like width and heightmultiple
: Groups multiple constraints for convenience methods like.allEdges
The Place Method
The place
method simplifies the common pattern of adding a subview and preparing it for Auto Layout:
@discardableResult
func place(on view: UIView) -> UIView {
view.addSubview(self)
self.translatesAutoresizingMaskIntoConstraints = false
return self
}
This method handles subview addition and disables autoresizing mask translation in one call, returning self for method chaining.
The Pin Method
The pin
method applies constraints using a variadic parameter:
@discardableResult
func pin(_ constraints: Constraint...) -> UIView {
self.translatesAutoresizingMaskIntoConstraints = false
apply(constraints)
return self
}
Variadic parameters eliminate the need for array brackets, making the API cleaner:
// Clean syntax
view.pin(.top, .leading, .trailing, .bottom)
// Instead of array syntax
view.pin([.top, .leading, .trailing, .bottom])
The core logic happens in the private apply
method:
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)
}
}
}
This method translates our enum cases into actual NSLayoutConstraint instances, providing sensible defaults when optional parameters are omitted.
Factory Functions and Constants
Convenience factory functions create specific constraint types:
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)
}
To avoid empty parentheses in the API, static constants call these functions with default parameters:
static let top: Constraint = .top()
static let bottom: Constraint = .bottom()
This enables clean syntax like view.pin(.top)
instead of view.pin(.top())
.
Improved Developer Experience
By explicitly naming the padding parameter, Xcode's autocomplete becomes more helpful, especially when padding constants exist in scope:
let padding = CGFloat(20)
view.pin(.top(padding: padding)) // Xcode suggests the padding constant
Practical Usage Examples
The DSL supports various common layout scenarios:
// Basic edge pinning with padding
subview.place(on: view).pin(.allEdges(padding: 20))
// Individual edge constraints
subview.place(on: view).pin(.top, .leading, .trailing, .bottom)
// Cross-attribute constraints
subview.pin(.top(to: anotherView, .bottom, padding: 10))
// Layout guide constraints
subview.pin(.top(to: view.safeAreaLayoutGuide),
.bottom(to: view.safeAreaLayoutGuide),
.leading, .trailing)
// Fixed dimensions with centering
subview.pin(.fixedHeight(50), .fixedWidth(50), .centerX, .centerY)
// Horizontal edges only
subview.pin(.horizontalEdges)
Final Thoughts
This lightweight DSL comprises just 147 lines of code, primarily factory functions and convenience methods. It eliminates the need for external dependencies while significantly reducing the verbosity of Apple's Auto Layout API. The approach provides a clean, chainable interface that makes programmatic layout code more readable and maintainable.
For simple to moderate layout requirements, this solution offers an excellent balance between functionality and simplicity, proving that sometimes a custom, lightweight approach can be more effective than heavy third-party frameworks.