Mini Auto Layout DSL Revisited

After the previous post, and despite the positive feedback, I wasn’t quite satisfied yet with the result and felt the code could do with some improvement. As I looked again at the NSLayoutAnchor header, I realised I had left out a bit of functionality. I also wanted to see if I could manage one more simplification. The end result is that, after some refactoring, the API surface has been slightly reduced, and, while the code has gained a few lines, it is more correct and more flexible.

I wanted to post about this refactoring sooner, but having sustained two broken ribs about a week ago slowed me down a bit… But anyway, here it is.

If you haven’t read the original post be sure to do so. It will make it easier to follow along here.

The first thing I wanted to do, which is something Chris also suggested, was to remove the distinction between PairedConstraint and UnpairedConstraint:

typealias Constraint = (_ view: UIView, _ otherView: UIView?) -> NSLayoutConstraint

This leaves us with a single type: Constraint, which takes two UIViews and returns a layout constraint. The second view (otherView) was made optional, because some constraints are not relative to other views. This compensates for the removal of the UnpairedConstraint type.

The definition of the constraint function for axis-relative constraints remains largely the same:

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) -> Constraint where Anchor: NSLayoutAnchor<AnchorType> {
    return { view, otherView in
        guard let otherView = otherView else { fatalError("Pairing view missing")}
        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 fullConstraint(from: partialConstraint, withMultiplier:multiplier, priority: priority)
    }
}

It gained only a single line: since the closure’s second view parameter is now optional, we need to unwrap it. If unwrapping fails, we cop out, because not supplying a second view for this type of constraint is a programmer error.

The second constraint function, which creates constraints for NSLayoutDimension based anchors gets more heavily refactored, since this is where we add the missing functionality. In its previous incarnation the function could create constraints directly on constants, but I had left out the possibility of creating constraints based on another anchor and a constant. So lets fix that:

The function will need to either create a constraint of the equalToConstant: type (as it already did), or of the equalTo:otherAnchor, constant: type. To avoid a fair amount of duplication and typing, we define a nested function that will do the work of actually creating the constraint:

        func constraint(otherView: UIView,
                        otherKeyPath: KeyPath<UIView, Anchor>?) -> NSLayoutConstraint {
            if let otherKeyPath = otherKeyPath {
                switch constraintRelation {
                case .equal:
                    return view[keyPath: keyPath].constraint(equalTo:otherView[keyPath: otherKeyPath], constant: constant)
                case .greaterThanOrEqual:
                    return view[keyPath: keyPath].constraint(greaterThanOrEqualTo:otherView[keyPath: otherKeyPath], constant: constant)
                case .lessThanOrEqual:
                    return view[keyPath: keyPath].constraint(lessThanOrEqualTo:otherView[keyPath: otherKeyPath], constant: constant)
                }
            } else {
                switch constraintRelation {
                case .equal:
                    return view[keyPath: keyPath].constraint(equalToConstant: constant)
                case .greaterThanOrEqual:
                    return view[keyPath: keyPath].constraint(greaterThanOrEqualToConstant: constant)
                case .lessThanOrEqual:
                    return view[keyPath: keyPath].constraint(lessThanOrEqualToConstant: constant)
                }
            }
        }

It takes a view, which is the related view, and an optional keyPath to the related anchor. The other parameters needed within the function body are inherited from the surrounding context. This saves quite a bit of hassle. The function first checks for the existence of a related key path. If there is none, we create the constant based constraint, as in the previous version of the code, ignoring the otherView, which is not needed here. If we did receive an otherKeyPath, we unwrap it and use it to create a constraint related to that key path and a constant, relative to the view we received in otherView.

What is left, is to call the internal function, which can be done quite succinctly, passing in correct values for otherView and otherKeyPath:

        constraint(otherView: otherView ?? view,
                   otherKeyPath: otherView == nil ? otherKeyPath : otherKeyPath ?? keyPath)

If the outer function was called with an otherView we pass it along to the inner function. If the otherView is nil, we pass along the base view.

For otherKeyPath, if the outer function did not receive an otherView we pass in the otherKeyPath, which may or may not be nil. If we did receive another view, we are definitely creating a constraint based on a related anchor. Therefore we must pass in another key path. If one is upplied, we pass it along, if none is supplied, we use the same key path as that for the base view. This last trick allows us to create constraints without having to retype the anchor key path, thus simplifying the call site. I wrote it using a combination of the ternary and the nil coalescing operators. While I’m usually wary of doing that, I felt that, in this instance, the result is compact and quite readable.

Putting the pieces together, the whole function becomes:

func constraint<Anchor>(_ keyPath: KeyPath<UIView, Anchor>,
                        _ otherKeyPath: KeyPath<UIView, Anchor>? = nil,
                        constraintRelation: ConstraintRelation = .equal,
                        multiplier: CGFloat? = nil,
                        constant: CGFloat = 0,
                        priority: UILayoutPriority? = nil) -> Constraint where Anchor: NSLayoutDimension {
    return { view, otherView in
        func constraint(otherView: UIView,
                        otherKeyPath: KeyPath<UIView, Anchor>?) -> NSLayoutConstraint {
            if let otherKeyPath = otherKeyPath {
                switch constraintRelation {
                case .equal:
                    return view[keyPath: keyPath].constraint(equalTo:otherView[keyPath: otherKeyPath], constant: constant)
                case .greaterThanOrEqual:
                    return view[keyPath: keyPath].constraint(greaterThanOrEqualTo:otherView[keyPath: otherKeyPath], constant: constant)
                case .lessThanOrEqual:
                    return view[keyPath: keyPath].constraint(lessThanOrEqualTo:otherView[keyPath: otherKeyPath], constant: constant)
                }
            } else {
                switch constraintRelation {
                case .equal:
                    return view[keyPath: keyPath].constraint(equalToConstant: constant)
                case .greaterThanOrEqual:
                    return view[keyPath: keyPath].constraint(greaterThanOrEqualToConstant: constant)
                case .lessThanOrEqual:
                    return view[keyPath: keyPath].constraint(lessThanOrEqualToConstant: constant)
                }
            }
        }

        return fullConstraint(from: constraint(otherView: otherView ?? view,
                                               otherKeyPath: otherView == nil ? otherKeyPath : otherKeyPath ?? keyPath),
                              withMultiplier:multiplier,
                              priority: priority)
    }
}

The constraint creation helper function, which allows us to apply a multiplier and a priority, remains the same but was renamed to fullConstraint for clarity.

The extension on UIView remains largely the same. Only the body of the last function was refactored, to account for the removal of UnpairedConstraint. We now must pass in a nil second parameter:

extension UIView {
    func addSubview(_ child: UIView, pairingTo pairingView: UIView? = nil, constraints: [Constraint]) {}
    func constrainToView(_ pairingView: UIView, constraints: [Constraint]) {}
    
    func constrain(to constraints: [Constraint]) {
        NSLayoutConstraint.activate(constraints.map { $0(self, nil) })
    }
}

We can now create additional constraint types, in a way that we couldn’t in the previous version of the code. For instance:

purpleView.constrain(to: [
    constraint(\.widthAnchor, \.heightAnchor)
    ])
	

You can find the full source code for this refactoring, with additional constraint examples, in a gist here.

Published on 6 January, 2018