Draftsman
Draftsman is a DSL framework for Swift focussed on builder pattern
Example
To run the example project, clone the repo, and run pod install
from the Example directory first.
Requirements
- Swift 5.3 or higher (Swift 5.1 for version 1.1.1 or lower)
- iOS 10.0 or higher
- XCode 12.5 or higher (XCode 11 for version 1.1.1 or lower)
Installation
Cocoapods
Draftsman is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod 'Draftsman', '~> 2.0.4'
or for Swift 5.1 and XCode 11
pod 'Draftsman', '~> 1.1.1'
Swift Package Manager from XCode
- Add it using XCode menu File > Swift Package > Add Package Dependency
- Add https://github.com/hainayanda/Draftsman.git as Swift Package URL
- Set rules at version, with Up to Next Major option and put 2.0.3 or 1.1.1 for Swift 5.1 and XCode 11 as its version
- Click next and wait
Swift Package Manager from Package.swift
Add as your target dependency in Package.swift
dependencies: [
.package(url: "https://github.com/hainayanda/Draftsman.git", .upToNextMajor(from: "2.0.4"))
]
or for Swift 5.1 and XCode 11
dependencies: [
.package(url: "https://github.com/hainayanda/Draftsman.git", .upToNextMajor(from: "1.1.1"))
]
Use it in your target as Draftsman
.target(
name: "MyModule",
dependencies: ["Draftsman"]
)
Author
Nayanda Haberty, [email protected]
License
Draftsman is available under the MIT license. See the LICENSE file for more info.
Basic Usage
Draftsman is NSLayoutConstraints
and UIView
hierarchy builder. Draftsman using new resultBuilder from Swift that makes the Declarative approach possible. For 1.1.1 version, please lookup this readme instead
Basic
Creating constraints is very easy. All you need to do is call plan to get LayoutScheme
:
myView.plan
.left(.equal, to: otherView.rightAnchor)
.right(.equalTo(16), to: .parent)
.top(.lessThanTo(8), to: .safeArea)
.bottom(.moreThan, to: .top(of: .keyboard))
.apply()
there are two method to end planning constraints which can be called from both any UIView
or UIViewController
:
func apply() -> [NSLayoutConstraint]
func build() -> [NSLayoutConstraint]
the difference between the two is apply
will activate the constraints but build
will only create constraints without activating it. Apply return value is discardable so it's optional for you to use the created NSLayoutConstraint
or not.
You could always create a UIViewController
or UIView
and implements Planned
protocol, and call applyPlan()
whenever you want the plan to be applied:
import Draftsman
class MyViewController: UIViewController, Planned {
@LayoutPlan
var viewPlan: ViewPlan {
UIStackView(axis: .vertical, spacing: 32).plan
.center(.equal, to: .parent)
.horizontal(.equalTo(16), to: .safeArea)
.vertical(.moreThanTo(16), to: .safeArea)
.insertStacked {
MyView()
MyOtherView()
SomeOtherView()
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyPlan()
}
}
View Hierarchy
You can create view hierarchy while creating a constraints by using planContent
method and insert
method for subview plan (planStackedContent
and insertStacked
if its arranged subviews in UIStackView
). You should not calls apply or build when using this method, since it will always automatically populate all constraints inside to activate as bulk:
view.planContent {
UIView().plan
.center(.equal, to: .parent)
.horizontal(.equal, to: .safeArea)
.vertical(.moreThan, to: .safeArea)
.insert {
myView.plan
.edges(.equal, to: .parent)
}
}
The hierarchy of View is just like how the closure is declared in your code. The above code, It actually will do the following instruction sequentially:
view
create and insert newUIView()
- new
UIView
then will create constraints - new
UIView
then will insertmyView
myView
then will create constraints- all the constraints then will be activated
So if the hierarchy is written in pseudo hierarchy style, it should be similar to this:
view
|____new UIView
| |____myView
the compatible type to be passed in the closure are:
- any descendant of
UIView
- any descendant of
UIViewController
If you pass UIViewController
, it will be automatically added UIViewController
view as a child and put the UIViewController
as a child of its current UIViewController
. You could insert components as much as you need, it will fit all the Views just like how you write it.
PlanDelegate
You could pass PlanDelegate
to delegate some problem which could be occurs when you are planning. The delegate is declared like this:
public protocol PlanDelegate: class {
func planer(viewHaveNoSuperview view: UIView) -> UIView?
func planer(neededViewControllerFor viewController: UIViewController) -> UIViewController?
func planer(_ view: UIView, errorWhenPlanning error: DraftsmanError)
}
planer(viewHaveNoSuperview:)
will be called if the planner needs superview but cannot find any. You could provide one, or just return nil to ignore those plan and produce error which can be caught at this delegate. The default is nil.planer(neededViewControllerFor:)
will be called if the planner needs parentUIViewController
but cannot find any. You could provide one, or just return nil to ignore those plan and produce error which can be caught at this delegate. The default is to get the current view UIViewController if have any.planer(_:, errorWhenPlanning:)
will be called if any error occurs when planning.
view.planContent(withDelegate: myDelegate) {
UIView().plan
.center(.equal, to: .parent)
.horizontal(.equal, to: .safeArea)
.vertical(.moreThan, to: .safeArea)
.insert {
myView.plan
.edges(.equal, to: .parent)
}
}
View Position
Basic Positioning
Positioning a View is easy. You just need to declare which anchor should have relation to others:
myView.plan
.top(.equal, to: other.topAnchor)
.right(.equalTo(8), to: other.rightAnchor)
.bottom(.moreThan, to: other.bottomAnchor)
.left(.moreThanTo(8), to: other.leftAnchor)
.centerX(.lessThan, to: other.centerXAnchor)
.centerY(.lessThanTo(8), to: other.centerYAnchor)
the anatomy of position plan is:
func anchor_name(_ relation: LayoutRelation, to anchor: other_anchor, priority: UILayoutPriority? = nil) -> Self
which anchor_name
could be:
- top
- bottom
- left
- right
- centerX
- centerY
and LayoutRelation
is enumeration with generic param which in this case is CGFloat
:
- moreThanTo(CGFloat)
- lessThanTo(CGFloat)
- equalTo(CGFloat)
- moreThan
- lessThan
- equal
all the parameter is CGFloat
that will be translated as space towards the anchor, as when the anchor is left
, then the param will be translated as space to the left, and if the anchor is right
, then the param will be translated as space to the right.
and other_anchor
common types are:
NSLayoutYAxisAnchor
NSLayoutXAxisAnchor
AnonymousRelation
the AnonymousRelation
is an enumeration that contains:
- parent which are where the same anchor of parent
UIView
- safeArea which are where the same anchor of parent safe area
UIView
- myself which are where the same anchor of current
UIView
- mySafeArea which are where the same anchor of current safe area
UIView
- previous which are where the same anchor of previous planned
UIView
- previousSafeArea which are where the same anchor of previous planned
UIView
- keyboard which are the same anchor of parent keyboard guide of
UIView
- keyboardSafeArea which are the same anchor of parent keyboard guide of
UIView
intersect with safe area
the keyboard
and keyboardSafeArea
are all powered by Clavier
You could also pass UILayoutPriority
if you need. If you ignore it, it will be lesser than the previous priority started by mandatory.
So lets say you want your view to fill the bottom of its superview, you could just use AnonymousRelation
instead of declare explicitly:
myView.plan
.right(.equal, to: .parent)
.bottom(.equal, to: .parent)
.left(.equal, to: .parent)
Related Anchor
If your AnonymousRelation
anchor is different than your anchor, you could use RelatedAnchor<NSLayoutXAxisAnchor>
. It's actually the anchor extractor from AnonymousRelation
.
So lets say you want to make your view always on top of keyboard, just do this:
myView.plan
.bottom(.equal, to: .top(of: .keyboard))
.right(.equalTo(8), to: .safeArea)
.left(.equalTo(8), to: .safeArea)
so instead of calling the previous anchor explicitly, you call them like that.
Positioning Shortcut
There are some shortcuts for Positioning. For center anchor:
func center(_ relation: LayoutRelation<CoordinateOffsets>, to view: UIView, priority: UILayoutPriority? = nil) -> Self
func center(_ relation: LayoutRelation<CoordinateOffsets>, to anchor: AnonymousRelation, priority: UILayoutPriority? = nil) -> Self
the CoordinateOffsets
is struct which contains xOffset
and yOffset
. The center is shortcut to centerX
and centerY
, it will automatically assign NSLayoutConstraints
relation to same center anchor to the UIView
or AnonymousRelation
. Example:
myView.plan
.center(.equalTo(.init(xOffset: 8, yOffset: 8)), to: .parent)
For vertical and horizontal position:
func vertical(_ relation: LayoutRelation<InsetsConvertible>, to anchor: AnonymousRelation, priority: UILayoutPriority? = nil) -> Self
func vertical(_ relation: LayoutRelation<InsetsConvertible>, to view: UIView, priority: UILayoutPriority? = nil) -> Self
func horizontal(_ relation: LayoutRelation<InsetsConvertible>, to view: UIView, priority: UILayoutPriority? = nil) -> Self
func horizontal(_ relation: LayoutRelation<InsetsConvertible>, to anchor: AnonymousRelation, priority: UILayoutPriority? = nil) -> Self
the InsetsConvertible
can be UIEdgeInsets
, UIVerticalInsets
, UIHorizontalInsets
, CGFloat
, Int
or Double
. Single types like CGFloat
, Int
or Double
will be treated as UIEdgeInsets
with same insets for top, left, right and bottom. The vertical will be assign top and bottom anchor to UIView
or AnonymousRelation
same anchors. Example:
myView.plan
.vertical(equalTo(16), to: .parent)
For all edges:
func edges(_ relation: LayoutRelation<InsetsConvertible>, to anchor: AnonymousRelation, priority: UILayoutPriority? = nil) -> Self
It will automatically assign top
, left
, bottom
and right
anchor to UIView
or AnonymousRelation
same anchors. Example:
myView.plan
.edges(.equalTo(16), to: .parent)
For any specific position:
func at(_ positions: [LayoutEdge], _ relation: LayoutRelation<InsetsConvertible>, to anchor: AnonymousRelation, priority: UILayoutPriority? = nil) -> Self
func at(_ viewRelation: RelatedPosition, _ relation: LayoutRelation<InsetsConvertible>, priority: UILayoutPriority? = nil) -> Self
func inBetween(of view: UIView, and otherView: UIView, _ position: MiddlePosition, priority: UILayoutPriority? = nil) -> Self
Array of LayoutEdge
have static var extensions which are:
- topLeft which is
[.top, .left]
- topRight which is
[.top, .right]
- bottomLeft which is
[.bottom, .left]
- bottomRight which is
[.bottom, .right]
- fullLeft which is
[.left, .top, .bottom]
- fullRight which is
[.right, .top, .bottom]
- fullBottom which is
[.bottom, .left, .right]
- fullTop which is
[.top, .left, .right]
- edge which is
[.top, .bottom, .left, .right]
RelatedPosition
is enumeration which are:
- topOf(UIView) which will mark bottom to be at top of other
UIView
- bottomOf(UIView) which will mark top to be at bottom of other
UIView
- leftOf(UIView) which will mark right to be at left of other
UIView
- rightOf(UIView) which will mark left to be at right of other
UIView
- topOfAndParallelWith(UIView) which same as
topOf
, but with same left and right as otherUIView
- bottomOfAndParallelWith(UIView) which same as
bottomOf
, but with same left and right as otherUIView
- leftOfAndParallelWith(UIView) which same as
leftOf
, but with same top and bottom as otherUIView
- rightOfAndParallelWith(UIView) which same as
rightOf
, but with same top and bottom as otherUIView
MiddlePosition
is enumeration with LayoutRelation<InsetsConvertible>
parameter:
- horizontally(LayoutRelation)
- vertically(LayoutRelation)
Example:
myView.plan
.at(.topLeft, .equal, to: .parent)
.at(.rightOf(otherView), .equalTo(8))
otherView.plan
.inBetween(of: myView, and: anyView, .horizontally(.equalTo(8)))
View Dimension
Basic Dimensioning
Create dimension constraints for a View is easy. You just need to declare which anchor should have relation to others:
myView.plan
.height(.equalTo(otherView.heightAnchor), multiplyBy: 2)
.width(.equalTo(.parent), .width)
.height(.lessThanTo(100))
.width(.moreThanTo(100))
The anatomy of dimension plan is:
func dimension_name(_ _relation: InterRelation of AnonymousRelation, __ dimension: LayoutDimension, multiplyBy multiplier: CGFloat, constant: CGFloat, priority: UILayoutPriority? = nil) -> Planner
this one could be used to plan any dimension constraints with any AnonymousRelation
dimension anchor. The other dimension plan is:
func dimension_name(_ relation: InterRelation of NSLayoutDimension, multiplyBy multiplier: CGFloat, constant: CGFloat, priority: UILayoutPriority? = nil) -> Planner
this one could be used to plan any dimension constraints with any NSLayoutDimension
anchor. The last dimension plan is:
func dimension_name(_ relation: InterRelation of CGFloat, priority: UILayoutPriority? = nil) -> Planner
this one could be used to plan any dimension constraints against any constant.
The InterRelation
is the enumeration with generic parameter:
- moreThanTo(Related)
- lessThanTo(Related)
- equalTo(Related)
and LayoutDimension
is enumeration of dimension:
- height
- width
a multiplier is a value that will be multiplied by dimension anchor. The constant will be added to the dimension.
Dimensioning Shortcut
There are some shortcut for Dimensioning which are:
func size(_ relation: InterRelation<CGSize>, priority: UILayoutPriority? = nil) -> Self
func size(_ relation: InterRelation<UIView>, multiplyBy multiplier: CGFloat = 1, constant: CGFloat = 0, priority: UILayoutPriority? = nil) -> Self
both will automatically assign both width and height constraints towards CGSize constant or UIView size. Example:
myView.plan
.size(.equalTo(otherView), multiplyBy: 2)
otherView.plan
.size(.equalTo(.init(width: 24, height: 24)))
Draftsman Planned
Draftsman Planned
protocol is the protocol that makes any UIView
or UIViewController
can have its predefined view plan and applied it using applyPlan
method. The protocol is declared like this:
public protocol Planned {
@LayoutPlan
var viewPlan: ViewPlan { get }
func applyPlan()
}
The only thing you need to implement is the viewPlan
getter:
import Draftsman
class MyViewController: UIViewController, Planned {
@LayoutPlan
var viewPlan: ViewPlan {
UIStackView(axis: .vertical, spacing: 32).plan
.center(.equal, to: .parent)
.horizontal(.equalTo(16), to: .safeArea)
.vertical(.moreThanTo(16), to: .safeArea)
.insertStacked {
MyView()
MyOtherView()
SomeOtherView()
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyPlan()
}
}
Every time you call applyPlan
, it will always remove all subviews
from UIView
root and recreate it
Fragment
Fragment
in Draftsman is actually an ordinary UIView (or TableViewCell
/CollectionViewCell
) that can do a plan for itself and its subviews
by using Planned
capabilities
public protocol Fragment: Planned {
func fragmentWillPlanContent()
func fragmentDidPlanContent()
}
fragmentWillPlanContent()
will be called beforeplanContent(_:)
is called. its optionalfragmentDidPlanContent()
will be called afterplanContent(_:)
is called. its optional
The mechanism is very straightforward. lets say you have this simple fragment:
class MySimpleFragment: UIView, Fragment {
var margin = UIEdgeInsets(insets: 8)
@LayoutPlan
var viewPlan: ViewPlan {
UIButton().plan.edges(equalTo(margin), to: .parent)
}
}
to put it in ViewController bottom edges:
class MySimpleViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
planContent {
MySimpleFragment().plan
.at(.fullBottom, .equal, to: .parent)
}
}
what happening here is when you fit simpleFragment into view, it will call simpleFragment's planContent(_:)
where the InsertablePlan
is the simpleFragment's LayoutPlaner
. If it's described as hierarchal pseudocode and where the part is executed. It will be looked like this:
- viewDidLoad
- planContent
- fit simpleFragment inside view
- add simpleFragment as subviews of view controller view
- create LayoutPlaner with simpleFragment as its view
- call simpleFragment's
fragmentWillPlanContent()
method which not implemented so its doing nothing- get simpleFragment's
viewPlan
and create its constraints and fit all its subviews
- fit UIButton inside simpleFragment
- create edges contraints
- call simpleFragment's
fragmentDidPlanContent()
method which not implemented so its doing nothing
- create fullBottom constraints
- activate all constraints created inside
There are two other extensions method you could use to call planContent indirectly:
func planFragment(delegate: PlanDelegate? = nil)
which will be callfragmentWillPlanContent()
and activate all created constraints right away andfragmentDidPlanContent()
func replanContent(delegate: PlanDelegate? = nil)
which will remove all its and subviews constraints created by Draftsman and callplanFragment(delegate:)
Fragment View
There is UIView that already implemented Fragment that you can extend named FragmentView
. It has some more open method that you can use:
func fragmentWillLayoutForTheFirstTime()
which will be called insidelayoutSubviews()
and only once at the first time beforesuper.layoutSubviews()
func fragmentDidLayoutForTheFirstTime()
which will be called insidelayoutSubviews()
and only once at the first time aftersuper.layoutSubviews()
The advantages of the FragmentView are:
Capabilities | Fragment View | Implementing Own Fragment |
---|---|---|
have fragmentWillLayoutForTheFirstTime() |
YES | NO |
have fragmentDidLayoutForTheFirstTime() |
YES | NO |
automatically run planFragment when inserted to any UIView |
YES | NO |
automatically run planFragment when planned using Draftsman |
YES | YES |
example:
class MySimpleFragment: UIView, Fragment {
var margin = UIEdgeInsets(insets: 8)
@LayoutPlan
var viewPlan: ViewPlan {
UIButton().plan.edges(equalTo(margin), to: .parent)
}
func fragmentDidLayoutForTheFirstTime() {
addGradient(with: self.bounds)
}
}
Fragment Cell
There is Fragment created specifically for cell which named FragmentCell
:
public protocol FragmentCell: Fragment {
var layoutPhase: CellLayoutingPhase { get }
var planningBehavior: CellPlanningBehavior { get }
func planningOption(on phase: CellLayoutingPhase) -> PlanningOption
}
You're not supposed to implement FragmentCell by yourself but by extend TableFragmentCell
which is UITableViewCell
that implement FragmentCell
or CollectionFragmentCell
which is UICollectionViewCell
that implement FragmentCell
. The reason is that all of the FragmentCell
implementation is implemented there as part of how those FragmentCell
should behave.
Behavior and Phase
As we could see before, the FragmentCell
have two properties and one added method:
var layoutPhase: CellLayoutingPhase { get }
var planningBehavior: CellPlanningBehavior { get }
func planningOption(on phase: CellLayoutingPhase) -> PlanningOption
The layoutPhase
is the phase of the Cell, which is an enumeration:
- firstLoad which indicated that the
Cell
is just created - setNeedsLayout which indicated that the
Cell
setNeedsLayout()
is just called - reused which indicated that the
Cell
is being reused - **none
the planningBehavior
is the behavior of the cell during layouting which could be overridden if needed. It is enumeration which contains:
- planOnce which will only call
planContent(_:)
duringfirstLoad
phase - planOn(CellLayoutingPhase) which will only call
planContent(_:)
during the givenCellLayoutingPhase
- planOnEach([CellLayoutingPhase]) which will only call
planContent(_:)
during each givenCellLayoutingPhase
- planIfPossible which will always call
planContent(_:)
for anyCellLayoutingPhase
the planningOption(on:)
will be called before planContent(_:)
is called by any phase. It will asked what PlanningOption
you want to use when call planContent(_:)
. The default is append when firstLoad
phase and starFresh
on the other phases. You could read more about PlanningOption
here. Example:
class EventCollectionCell: CollectionFragmentCell {
override var planningBehavior: CellPlanningBehavior { .planIfPossible }
lazy var imageView: UIImageView = .init()
@LayoutPlan
var viewPlan: ViewPlan {
imageView.plan.edges(equalTo(margin), to: .parent)
}
override func planningOption(on phase: CellLayoutingPhase) -> PlanningOption {
switch phase {
case .firstLoad:
return .append
default:
return .renew
}
}
}
Cell at the example above will always create viewPlan
at any phases, but will only do append
on firstLoad
and the rest will be renew
the current constraints.
If you want to manually create viewPlan
during any phases, you could just call layoutContentIfNeeded()
it will layout content if the current phase is whitelisted in planningBehavior
and return Bool
indicated that the planContent(_:)
is called or not.
Table Fragment Cell
TableFragmentCell
is the UITableViewCell
that implement FragmentCell
. Other than what UITableViewCell
, Fragment
and FragmentCell
feature, it have one method that could help you determine cell dimension:
func calculatedCellHeight(for cellWidth: CGFloat) -> CGFloat
cellWidth
is the width of the cell which already calculated according to the tableView content width and insets. The default return value is automatic, but it could be came in handy if the cell NSLayoutConstraints
alone cannot give the exact dimension of the cell. Example:
class MyCell: TableFragmentCell {
lazy var collectionLayout: UICollectionViewFlowLayout = .init()
lazy var collectionView: UICollectionView = .init(frame: .zero, collectionViewLayout: collectionLayout)
@LayoutPlan
var viewPlan: ViewPlan {
collectionView.plan.edges(equalTo(margin), to: .parent)
}
override func fragmentWillPlanContent() {
collectionView.allowsSelection = true
collectionView.backgroundColor = .clear
collectionView.allowsSelection = true
collectionLayout.scrollDirection = .horizontal
collectionLayout.itemSize = .init(width: .x64, height: .x48)
collectionLayout.minimumInteritemSpacing = .zero
collectionLayout.minimumLineSpacing = .zero
}
override func calculatedCellHeight(for cellWidth: CGFloat) -> CGFloat {
128
}
}
If you want to inject cellSize calculator, just pass a closure to whenNeedCellSize
method at the cell
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! TableFragmentCell
cell.whenNeedCellSize { width in
return 128
}
return cell
}
The cell above will have a height of 128 regarding how long is cellWidth.
Collection Fragment Cell
CollectionFragmentCell
is the UICollectionViewCell
that implement FragmentCell
. Other than what UICollectionViewCell
, Fragment
and FragmentCell
feature.
class EventCollectionCell: CollectionFragmentCell {
lazy var imageView: UIImageView = .init()
@LayoutPlan
var viewPlan: ViewPlan {
imageView.plan.edges(equalTo(margin), to: .parent)
}
}
Contribute
You know how, just clone and do pull request