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.