Explanation for how this framework came to be - Wattpad/Schematic GitHub Wiki
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, [email protected] 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 withNSLayoutConstraint.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 CompositeConstraint
s 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 UIView
s.
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!
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)