The problem

When working on big applications at some point we need to decide how to handle consistent components styling. The default approach chosen by many is to create subclasses of UIKit components and implement all styling there.

For example, let’s create a simple custom UISwitch with changing border, background colour and two visual styles - default and custom1.

class CustomOldSwitch: UISwitch {
    enum ComponentStyle {
        case `default`
        case custom1
        // ... more styles in the future
    }
    private let componentStyle: ComponentStyle

    init(componentStyle: ComponentStyle) {
        self.componentStyle = componentStyle
        super.init(frame: .zero)
        addTarget(self, action: #selector(changeStyle(_:)), for: .valueChanged)
        updateStyle()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func changeStyle(_ sender: CustomOldSwitch) {
        updateStyle()
    }

    private func updateStyle() {
        switch componentStyle {
        case .default:
            applyDefaultStyle()
        case .custom1:
            applyCustomStyle()
        }
    }

    private func applyDefaultStyle() {
        if isOn {
            self.layer.borderWidth = 1
        } else {
            self.layer.borderWidth = 0
        }
    }

    private func applyCustomStyle() {
        if isOn {
            self.layer.borderWidth = 1
            self.backgroundColor = .white
        } else {
            self.layer.borderWidth = 0
            self.backgroundColor = .gray
        }
    }
}

One of the biggest issues with this approach is a tendency for components to grow over time and becoming monsters with lots of code incorporating all future and past presentation cases. In our example, every new style requires a new enum case with corresponding applyXXX function and new switch entry. So it’s easy to imagine how quickly this component can grow over time.

In this article, I will present an alternative approach which helps creating customizable components and application styling.

Functions

Let’s start with creating a function on UIView type that sets view’s border width and colour.

func border<T: UIView>(
    _ view: T,
    width: CGFloat? = nil,
    color: UIColor? = nil
) {
    width.map { view.layer.borderWidth = $0 }
    color.map { view.layer.borderColor = $0.cgColor }
}

We may assume that all functions of this kind will need a view argument. Let’s try to rewrite it a bit.

func border<T: UIView>(
    width: CGFloat? = nil,
    color: UIColor? = nil
) -> (T) -> Void {
    return { view in
        width.map { view.layer.borderWidth = $0 }
        color.map { view.layer.borderColor = $0.cgColor }
    }
}

With this small change, we extracted a common type (T) -> Void for the family o functions operating on UIView subclasses.

This allows us to define our “Style” as a generic function operating on UIView subclass and returning Void.

typealias AppStyle<T: UIView> = (T) -> Void

This way a function declares how to get a change without actually executing it. The style is applied when we call the function with a view instance.

let border1ptStyle = border(width: 1) // a generic 1pt border style

let view = UIView()
border1ptStyle(view) // apply style 

Stylesheet

After defining what style is, let’s create a global entity for keeping our components styles.

enum AppStyleSheet {
    static let noBorderStyle = border(width: 0)
    static let strongBorderStyle = border(width: 4)
}

For now, we have only function creating border style. Let’s define a few more small helper function implementing common styling actions (corner radius, background colour, etc.).

For convenience, we will use a nested enum called Mixins to group them together.

extension AppStyleSheet {
    enum Mixins {}
}

extension AppStyleSheet.Mixins {
    static func border<T: UIView>(
        width: CGFloat? = nil,
        color: UIColor? = nil
    ) -> AppStyle<T> {
        return { view in
            width.map { view.layer.borderWidth = $0 }
            color.map { view.layer.borderColor = $0.cgColor }
        }
    }

    static func cornerRadius<T: UIView>(value: CGFloat) -> AppStyle<T> {
        return { view in
            view.layer.cornerRadius = value
        }
    }

    static func base<T: UIView>(
        backgroundColor: UIColor? = nil,
        clipsToBounds: Bool? = nil
    ) -> AppStyle<T> {
        return { view in
            backgroundColor.map { view.backgroundColor = $0 }
            clipsToBounds.map { view.clipsToBounds = $0}
        }
    }

    static func button<T: UIButton>(
        titleColor: UIColor,
        for state: UIControl.State
    ) -> AppStyle<T> {
        return { button in
            button.setTitleColor(titleColor, for: state)
        }
    }
}

Now we can use our functional toolbox to create global styles for our primary and secondary buttons.

extension AppStyleSheet {
    static let baseButtonStyle: AppStyle<UIButton> = { view in
        Mixins.base(backgroundColor: .white, clipsToBounds: true)(view)
        Mixins.border(width: 2, color: .black)(view)
        Mixins.cornerRadius(value: 8)(view)
        Mixins.button(titleColor: .black, for: .normal)(view)
        Mixins.button(titleColor: .gray, for: .highlighted)(view)
    }

    static let primaryButtonStyle: AppStyle<UIButton> = { view in
        baseButtonStyle(view)
        Mixins.border(color: .black)(view)
    }

    static let secondaryButtonStyle: AppStyle<UIButton> = { view in
        baseButtonStyle(view)
        Mixins.base(backgroundColor: .clear)(view)
        Mixins.border(width: 0)(view)
        Mixins.button(titleColor: .blue, for: .normal)(view)
    }
}

Refining

As you can see we were able to create a baseButtonStyle button style and reuse it for specific implementations. However, there is a small issue with this code. We need to remember to call each nested function with a view argument otherwise style won’t be applied. That’s a potential source of bugs.

Let’s fix it by introducing a function for composing an array of styles into one style.

func concat<T: UIView>(_ styles: [AppStyle<T>]) -> AppStyle<T> {
    return { view in
        styles.forEach {
            $0(view)
        }
    }
}

With this new tool, our code looks much cleaner and is less error-prone.

extension AppStyleSheet {
    static let baseButtonStyle: AppStyle<UIButton> = concat([
        Mixins.base(backgroundColor: .white, clipsToBounds: true),
        Mixins.border(width: 2, color: .black),
        Mixins.cornerRadius(value: 8),
        Mixins.button(titleColor: .black, for: .normal),
        Mixins.button(titleColor: .gray, for: .highlighted)
    ])

    static let primaryButtonStyle: AppStyle<UIButton> = concat([
        baseButtonStyle,
        Mixins.border(color: .black)
    ])

    static let secondaryButtonStyle: AppStyle<UIButton> = concat([
        baseButtonStyle,
        Mixins.base(backgroundColor: .clear),
        Mixins.border(width: 0),
        Mixins.button(titleColor: .blue, for: .normal)
    ])
}

An example

Before we go to the real-life example, let’s define a simple protocol which will help to apply a style on a view.

protocol Styleable {}

extension Styleable where Self: UIView {
    func withStyle(_ f: AppStyle<Self>) -> Self {
        f(self)
        return self
    }
}
extension UIView: Styleable {}

It’s time to put our style sheet to good use. Let’s define a simple screen with 2 buttons (primary and secondary) nested in the stack view.

class TestViewController: UIViewController {
    override func loadView() {
        view = UIView()
        view.backgroundColor = .white
        let primaryButton = UIButton().withStyle(AppStyleSheet.primaryButtonStyle)
        
        primaryButton.setTitle("Primary button", for: .normal)
        
        let secondaryButton = UIButton().withStyle(AppStyleSheet.secondaryButtonStyle)
        secondaryButton.setTitle("Secondary button", for: .normal)
        
        let container = UIStackView(arrangedSubviews: [primaryButton, secondaryButton])
        container.axis = .vertical
        container.spacing = 10
        container.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(container)
        NSLayoutConstraint.activate([
            view.centerXAnchor.constraint(equalTo: container.centerXAnchor),
            view.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            container.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8)
        ])
    }
}

As you can see, there is no need to create subclasses of the UIButton. We are using only standard UIKit stuff. Also applying a style is just one-liner.

With our functional approach, we need to create a custom UIView subclasses only in case when we want to encapsulate internal behaviour or create a complex view hierarchy.

To demonstrate simple custom implementation let’s reuse the example the beginning of this article and create a switch with a border changing on a status change.

class CustomSwitch: UISwitch {
    var onStyle: AppStyle<CustomSwitch> = AppStyleSheet.Mixins.border(width: 1) {
        didSet {
            changeStyle(self)
        }
    }
    var offStyle: AppStyle<CustomSwitch> = AppStyleSheet.Mixins.border(width: 0) {
        didSet {
            changeStyle(self)
        }
    }

