Skip to content

Explanation for how this framework came to be

Elton Gao edited this page Dec 6, 2019 · 2 revisions

Original PR that is introduced to Wattpad code base (no public access): https://github.com/Wattpad/ios/pull/9896

TLDR; Investigated making a micro-framework for constraints. Looking for feedback, it was a fun experience attempting to make this work.

Author: Alexander Figueroa, alexjfigueroa@gmail.com Now maintained by: iOS engineers at Wattpad

Inspiration

I was inspired by this article which talks about designing a declarative API for Animations.

Essentially, turning this:

UIView.animate(withDuration: 0.3, animations: {
    button.alpha = 1
}, completion: { _ in
    UIView.animate(withDuration: 0.3) {
        button.frame.size = CGSize(width: 200, height: 200)
    }
})

into something like this

button.animate([
    .fadeIn(duration: 0.3),
    .resize(to: CGSize(width: 200, height: 200), duration: 0.3)
])

Autolayout in Wattpad App

Historically we've used frames and they're efficient, awesome, and get the job done. However, they tend to get hard to maintain quite quickly. Why do we want to change then?

Long story short, we want to make it easier for the future developers to better understand our layout intent.

1. Plain Old NSLayoutConstraints (PON)

Pros:

  • Verbose
  • Activated by default

Cons:

  • Verbose
  • Often hard to read and make elegant since they're so long horizontally
  • Requires the constraint be added on the common superview of the views being constrained (i.e. if you have View A with subviews B and C then constraints have to be added to View A)
  • No compile time safety (you can align a view's left to another view's bottom and it wouldn't complain)
/// Simple: Aligning view A to its superview by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

// Setup the constraint
parentView.addConstraint(NSLayoutConstraint(item: a,
                                            attribute: .leading,
                                            relatedBy: .equal,
                                            toItem: parentView,
                                            attribute: .leading,
                                            multiplier: 1.0,
                                            constant: 10.0)
/// Complex: Aligning view A to it's superview on all edges by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

// Setup the constraints
parentView.addConstraint(NSLayoutConstraint(item: a,
                                            attribute: .leading,
                                            relatedBy: .equal,
                                            toItem: parentView,
                                            attribute: .leading,
                                            multiplier: 1.0,
                                            constant: 10.0))

parentView.addConstraint(NSLayoutConstraint(item: a,
                                            attribute: .trailing,
                                            relatedBy: .equal,
                                            toItem: parentView,
                                            attribute: .trailing,
                                            multiplier: 1.0,
                                            constant: 10.0))

parentView.addConstraint(NSLayoutConstraint(item: a,
                                            attribute: .top,
                                            relatedBy: .equal,
                                            toItem: parentView,
                                            attribute: .top,
                                            multiplier: 1.0,
                                            constant: 10.0))

parentView.addConstraint(NSLayoutConstraint(item: a,
                                            attribute: .bottom,
                                            relatedBy: .equal,
                                            toItem: parentView,
                                            attribute: .bottom,
                                            multiplier: 1.0,
                                            constant: 10.0))                        

2. Visual Format Language (VFL)

Pros:

  • VFL String is descriptive for simple cases
  • Multiple constraints can be added at once

Cons:

  • VFL string can get hard to read for anything remotely complex
  • No compile time safety since stringly typed
  • Require defining views and metrics (also stringly typed) in order to reference
  • Horizontally long
/// Simple: Aligning view A to it's superview by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

// Setup the constraint
// This lets you reference the view by their key
let views = ["a": a]
// This lets you reference constants by their key
let metrics = ["spacing": 10]
// VFL assumes Horizontal by default but this could also be written as: "H:|-(spacing)-[a]"
parentView.addConstraint(NSLayoutConstraint.constraints(withVisualFormat: "|-(spacing)-[a]",
                                                        options: 0,
                                                        metrics: metrics,
                                                        views: views))
/// Complex: Aligning view A to it's superview on all edges by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

// Setup the constraint
// This lets you reference the view by their key
let views = ["a": a]
// This lets you reference constants by their key
let metrics = ["spacing": 10]
parentView.addConstraint(NSLayoutConstraint.constraints(withVisualFormat: "|-(spacing)-[a]-(spacing)-|",
                                                        options: 0,
                                                        metrics: metrics,
                                                        views: views))
parentView.addConstraint(NSLayoutConstraint.constraints(withVisualFormat: "V:|-(spacing)-[a]-(spacing)-|",
                                                        options: 0,
                                                        metrics: metrics,
                                                        views: views))

3. NSLayoutAnchors

Pros:

  • Compile time safety since anchors are broken down into three types:
    • x-axis (leading, trailing, centerX, etc)
    • y-axis (top, bottom, centerY, etc)
    • dimension (width, height)
  • Concise since it's a 1 to 1 relationship for constraints

Cons:

  • Not enabled by default. isActive must be set on each or in bulk with NSLayoutConstraint.active([NSLayoutConstraints])
  • Due to 1 to 1 relationship, the amount of constraint statement gets unwieldly for complex layouts and it loses some of its intuitiveness
/// Simple: Aligning view A to it's superview by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

// Setup the constraint and activating it (Apple says activation is fastests in updateConstraints)
a.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: 10.0).isActive = true

// or

// Apple recommends this for bulk activation of constraints
NSLayoutConstraint.activate([
    a.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: 10.0)
])
/// Complex: Aligning view A to it's superview on all edges by 10

