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 height
  • multiple: 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.