Mini Auto Layout DSL
Not too long ago Chris Eidhof posted about making auto layout a bit easier by creating a Micro Auto Layout DSL, hinging on keypaths. In his post Chris suggested we add a few obvious extensions to his work, as an exercise. Sure, but I’d rather not leave it at that. There’s a few more extensions that, I feel, would greatly benefit the expressiveness of the DSL, without growing the code base too much. In addition to being able to constrain an anchor to a constant, and to constrain to a different view, as Chris suggested, I also want to be able to supply a constraint priority, when needed, and I want to be able to create more constraints than only equal to, but without creating a host of separate functions to achieve all this. This will lead me to not only extend Chris’s work, but also modify it here and there.
Since I want this article to stand on itself, I’ll start pretty much from scratch.
The first building block we need is a function type that, given two views, returns a layout constraint. To be able to succinctly refer to that function type we create a typealias for it:
typealias PairedConstraint = (_ view: UIView, _ otherView: UIView) -> NSLayoutConstraint
We also want a function type that returns a layout constraint, given a single view:
typealias UnpairedConstraint = (_ view: UIView) -> NSLayoutConstraint
The next building block to get out of the way is a construct that will allow us to express the magnitude of the constraint relation. For this we use an enum:
enum ConstraintRelation {
case equal, greaterThanOrEqual, lessThanOrEqual
}
Now that we have that out of the way, we want to write a function that, given two layout anchors, returns a PairedConstraint. The basic signature of that function would look like this:
func constraint<Anchor, AnchorType>(_ keyPath: KeyPath<UIView, Anchor>,
_ otherKeyPath: KeyPath<UIView, Anchor>) -> PairedConstraint where Anchor: NSLayoutAnchor<AnchorType>
The function is generic over Anchor
and AnchorType
, where we constrain Anchor to be of type NSLayoutAnchor<AnchorType>
, which will allow it to be either an NSLayoutXAxisAnchor
or an NSLayoutYAxisAnchor
. This enables us to call this function safely when creating horizontal or vertical constraints. If we make a mistake and try to mix types we’ll get a compiler error. How great is that?!
In case you’re unfamiliar with key paths (new in Swift 4): the first generic parameter on KeyPath
is the root type (the type we are querying: UIView
), and the second generic parameter is the type of the value we are asking for (Anchor
, constrained to type NSLayoutAnchor<AnchorType>
).
What we have so far allows us to express basic constraints, but we can’t yet express layout priority nor a multiplier, nor, more importantly, a constraint relation or a constant. I think we can add these features without adding too much cruft, by the power of optional parameters with default values. We expand the function created above to read:
func constraint<Anchor, AnchorType>(_ keyPath: KeyPath<UIView, Anchor>,
_ otherKeyPath: KeyPath<UIView, Anchor>? = nil,
constraintRelation: ConstraintRelation = .equal,
multiplier: CGFloat? = nil,
constant: CGFloat = 0,
priority: UILayoutPriority? = nil) -> PairedConstraint where Anchor: NSLayoutAnchor<AnchorType>
The beauty of providing default values is that we can omit defaulted parameters from the call site when the default is what we need; which is, for most of us, most of the time. By default, the constraint relation has equal magnitude. So we can now write: constraint(\.leftAnchor, \.leftAnchor)
, or constraint(\.leftAnchor, \.centerXAnchor, constant: 10.0)
. If the magnitude is not equal, we provide it as a parameter: constraint(\.leftAnchor, \.centerXAnchor, constraintRelation: .greaterThanOrEqual, multiplier: 0.5)
. It is quite common to constrain views by the same anchors, hence we define otherKeyPath
as optional, with a default value of nil, so that we don’t have to pass in the second key path if it is equal to the first keypath. This allows us to be succinct: constraint(\.leftAnchor)
instead of constraint(\.leftAnchor, \.leftAnchor)
.
We’re building up a nice repertoire for easily creating all manner of layout constraints. But we can’t yet create contraints anchored to a constant, instead of to another constraint and a constant. Let’s fix that:
func constraint<Anchor>(_ keyPath: KeyPath<UIView, Anchor>,
constraintRelation: ConstraintRelation = .equal,
multiplier: CGFloat? = nil,
constant: CGFloat = 0,
priority: UILayoutPriority? = nil) -> UnpairedConstraint where Anchor: NSLayoutDimension
Here, we lose the generic AnchorType
, and return an UnpairedConstraint
whose Anchor
is an NSLayoutDimension
. This allows us to write constraint(\.heightAnchor, constant: 60.0)
or constraint(\.widthAnchor, constant: 250.0, priority: .defaultHigh)
.
Now we come to the actual implementation of the constraint functions. Here we face an issue: We want to be able to keep them nice and short, and take advantage of anchors, rather than building each constraint from scratch manually. Yet, we can’t change a constraint’s multiplier once it’s been created. Also, even though we can change a constraint’s priority after creation, we can’t supply it when creating constraints through the family of anchor based creator functions. So, we write a helper function:
func constraint(from constraint: NSLayoutConstraint,
withMultiplier multiplier: CGFloat? = nil,
priority: UILayoutPriority?) -> NSLayoutConstraint {
var constraint = constraint
if let multiplier = multiplier {
constraint = NSLayoutConstraint(item: constraint.firstItem as Any,
attribute: constraint.firstAttribute,
relatedBy: constraint.relation,
toItem: constraint.secondItem,
attribute: constraint.secondAttribute,
multiplier: multiplier,
constant: constraint.constant)
}
if let priority = priority {
constraint.priority = priority
}
return constraint
}
This function checks for a multiplier. If supplied, it recreates the layout constraint the longwinded way (affording complete control, which is what we need here, for passing in the multiplier). Then it checks for a priority, and sets it if supplied. With this helper function in place, we can finally implement the constraint functions defined above.
Here is the first one, for creating constraints from related anchors:
func constraint<Anchor, AnchorType>(_ keyPath: KeyPath<UIView, Anchor>,
_ otherKeyPath: KeyPath<UIView, Anchor>? = nil,
constraintRelation: ConstraintRelation = .equal,
multiplier: CGFloat? = nil,
constant: CGFloat = 0,
priority: UILayoutPriority? = nil) -> PairedConstraint where Anchor: NSLayoutAnchor<AnchorType> {
return { view, otherView in
var partialConstraint: NSLayoutConstraint
let otherKeyPath = otherKeyPath ?? keyPath
switch constraintRelation {
case .equal:
partialConstraint = view[keyPath: keyPath].constraint(equalTo: otherView[keyPath: otherKeyPath], constant: constant)
case .greaterThanOrEqual:
partialConstraint = view[keyPath: keyPath].constraint(greaterThanOrEqualTo: otherView[keyPath: otherKeyPath], constant: constant)
case .lessThanOrEqual:
partialConstraint = view[keyPath: keyPath].constraint(lessThanOrEqualTo: otherView[keyPath: otherKeyPath], constant: constant)
}
return constraint(from: partialConstraint,
withMultiplier:multiplier,
priority: priority)
}
}
The optional otherKeyPath
is unwrapped. If it is nil, we set otherKeyPath
to the first keyPath, otherwise to the passed in keyPath. Then, we switch
on the relation to create the right type of constraint, and finally call our helper function passing in the constraint we just created, and the two properties that we couldn’t set when creating the constraints.
Next is the implementation of the function that creates constraints anchored to a constant:
func constraint<Anchor>(_ keyPath: KeyPath<UIView, Anchor>,
constraintRelation: ConstraintRelation = .equal,
multiplier: CGFloat? = nil,
constant: CGFloat = 0,
priority: UILayoutPriority? = nil) -> UnpairedConstraint where Anchor: NSLayoutDimension {
return { view in
var partialConstraint: NSLayoutConstraint
switch constraintRelation {
case .equal:
partialConstraint = view[keyPath: keyPath].constraint(equalToConstant: constant)
case .greaterThanOrEqual:
partialConstraint = view[keyPath: keyPath].constraint(greaterThanOrEqualToConstant: constant)
case .lessThanOrEqual:
partialConstraint = view[keyPath: keyPath].constraint(lessThanOrEqualToConstant: constant)
}
return constraint(from: partialConstraint,
withMultiplier:multiplier,
priority: priority)
}
}
As you can see, this is very similar to the function that returns PairedConstraints. That’s it for the constraint creation helpers. What is left, is to provide a convenient way to add a subview along with the desired constraints, and a way to constrain one view to another view, and also a way to set internal constraints on a view. For that we’ll create an extension on UIView
:
extension UIView {
func addSubview(_ child: UIView, pairingTo pairingView: UIView? = nil, constraints: [PairedConstraint]) {
addSubview(child)
child.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(constraints.map { $0(child, pairingView ?? self) })
}
func constrainToView(_ pairingView: UIView, constraints: [PairedConstraint]) {
NSLayoutConstraint.activate(constraints.map { $0(self, pairingView) })
}
func constrain(to constraints: [UnpairedConstraint]) {
NSLayoutConstraint.activate(constraints.map { $0(self) })
}
}
The first function allows us to add a subview, along with a view to which the constraints are to be related. The related view parameter defaults to nil. If a related view is not supplied, we relate to self
, i.e. the superview. The second function allows us to use our setup to easily constrain two views to each other, when there is no child-parent relationship, or when the views are already in a parent-child relationship. Finally, the third function simply adds unpaired constraints to a view.
With all this in place (in a bit under a hundred lines of code), we can now create a view and add it as a subview to another view like this:
view.addSubview(greenView, constraints: [
constraint(\.leftAnchor, constant: 20.0, priority: .required),
constraint(\.rightAnchor, constant: -20.0),
constraint(\.topAnchor, constant: 220.0),
constraint(\.bottomAnchor, constant: -20.0)
])
To add a subview, but constrain it to another view than its parent:
view.addSubview(label, pairingTo:greenView, constraints: [
constraint(\.centerXAnchor),
constraint(\.centerYAnchor),
])
view.addSubview(purpleView, pairingTo:greenView, constraints: [
constraint(\.leftAnchor, \.centerXAnchor),
])
To simply constrain two views to each other:
purpleView.constrainToView(label, constraints: [
constraint(\.widthAnchor, constraintRelation: .greaterThanOrEqual, multiplier: 0.5),
constraint(\.topAnchor, \.bottomAnchor, constant: 10.0, priority: UILayoutPriority(500))
])
And, finally, to constrain a view anchor to a constant:
label.constrain(to: [
constraint(\.widthAnchor, constant: 250.0, priority: .defaultHigh)
])
purpleView.constrain(to: [
constraint(\.heightAnchor, constant: 60.0)
])
And that’s it. Without a huge amount of code, we now have a flexible tool to succinctly express the vast majority of layout constraints we may need. The only thing I miss here is autocompletion on the key paths.
You can find the full source code in a gist here.