    init() {
        super.init(frame: .zero)
        addTarget(self, action: #selector(changeStyle(_:)), for: .valueChanged)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func changeStyle(_ sender: CustomSwitch) {
        sender.isOn ? onStyle(self) : offStyle(self)
    }

}

class TestViewController: UIViewController {
    enum LocalStyles {
        static let switchButtonStyle: AppStyle<CustomSwitch> = {
            $0.offStyle = concat([
                AppStyleSheet.Mixins.border(width: 0),
                AppStyleSheet.Mixins.base(backgroundColor: .gray)
            ])
            $0.onStyle = concat([
                AppStyleSheet.Mixins.border(width: 1),
                AppStyleSheet.Mixins.base(backgroundColor: .white)
            ])
        }
    }

    override func loadView() {
        view = UIView()
        view.backgroundColor = .white
        let primaryButton = UIButton()
            .withStyle(AppStyleSheet.primaryButtonStyle)
        primaryButton.setTitle("Primary button", for: .normal)

        let secondaryButton = UIButton()
            .withStyle(AppStyleSheet.secondaryButtonStyle)
        secondaryButton.setTitle("Secondary button", for: .normal)

        let custom1Switch = CustomSwitch()
            .withStyle(LocalStyles.switchButtonStyle)
        let custom2Switch = CustomOldSwitch(componentStyle: .custom1)

        let container = UIStackView(arrangedSubviews: [
            primaryButton,
            secondaryButton,
            custom1Switch,
            custom2Switch
        ])
        container.axis = .vertical
        container.spacing = 10
        container.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(container)
        NSLayoutConstraint.activate([
            view.centerXAnchor.constraint(equalTo: container.centerXAnchor),
            view.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            container.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.8)
        ])
    }
}

In this example, we defined style properties for each of view states and then call them on state change. This way the component presentation is separated from its logic/behaviour. Also, it’s easy to add a new style without touching view’s implementation.

Tradeoffs

With this approach we need to be aware of one small limitation - the styling must be set from the code. So if we have UI in xib or storyboard we need to connect views to a ViewController and the then call style function on outlets.

Summary

As you can see with using the simple concept of a function we were able to create a quite comprehensive solution. When you compare the initial implementation of CustomSwitch you can easily see that after refactoring we were able to separate presentation style and make it independent from the logic code. This way we have a component that can be easily put into the library and reuse across multiple screens and applications.

Another important aspect of this solution is AppStyleSheet which groups all styles for the application. It’s our application centralized and consistent styling provider - a design system. This way you can create a shared naming between design team and developers.

You can play with the final code from this article using this playground.

References