// Setup the view
let parentView = UIView()
let a = UIView()
parentView.addSubview(a)

a.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: 10.0).isActive = true
a.trailingAnchor.constraint(equalTo: parentView.trailingAnchor, constant: -10.0).isActive = true
a.topAnchor.constraint(equalTo: parentView.topAnchor, constant: 10.0).isActive = true
a.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, constant: -10.0).isActive = true

// or

// Apple recommends this for bulk activation of constraints
NSLayoutConstraint.activate([
    a.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: 10.0),
    a.trailingAnchor.constraint(equalTo: parentView.trailingAnchor, constant: -10.0),
    a.topAnchor.constraint(equalTo: parentView.topAnchor, constant: 10.0),
    a.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, constant: -10.0)
])

Problem

Can we take the above example and turn it from this:

NSLayoutConstraint.activate([
    a.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, constant: 10.0),
    a.trailingAnchor.constraint(equalTo: parentView.trailingAnchor, constant: -10.0),
    a.topAnchor.constraint(equalTo: parentView.topAnchor, constant: 10.0),
    a.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, constant: -10.0)
])

to something like this:

a.applyLayout([
    .alignToEdges(of: parentView)
])

// or

a.alignToEdges(of: parentView)

Better yet, can we take something like this:

// self is implied parentView, hence `topAnchor` vs `self.topAnchor`
NSLayoutConstraint.activate([
    // imageView
    imageView.topAnchor.constraint(equalTo: topAnchor, constant: 2.0 * mediumPadding),
    imageView.centerXAnchor.constraint(equalTo: centerXAnchor),
    // titleLabel
    titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: largePadding),
    titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
    titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: largePadding),
    titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -largePadding),
    // subtitleLabel
    subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: smallPadding),
    subtitleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
    subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: subtitlePadding),
    subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -subtitlePadding),
    // notifyMeButton
    notifyMeButton.centerXAnchor.constraint(equalTo: centerXAnchor),
    notifyMeButton.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: mediumPadding),
    notifyMeButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: buttonPadding),
    notifyMeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -buttonPadding),
    notifyMeButton.heightAnchor.constraint(equalTo: notifyMeButton.widthAnchor, multiplier: 1.0 / aspectRatio),
    // maybeLaterButton
    maybeLaterButton.centerXAnchor.constraint(equalTo: centerXAnchor),
    maybeLaterButton.topAnchor.constraint(equalTo: notifyMeButton.bottomAnchor, constant: smallPadding),
    maybeLaterButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -mediumPadding),
    maybeLaterButton.widthAnchor.constraint(equalTo: notifyMeButton.widthAnchor),
    maybeLaterButton.heightAnchor.constraint(equalTo: notifyMeButton.heightAnchor)
])

and turn it into something like this:

/// Version 1
let allViews = [imageView, firstTitleLabel, subtitleLabel, notifyMeButton, maybeLaterButton]

allViews.applyLayout([
    .centerX(in: self)
])

imageView.applyLayout([
    .matchTop(to: self, with: 2.0 * mediumPadding),
])

titleLabel.applyLayout([
    .alignTop(to: .bottom, of: imageView, with: largePadding),
    .matchLeading(to: self, with: largePadding),
    .matchTrailing(to: self, with: -largePadding)
])

subtitleLabel.applyLayout([
    .alignTop(to: .bottom, of: titleLabel, with: smallPadding),
    .alignToHorizontalEdges(of: self, with: subtitlePadding)
])

