Style views using an enum array with its attributes:
let label: UILabel = [.text("Hello World"), .textColor(.red)]
- Installation
- Style views using enums swiftly
- Non-intrusive - standard UIKit views
- Mergeable
- Predefined styles
- Passing delegates
- Supported attributes
- SwiftGen support
- CAUTION: Avoid arrays with duplicate values.
- Composables
- Custom attribute
- Roadmap
You can use the swift4
branch to use ViewComposer in your Swift 4 code, e.g. with in CocoaPods, the Podfile would be:
pod 'ViewComposer', :git => 'https://github.com/Sajjon/ViewComposer.git', :branch => 'swift4'
pod 'ViewComposer', '~> 0.2'
github "ViewComposer" ~> 0.2
dependencies: [
.Package(url: "https://github.com/Sajjon/ViewComposer.git", majorVersion: 0)
]
Refer to famous open source framwork Alamofire's instructions on how to manually integrate a framework in order to install ViewComposer in your project manually.
We are styling our views using an array of the enum type ViewAttribute
which creates a type called ViewStyle
which can be used to style our views. Please note that the order of the attributes (enums) does not matter:
let label: UILabel = [.text("Hello World"), .textColor(.red)]
let same: UILabel = [.textColor(.red), .text("Hello World")] // order does not matter
(Even though it might be a good idea to use the same order in your app for consistency.)
The strength of styling views like this get especially clear when you look at a UIViewController
example, and this isn't even a complicated ViewController.
class NestedStackViewsViewController: UIViewController {
lazy var fooLabel: UILabel = [.text("Foo"), .textColor(.blue), .color(.red), .textAlignment(.center)]
lazy var barLabel: UILabel = [.text("Bar"), .textColor(.red), .color(.green), .textAlignment(.center)]
lazy var labels: UIStackView = [.views([self.fooLabel, self.barLabel]), .distribution(.fillEqually)]
lazy var button: UIButton = [.text("Baz"), .color(.cyan), .textColor(.red)]
lazy var stackView: UIStackView = [.views([self.labels, self.button]), .axis(.vertical), .distribution(.fillEqually)]
...
}
Compared to vanilla:
class VanillaNestedStackViewsViewController: UIViewController {
lazy var fooLabel: UILabel = {
let fooLabel = UILabel()
fooLabel.translatesAutoresizingMaskIntoConstraints = false
fooLabel.text = "Foo"
fooLabel.textColor = .blue
fooLabel.backgroundColor = .red
fooLabel.textAlignment = .center
return fooLabel
}()
lazy var barLabel: UILabel = {
let barLabel = UILabel()
barLabel.translatesAutoresizingMaskIntoConstraints = false
barLabel.text = "Bar"
barLabel.textColor = .red
barLabel.backgroundColor = .green
barLabel.textAlignment = .center
return barLabel
}()
lazy var labels: UIStackView = {
let labels = UIStackView(arrangedSubviews: [self.fooLabel, self.barLabel])
labels.translatesAutoresizingMaskIntoConstraints = false
labels.distribution = .fillEqually
return labels
}()
lazy var button: UIButton = {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = .cyan
button.setTitle("Baz", for: .normal)
button.setTitleColor(.red, for: .normal)
return button
}()
lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [self.labels, self.button, self.button])
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
return stackView
}()
...
}
As we saw in the ViewComposer example above:
let button: UIButton = [.color(.red), .text("Red"), .textColor(.blue)]
NO SUBCLASSES NEEDED 🙌
Of course you can always change your var
to be lazy
(recommended) and set attributes on the view which are not yet supported by ViewComposer, like this:
lazy var button: UIButton = {
let button: UIButton = [.color(.red), .text("Red"), .textColor(.blue)]
// setup attributes not yet supported by ViewComposer
button.layer.isDoubleSided = false // `isDoubleSided` is not yet supported
return button
}()
The attributes enum array [ViewAttribute]
creates a ViewStyle
(wrapper that can create views).
An array [ViewAttribute]
can be merged with another array or a single attribute. Such an array can also be merged with a ViewStyle
. A ViewStyle
can be merged with a single ViewAttribute
as well. An array of attributes can also be merged with a single attribute. Any type can be on the left handside or right handside in the merge.
The result of the merge is always a ViewStyle
, since this is the most refined type.
There are two different merge functions, merge:master
and merge:slave
, since the two types you are merging may contain the same attribute, i.e. there is a duplicate, you need to decide which value to keep.
Merge between [ViewAttribute]
arrays with a duplicate value using merge:slave
and merge:master
let foo: [ViewAttribute] = [.text("foo")] // can use `ViewStyle` as `bar`
let bar: ViewStyle = [.text("bar"), .color(.red)] // prefer `ViewStyle`
// The merged results are of type `ViewStyle`
let fooMerged = foo.merge(slave: bar) // [.text("foo"), .color(.red)]
let barMerged = foo.merge(master: bar) // [.text("bar"), .color(.red)]
As mentioned above, you can merge single attributes as well
let foo: ViewAttribute = .text("foo")
let style: ViewStyle = [.text("bar"), .color(.red)]
// The merged results are of type `ViewStyle`
let mergeSingleAttribute = style.merge(master: foo) // [.text("foo"), .color(.red)]
let array: [ViewAttriubte] = [.text("foo")]
let mergeArray = style.merge(master: foo) // [.text("foo"), .color(.red)]
You can also merge optional ViewStyle
, which is convenient for you initializers
final class MyViewDefaultingToRed: UIView {
init(_ style: ViewStyle? = nil) {
let style = style.merge(slave: .default)
self.style = style
super.init(frame: .zero)
setup(with: style) // setup the view using the style..
}
}
private extension ViewStyle {
static let `default`: ViewStyle = [.color(.red)]
}
Instead of writing foo.merge(slave: bar)
we can write foo <- bar
and instead of writing foo.merge(master: bar
we can write foo <<- bar
.
let foo: ViewStyle = [.text("foo")]
let bar: ViewStyle = [.text("bar"), .color(.red)]
// The merged results are of type `ViewStyle`
let fooMerged = foo <- bar // [.text("foo"), .color(.red)]
let barMerged = foo <<- bar // [.text("bar"), .color(.red)]
Of course the operator <-
and <<-
works between ViewStyle
s, ViewAttribute
and [ViewAttriubte]
interchangably.
The operators also works with optional ViewStyle
. Thus we can rewrite the merge in the initializer of MyViewDefaultingToRed
using the merge:slave
operator if we want to:
final class MyViewDefaultingToRed: UIView {
init(_ style: ViewStyle? = nil) {
let style = style <- .default
...
Of course it is possible to chain merges. But when chaining three operands, e.g.
let foo: ViewStyle = ...
let bar: ViewStyle = ...
let baz: ViewStyle = ...
let result1 = foo <<- bar <<- baz
let result2 = foo <- bar <- baz
let result3 = foo <<- bar <- baz
let result4 = foo <- bar <<- baz
In which order shall the merging take place? Should we first merge foo
with bar
and then merge that intermediate result with baz
? Or should we first merge bar
with baz
and then merge that intermediate result with foo
?
This is called operator associativity. In the example above
Disregarding of left or right associativity the results individually, would have the same result. Meaning that the value of result1
is the same when using left and right associativity. The same applies for result2
and result3
.
let foo: ViewStyle = [.text("foo")]
let bar: ViewStyle = [.text("bar")]
// Associativity irrelevant
let result1 = foo <<- bar <<- .text("baz") // result: `[.text(.baz)]`
let result2 = foo <- bar <- .text("baz") // result: `[.text(.foo)]`
let result3 = foo <<- bar <- .text("baz") // result: `[.text(.bar)]`
But having a look at this example, associativity matters!:
let foo: ViewStyle = [.text("foo")]
let bar: ViewStyle = [.text("bar")]
// Associativy IS relevant
// when using `right` associative:
let result4r = foo <- bar <<- .text("baz") // result: `[.text(.foo)]` USED IN ViewComposer
// When using `left` associative:
let result4l = foo <- bar <<- .text("baz") // result: `[.text(.baz)]`
ViewComposer
is using right for both the <-
as well as the <<-
operator. This means that you should read from right to left when values of chained merge operators.
You can also declare some standard style, e.g. font
, textColor
, textAlignment
and upper/case strings that you wanna use for all of your UILabel
s. Since ViewStyle
are mergeable it makes it convenient to share style between labels and merge custom values into the shared style and creating the label from this merged style.
let style: ViewStyle = [.textColor(.red), .textAlignment(.center)]
let fooLabel: UILabel = labelStyle.merge(master: .text("Foo")))
Take another look at the same example as above, but here making use of the merge(master:
operator <<-
:
let labelStyle: ViewStyle = [.textColor(.red), .textAlignment(.center)]
let fooLabel: Label = labelStyle <<- .text("Foo")
Here the operator <<-
actually creates a UILabel
directly, instead of having to first create a ViewStyle
.
Let's look at a ViewController example, making use of the strength of predefined styles and the <<-
operator:
private let labelStyle: ViewStyle = [.textColor(.red), .textAlignment(.center), .font(.boldSystemFont(ofSize: 30))]
class LabelsViewController: UIViewController {
private lazy var fooLabel: UILabel = labelStyle <<- .text("Foo")
private lazy var barLabel: UILabel = labelStyle <<- [.text("Bar"), .textColor(.blue), .color(.red)]
private lazy var bazLabel: UILabel = labelStyle <<- [.text("Baz"), .textAlignment(.left), .color(.green), .font(.boldSystemFont(ofSize: 45))]
lazy var stackView: UIStackView = [.views([self.fooLabel, self.barLabel, self.bazLabel]), .axis(.vertical), .distribution(.fillEqually)]
}
Compared to vanilla:
class LabelsViewControllerVanilla: UIViewController {
private lazy var fooLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Foo"
label.textColor = .red
label.textAlignment = .center
label.font = .boldSystemFont(ofSize: 30)
return label
}()
private lazy var barLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Bar"
label.backgroundColor = .red
label.textColor = .blue
label.textAlignment = .center
label.font = .boldSystemFont(ofSize: 30)
return label
}()
private lazy var bazLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "Baz"
label.backgroundColor = .green
label.textColor = .red
label.textAlignment = .left
label.font = .boldSystemFont(ofSize: 45)
return label
}()
lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [self.fooLabel, self.barLabel, self.bazLabel])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.distribution = .fillEqually
stackView.axis = .vertical
return stackView
}()
}
private let height: CGFloat = 50
private let style: ViewStyle = [.font(.big), .height(height)]
private let fieldStyle = style <<- .borderWidth(2)
final class LoginViewController: UIViewController {
lazy var emailField: UITextField = fieldStyle <<- [.placeholder("Email"), .delegate(self)]
lazy var passwordField: UITextField = fieldStyle <<- [.placeholder("Password"), .delegate(self)]
// can line break merge
lazy var loginButton: UIButton = style <<-
.states([Normal("Login", .blue), Highlighted("Logging in...", .red)]) <-
.target(self.target(#selector(loginButtonPressed))) <-
[.color(.green), .cornerRadius(height/2)]
lazy var stackView: UIStackView = .axis(.vertical) <-
.views([self.emailField, self.passwordField, self.loginButton]) <-
[.spacing(20), .layoutMargins(all: 20), .marginsRelative(true)]
...
}
extension LoginViewController: UITextFieldDelegate {
public func textFieldDidEndEditing(_ textField: UITextField) {
textField.validate()
}
}
private extension LoginViewController {
@objc func loginButtonPressed() {
print("should login")
}
}
Note how we pass in self
as associated value
to the attribute named .delegate
, setting the LoginViewController
class itself as UITextViewDelegate
.
Compare to vanilla:
private let height: CGFloat = 50
final class VanillaLoginViewController: UIViewController {
lazy var emailField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = "Email"
field.layer.borderWidth = 2
field.font = .big
field.delegate = self
field.addConstraint(field.heightAnchor.constraint(equalToConstant: height))
return field
}()
lazy var passwordField: UITextField = {
let field = UITextField()
field.translatesAutoresizingMaskIntoConstraints = false
field.placeholder = "Password"
field.layer.borderWidth = 2
field.font = .big
field.delegate = self
field.addConstraint(field.heightAnchor.constraint(equalToConstant: height))
return field
}()
lazy var loginButton: UIButton = {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.layer.cornerRadius = height/2
button.addConstraint(button.heightAnchor.constraint(equalToConstant: height))
button.setTitle("Login", for: .normal)
button.setTitle("Logging in..", for: .highlighted)
button.setTitleColor(.blue, for: .normal)
button.setTitleColor(.red, for: .highlighted)
button.backgroundColor = .green
button.addTarget(self, action: #selector(loginButtonPressed), for: .primaryActionTriggered)
return button
}()
lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [self.emailField, self.passwordField, self.loginButton])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 20
let margins: CGFloat = 20
stackView.layoutMargins = UIEdgeInsets(top: margins, left: margins, bottom: margins, right: margins)
stackView.isLayoutMarginsRelativeArrangement = true
return stackView
}()
...
}
extension VanillaLoginViewController: UITextFieldDelegate {
public func textFieldDidEndEditing(_ textField: UITextField) {
textField.validate()
}
}
private extension VanillaLoginViewController {
@objc func loginButtonPressed() {
print("should login")
}
}
We can also use the .delegates
attribute for UITextViewDelegate
and more delegate types are coming.
View the full of supported attributes list here.
Thanks to this code snippet from Olivier Halligon aka "AliSoftware" - creator of amazing open source project SwiftGen:
extension ViewAttribute {
static func l10n(_ key: L10n) -> ViewAttribute {
return .text(key.string)
}
}
You can make use of you L10n
enum cases like this:
let label: UILabel = [.l10n(.helloWorld), .textColor(.red)]
Clear, consise and type safe! ⚡️
As of now it is possible to create an attributes array with duplicate values, e.g.
// NEVER DO THIS!
let foobar: [ViewAttribute] = [.text("bar"), .text("foo")]
// NOR THIS
let foofoo: [ViewAttribute] = [.text("foo"), .text("foo")]
//NOR using style
let foobarStyle: Style = [.text("bar"), .text("foo")] // confusing!
// NOR this
let foofooStyle: Style = [.text("foo"), .text("foo")] // confusing!
It is possible to have an array of attributes containing duplicate values. But using it to instantiate a view, e.g. a UILabel
will in fact ignore the duplicate value.
let foobar: [ViewAttribute] = [.text("foo"), .text("bar")] //contains both, since array may contain duplicates
let label: UILabel = make(foobar) // func `make` calls `let style = attributes.merge(slave: [])` removing duplicates.
print(label.text!) // prints "foo", since duplicate value `.text("bar")` has been removed by call to `make`
Thus it is strongly discouraged to instantiate arrays with duplicate values. But the scenarios where you are merging types with duplicates is handled, since you chose which attribute you wanna keep using either merge:master
or merge:slave
.
An alternative to this, if you want to make use of some even more sugary syntax is to use the subclasses conforming to the type Composable
. You can find some examples (Label, Button, StackView etc) here.
In the current release of ViewComposer in order to use array literal, use must use the caret postfix operator ^
to create your Composable
types subclassing UIKit
classes.
final class Label: UILabel, Composable { ... }
...
let label: Label = [.text("foo")]^ // requires use of `ˆ`
One of the attributes is called custom
taking a BaseAttributed
type. This is practical if you want to create a view taking some custom attributes.
In the example app you will find two cases of using the custom
attribute, one simple and one advanced.
Let's say that you create the custom attribute FooAttribute
:
enum FooAttribute {
case foo(String)
}
Let us then create a shared protocol for all types that what to by styled using the FooAttribute
, let's call this protocol FooProtocol
:
protocol FooProtocol {
var foo: String? { get set }
}
In this example we only declared one attribute (case
) inside FooAttribute
but you can of course have multiple. The list of these should be contained in a type conforming to BaseAttributed
, which requires the func install(on styleable: Any)
. In this function we style the type with the attributes. Now it becomes clear that it is convenient to not skip step 2, and use a protocol bridging all types that can use FooAttribute
together.
struct FooStyle: BaseAttributed {
let attributes: [FooAttribute]
init(_ attributes: [FooAttribute]) {
self.attributes = attributes
}
func install(on styleable: Any) {
guard var foobar = styleable as? FooProtocol else { return }
attributes.forEach {
switch $0 {
case .foo(let foo):
foobar.foo = foo
}
}
}
}
We can now create some simple view conforming to FooProtocol
that we can style using the custom FooAttribute
. This might be a weird example, since why not just subclass UILabel
directly? But that would make the code too short and simple, since UILabel
already conforms to Styleable
. So it would be misleading and cheating. That is why have this example, since UIView
does not conform to Styleable
.
final class FooLabel: UIView, FooProtocol {
typealias Style = ViewStyle
var foo: String? { didSet { label.text = foo } } //
let label: UILabel
init(_ style: ViewStyle? = nil) {
let style = style <- .textAlignment(.center)]//default attribute
label = style <- [.textColor(.red)] //default textColor
super.init(frame: .zero)
compose(with: style) // setting up this view and calls `setupSubviews` below
}
}
extension FooLabel: Composable {
func setupSubviews(with style: ViewStyle) {
addSubview(label) // and add constraints...
}
}
Now we can create and style FooLabel
with our "standard" ViewAttribute
s but also pass along FooAttribute
using custom
, like this:
let fooLabel: FooLabel = [.custom(FooStyle([.foo("Foobar")])), .textColor(.red), .color(.cyan)]
Here we create the FooLabel
and styling it with our custom FooStyle
(container for FooAttribute
) while also styling it with textColor
and color
. This way you can combine custom attributes with "standard" ones.
Whole code can be found here in the example app
Please note that you cannot merging of custom attributes does not happen automatically. See next section.
Check out TriangleView.swift for example of advanced usage of custom attributes.
- Change implementation to use Codable/Encodable (Swift 4)?
- Fix bug where classes conforming to
Composable
and inheriting from superclass conforming toMakeable
cannot be instantiated using array literals. (requires ues of caret postfix operator^
)
- UIActivityIndicatorView
- UIButton
- UICollectionView
-
UIDatePicker
- UIImageView
- UILabel
- UIPageControl
- UIPickerView
- UIProgressView
-
UIScrollView
(Tricky sinceUITableView
andUICollectionView
inherits from it) - UISearchBar
- UISegmentedControl
- UISlider
- UIStackView
- UISwitch
-
UITabBar
- UITableView
- UITextField
- UITextView
-
UIToolbar
- UIWebView
- WKWebView