Skip to content

Compose views using enums swiftly: `let label: UILabel = [.text("Hello"), .textColor(.red)]`

License

Notifications You must be signed in to change notification settings

Sajjon/ViewComposer

Repository files navigation

Platform Carthage Compatible Version License

ViewComposer

Style views using an enum array with its attributes:

let label: UILabel = [.text("Hello World"), .textColor(.red)]

Table of Contents

Installation

Swift 4

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'

Swift 3

pod 'ViewComposer', '~> 0.2'
github "ViewComposer" ~> 0.2
dependencies: [
    .Package(url: "https://github.com/Sajjon/ViewComposer.git", majorVersion: 0)
]

Manually

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.

Style views using enums swiftly

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
    }()

    ...
}

Non-intrusive - standard UIKit views

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
}()

Mergeable

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.

Examples

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)]

Optional styles

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)]
}

Merge operators <- and <<-

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 ViewStyles, 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
    ...

ViewComposer uses right operator associativty for chained merges

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.

Predefined styles

You can also declare some standard style, e.g. font, textColor, textAlignment and upper/case strings that you wanna use for all of your UILabels. 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
    }()
}

Passing delegates

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.

Supported attributes

View the full of supported attributes list here.

SwiftGen support

ViewComposer + SwiftGen = ❤️

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! ⚡️

CAUTION: Avoid arrays with duplicate values.

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.

Composables

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 `ˆ`

Custom attribute

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.

Creating a simple custom attribute

Let's say that you create the custom attribute FooAttribute:

Step 1: Create attribute enum

enum FooAttribute {
    case foo(String)
}

Step 2 (optional): Protocol for types using custom attribute

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 }
}

Step 3 (final): Create style holding list of custom attributes

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
            }
        }
    }
}

Usage of FooAttribute

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" ViewAttributes 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.

Merging custom attributes

Check out TriangleView.swift for example of advanced usage of custom attributes.

Roadmap

Architecture/implementation

  • Change implementation to use Codable/Encodable (Swift 4)?
  • Fix bug where classes conforming to Composable and inheriting from superclass conforming to Makeable cannot be instantiated using array literals. (requires ues of caret postfix operator ^)

Supported UIKit views

About

Compose views using enums swiftly: `let label: UILabel = [.text("Hello"), .textColor(.red)]`

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published