notifyMeButton.applyLayout([
    .alignTop(to: .bottom, of: subtitleLabel, with: mediumPadding),
    .alignToHorizontalEdges(of: self, with: buttonPadding),
    .matchHeightToWidth(multipliedBy: 1.0 / aspectRatio)
])

maybeLaterButton.applyLayout([
    .alignTop(to: .bottom, of: notifyMeButton, with: smallPadding),
    .matchBottom(to: self, with: -mediumPadding),
    .makeSize(equalTo: notifyMeButton)
])

or

/// Version 2
let allViews = [imageView, firstTitleLabel, subtitleLabel, notifyMeButton, maybeLaterButton]

allViews.centerX(in: self)

imageView.matchTop(to: self, with: 2.0 * mediumPadding)

titleLabel.alignTop(to: .bottom, of: imageView, with: largePadding)
titleLabel.matchLeading(to: self, with: largePadding)
titleLabel.matchTrailing(to: self, with: -largePadding)

subtitleLabel.alignTop(to: .bottom, of: titleLabel, with: smallPadding)
subtitleLabel.alignToHorizontalEdges(of: self, with: subtitlePadding)

notifyMeButton.alignTop(to: .bottom, of: subtitleLabel, with: mediumPadding)
notifyMeButton.alignToHorizontalEdges(of: self, with: buttonPadding)
notifyMeButton.matchHeightToWidth(multipledBy: 1.0 / aspectRatio)

maybeLaterButton.alignTop(to: .bottom, of: notifyMeButton, with: smallPadding)
maybeLaterButton.matchBottom(to: self, with: -mediumPadding)
maybeLaterButton.makeSize(equalTo: notifyMeButton)

Building a more declarative Constraints API

The goal of building a more declarative style is to help:

  • group common constraint patterns together (aligning a views edges to its parentView)
  • easily apply common constraints to multiple views
  • improve conciseness and readability of layout code (following Swifty approach of sentence like code)
  • maintain a small footprint (lightweight)

Getting Setup

First thing we need to do is setup a playground so we can test out the constraints quickly. This can be accomplished by using PlaygroundSupport and setting up a liveView.

Note: To see the live view open Assistant Editor and then select Timeline

let view = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
view.backgroundColor = .white

PlaygroundPage.current.liveView = view

The Model

To be able to declare constraints in a more concise manner, we're going to make a small wrapper around NSLayoutConstraint called Constraint. The ideal relationship will be similar to anchors such that each Constraint coresponds to a single NSLayoutConstraint. To represent complex constraints we'll be using the composite design pattern. We'll define another class called CompositeConstraint. Composite pattern has it so that we can abstract multiple and single constraints and that either or can be contained in one or the other.

First, let's define the a pseduo-abstract base class we'll be using to represent all types of constraints: BaseConstraint. Ideally, we could use a protocol but we'd be unable to reference Constraint or CompositeConstraint via unified manner.

Additionally, we can't call static methods on a metatype which will get in the way of our dream of calling the layout code as .centerX(in: view),

/// Base class for constraints, you should not initialize this
class BaseConstraint {
    // Used to return the constraints that will be applied to a view (not activated)
    func constraints(forView view: UIView) -> [NSLayoutConstraint] { return [] }
    // Used to activate the constraints for a view (will activate)
    @discardableResult func applyConstraints(toView view: UIView) -> [NSLayoutConstraint] {
        let constraintsToAdd = constraints(forView: view)
        NSLayoutConstraint.activate(constraintsToAdd)
        return constraintsToAdd
    }
}

applyConstraints is going to grab the constraints that would be built (either from Constraint or CompositeConstraint). It'll then take that and activate them and return them in case someone wanted to modify them.

Next, we'll define a class to represent a single Constraint convenientely called Constraint. This class will inherit from BaseConstraint and reference a closure used to build constraints.

/// Closure used to help build constraints
typealias Closure = (UIView) -> NSLayoutConstraint

// Represents a single constraint
final class Constraint: BaseConstraint {
    let closure: Closure
    
    init(closure: @escaping Closure) {
        self.closure = closure
    }
    
    // Return all the constraints used by this (single will only return 1)
    override func constraints(forView view: UIView) -> [NSLayoutConstraint] {
        return [self.closure(view)]
    }
}

Lastly, we'll define a class to represent multiple constraints called CompositeConstraint

