UIView styling with functions

Today I want to talk about UIView styling. The common approach when we want to customize the display of native UI controls (for instance UIButton or UILabel) is to create a subclass that overrides a bunch of properties. That works well most of the time, but some problems may arise.

The filled and rounded button

We you use subclasses in Swift to style your views, you loose composition. For example, if you create two UIButton subclasses, FilledButton and RoundedButton, how do you create a button that is both filled and rounded ?

One solution of this problem is to stop using subclasses, but to leverage Swift functions and type system.

We can think about a view style, as a function that consumes a view, and set some properties on it.

let filledButtonStyle: (UIButton) -> Void = {
    $0.setTitleColor(.white, for: .normal)
    $0.backgroundColor = .red
}

let button = UIButton()
button.setTitle("My Button", for: .normal)
filledButtonStyle(button)
Image 1. Filled button

Image 1. Filled button

We can wrap this function into an object, for more control on it.

struct ViewStyle<T> {
    let style: (T) -> Void
}

We can now create some styles for our filled and rounded button.

let filled = ViewStyle<UIButton> {
    $0.setTitleColor(.white, for: .normal)
    $0.backgroundColor = .red
}

let rounded = ViewStyle<UIButton> {
    $0.layer.cornerRadius = 4.0
}

let button = UIButton()
filled.style(button)

Now that we have our two styles for both filled and rounded buttons, we can create a new style for a rounded and filled button very easily.

extension ViewStyle {

    func compose(with style: ViewStyle<T>) -> ViewStyle<T> {
        return ViewStyle<T> {
            self.style($0)
            style.style($0)
        }
    }
}

let roundedAndFilled = filled.compose(with: rounded)

What was previously impossible with UIButton subclasses is now very straightforward using simple functions.

Image 2. Filled and rounded button

Image 2. Filled and rounded button

Improvements

Now that we get the general idea, it’s time for syntactic sugar!

First of all, for now our styles live in the global namespace. That’s not very scalable.

The solution here is to extend ViewStyle and to constrain the generic type.

extension ViewStyle where T: UIButton {

    static var filled: ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.setTitleColor(.white, for: .normal)
            $0.backgroundColor = .red
        }
    }

    static var rounded: ViewStyle<UIButton> {
        return ViewStyle<UIButton> {
            $0.layer.cornerRadius = 4.0
        }
    }

    static var roundedAndFilled: ViewStyle<UIButton> {
        return filled.compose(with: rounded)
    }
}

That’s nice, we have a namespace to list all our styles. But it’s not very handy to style a button yet.

ViewStyle<UIButton>.roundedAndFilled.style(button) // 🙈

To improve this, we can define a function that is responsible to apply a style to an object, inferring the type of the style based on the type of the object.

func style<T>(_ object: T, with style: ViewStyle<T>) {
    style.style(object)
}

style(button, with: .roundedAndFilled)

Protocols to the rescue

The code looks good and is readable. But we can go one step further! I want to get rid of the global style(_:with:) function, and to use an instance method of UIButton instead. For this, let’s define an empty protocol Stylable, and make UIView conform to it. That way we will be able to add methods to Stylable and all the UIView subclasses will get them for free.

protocol Stylable {}

extension UIView: Stylable {}

That may seem a little odd, but we can now extend Stylable to add a method to apply a style to any Stylable instance.

extension Stylable {

    func apply(_ style: ViewStyle<Self>) {
        style.style(self)
    }
}

All the UIView subclasses gain this apply(_:) method for free! The code becomes compact and readable.

button.apply(.roundedAndFilled)

What’s more, we can’t misuse our styles because of the Swift type system!

let labelStyle = ViewStyle<UILabel> { $0.textAlignment = .center }
button.apply(labelStyle) // 💣

// error: cannot convert value of type 'ViewStyle<UILabel>' to expected argument type 'ViewStyle<UIButton>'

Init with style

With the previous apply(_:) method you will often find yourself writing these two lines:

let button = UIButton()
button.apply(.rounded)

What if we could initialize our button (or any other UIView) with a predefined style? It would save us one line of code each time.

That is possible, modifying slightly our Stylable protocol.

protocol Stylable {
    init()
}

extension UIView: Stylable {}

extension Stylable {

    init(style: ViewStyle<Self>) {
        self.init()
        apply(style)
    }

    func apply(_ style: ViewStyle<Self>) {
        style.style(self)
    }
}

We can now use the following syntax:

let button = UIButton(style: .roundedAndFilled) // 👌

Conclusion

With view styles as plain Swift functions, we achieved two things:

You can find the full gist here.