final class CompositeConstraint: BaseConstraint {
    let constraints: [BaseConstraint]
    
    init(constraints: [BaseConstraint]) {
        self.constraints = constraints
    }
    
    override func constraints(forView view: UIView) -> [NSLayoutConstraint] {
        return constraints.flatMap { $0.constraints(forView: view) }
    }
}

Extending the Model: API

We can now get to the good stuff, let's start defining our API for these constraints by creating an extension off BaseConstraint. We'll add static methods for each kind of constraint layout we want to support. Let's start by creating the API to center a view horizontally and vertically in a view:

extension BaseConstraint {
    // This will read as ".centerX(in: view, withOffset: 10.0)" or ".centerX(in: view)"
    static func centerX(in otherView: UIView, withOffset offset: CGFloat = 0.0) -> Constraint {
        return Constraint(closure: { (view) -> NSLayoutConstraint in
            return view.centerXAnchor.constraint(equalTo: otherView.centerXAnchor, constant: offset)
        })
    }

    // This will read as ".centerY(in: view, withOffset: 10.0)" or ".centerY(in: view)"
    static func centerY(in otherView: UIView, withOffset offset: CGFloat = 0.0) -> Constraint {
        return Constraint(closure: { (view) -> NSLayoutConstraint in
            return view.centerYAnchor.constraint(equalTo: otherView.centerYAnchor, constant: offset)
        })
    }
    ...
}

As you can see above we make heavy use of default values, this is helpful for API facing code as it'll allow for great flexibility when calling these methods. You could center a view horizontally by default as: .centerX(in: view) and if you wanted to offset it by a constant value, you could easily do that by calling the extra argument .centerX(in: view, withOffset: 22.0).

You might be wondering how the CompositeConstraints come in? They are what we'll use to define the API for centering a view both horizontally and vertically in a view.

extension BaseConstraint {
    ...
    // This will read as ".center(in: view, withOffset: 10.0)" or ".center(in: view)"
    static func center(in otherView: UIView, withOffset offset: CGFloat = 0.0) -> CompositeConstraint {
        return CompositeConstraint(constraints: [
            .centerX(in: otherView, withOffset: offset),
            .centerY(in: otherView, withOffset: offset)
        ])
    }
}

As you can see, we can easily extend this to support more than just centering. We could do alignments, sizing, and so much more. The composite pattern lets us easily disambiguate between whether a constraint is a single or multiple and we can just worry about applying the constraints.

We'll do the same thing for height and width:

extension BaseConstraint {
    static func makeWidth(equalTo width: CGFloat) -> Constraint {
        return Constraint(closure: { (view) -> NSLayoutConstraint in
            return view.widthAnchor.constraint(equalToConstant: width)
        })
    }

    static func makeHeight(equalTo height: CGFloat) -> Constraint {
        return Constraint(closure: { (view) -> NSLayoutConstraint in
            return view.heightAnchor.constraint(equalToConstant: height)
        })
    }

    static func makeSize(equalTo size: CGFloat) -> CompositeConstraint {
        return CompositeConstraint(constraints: [
            makeWidth(equalTo: size),
            makeHeight(equalTo: size)
        ])
    }
}

Using the Model

Once we have the model in place, we need to actually allow for this code to work with UIViews. We can do this in a similar manner to above by adding an extension on UIView.

extension UIView {
    func applyLayout(_ constraints: [BaseConstraint]) {
        guard !constraints.isEmpty else {
            return
        }
        
        translatesAutoresizingMaskIntoConstraints = false
        for constraint in constraints {
            constraint.applyConstraints(toView: self)
        }
        // Note: This can be done recursively too
        // var constraints = constraints
        // let constraint = constraints.removeFirst()
        // constraint.applyConstraints(toView: self)
        // applyLayout(constraints)
    }
}

Action

Finally, we can now use the API to apply a layout to our view in a hopefully more readable fashion than before:

let redView = UIView()
redView.backgroundColor = .red

// This is the view from the first Playground snippet
view.addSubview(redView)

// Layout centered with fixed size
redView.applyLayout([
    .center(in: view),
    .makeSize(equalTo: 20.0)
])

You should now see something like the below image! Voila!

screen shot 2017-08-30 at 3 37 46 pm

You can see the attached playground for a live demo where I extended on this micro-framework to show how you can apply other types of layouts and perform animations.

[Playground Link](TO BE ADDED)