Encapsulating configuration code in Swift
Basics article available: ClosuresStriking a nice balance between code reuse and configurability can often be quite challenging. While we’d ideally like to avoid repeating code and accidentally creating multiple sources of truth, much of how our various objects and values need to be configured tends to be dependent on the context that they’re used in.
This week, let’s take a look at a few different techniques that can let us achieve such a balance — by building lightweight abstractions that enable us to encapsulate our configuration code, and how those abstractions can then be shared across a code base to also increase its level of consistency.
Building components, rather than screens
When doing any kind of software development, it often helps to slice a program up into various parts, in order to be able to deal with them as separate units. For UI-heavy applications, such as iOS and Mac apps, it’s often tempting to do that kind of slicing based on the various screens that make up the app. For example, a shopping app might have a product screen, a list screen, a search screen, and so on.
While that kind of screen-level slicing makes a lot of sense from a high-level perspective (especially since it matches the way we tend to discuss our apps with other collaborators — like testers and designers), it tends to result in UI code that needs to be quite heavily configured for each screen.
Take this ProductViewController
for example, which contains a buy button, as well as views for displaying details and related items for each product — all of which are configured within the view controller’s viewDidLoad
method:
class ProductViewController: UIViewController {
let product: Product
...
override func viewDidLoad() {
super.viewDidLoad()
// Buy button
let buyButton = UIButton(type: .custom)
buyButton.setImage(.buy, for: .normal)
buyButton.backgroundColor = .systemGreen
buyButton.addTarget(self,
action: #selector(buyButtonTapped),
for: .touchUpInside
)
view.addSubview(buyButton)
// Product detail view
let productDetailView = UIView()
...
// Related products view
let relatedProductsView = UIView()
...
}
}
Even though we’ve attempted to make the above code a bit easier to read by adding a comment before each configuration block, our current viewDidLoad
implementation does arguably suffer from a lack of structure. Since all of our configuration happens in one place, it’s easy for variables to be accidentally used in the wrong context, and for our code to become increasingly intertwined as time goes on.
Like we took a look at in “Writing self-documenting Swift code”, one way to mitigate the above problem is to simply break out the different parts of our configuration code into separate methods, which viewDidLoad
can then call:
private extension ProductViewController {
func setupBuyButton() {
let buyButton = UIButton(type: .custom)
...
}
func setupProductDetailView() {
let productDetailView = UIView()
...
}
func setupRelatedProductsView() {
let relatedProductsView = UIView()
...
}
}
While the above approach does solve our structural problem, and definitely makes our code more self-documenting and easier to read, it still strongly couples our individual view components with their presenting container — ProductViewController
in this case.
That’s probably not a problem for one-off views that are currently only used within a single view controller, but for more general-purpose UI code, it’d be nice if we were able to easily reuse our various configurations across our code base.
One way to do so, which doesn’t require any new types to be defined, is to use static factory methods — which enable us to encapsulate the way we configure each view, in a way that’s both easy to define, and easy to use:
extension UIView {
static func buyButton(withTarget target: Any, action: Selector) -> UIButton {
let button = UIButton(type: .custom)
button.setImage(.buy, for: .normal)
button.backgroundColor = .systemGreen
button.addTarget(target, action: action, for: .touchUpInside)
return button
}
}
The beauty of static factory methods is that they enable us to call our APIs in a way that’s similar to enums — using Swift’s very lightweight dot syntax. If we’d also define similar methods as the above one for creating a buy button, then we could end up with a viewDidLoad
implementation that simply looks like this:
class ProductViewController: UIViewController {
let product: Product
...
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(.buyButton(
withTarget: self,
action: #selector(buyButtonTapped)
))
view.addSubview(.productDetailView(
for: product
))
view.addSubview(.relatedProductsView(
for: product.relatedProducts,
delegate: self
))
}
}
That’s really neat! Gone are the local variables, and we can still fit all of our view setup code within one method, while also giving us a much greater degree of encapsulation — and complete reusability, since we can now easily construct the above kind of views wherever we might need them.
Multiple configuration steps
While the above approach works great for UI configuration code that ideally should remain the same across our entire code base, such as setting up common components, we also often need to extend such configurations in a way that’s much more context-specific.
For example, we probably need to apply some form of layout to our views, to update or bind certain pieces of state to them, or otherwise customize their behaviors or appearance depending on the feature that they’re used in.
To make it easy to do so, let’s extend UIView
with a convenience API — that simply executes a closure after adding a given view as a subview, like this:
extension UIView {
@discardableResult
func add<T: UIView>(_ subview: T, then closure: (T) -> Void) -> T {
addSubview(subview)
closure(subview)
return subview
}
}
With the above method in place, we can now keep using that nice dot syntax to create our views, while still enabling us to apply context-specific configurations as well — for example in order to add a set of Auto Layout constraints:
class ProductViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
view.add(.buyButton(
withTarget: self,
action: #selector(buyButtonTapped)
), then: {
NSLayoutConstraint.activate([
$0.topAnchor.constraint(equalTo: view.topAnchor),
$0.trailingAnchor.constraint(equalTo: view.trailingAnchor)
...
])
})
...
}
}
Although the above syntax might take a while to get used to, it does sort of give us the best of both worlds — we’re now able to fully encapsulate both our global and local configurations, while also enforcing a certain degree of structure as well. It also lets us easily share view components among different screens, without requiring us to define any new UIView
subclasses.
A declarative structure
What’s also interesting about the above approach is how it starts making our imperative UIKit-based code slightly more declarative — as we’re no longer continuously setting up our various views within our view controllers, but rather declaring what sort of configuration we wish to use. That sort of moves us closer to the world of SwiftUI, which could help ease our transition to that new world in the future.
Just compare how our ProductViewController
might look if expressed as a SwiftUI view instead — structurally, it’s really quite similar to our above UIKit-based approach:
struct ProductView: View {
var product: Product
var body: some View {
VStack {
BuyButton {
// Handling code
...
}
ProductDetailView(product: product)
RelatedProductsView(products: product.relatedProducts) {
// Handling code
...
}
}
}
}
That of course doesn’t mean that we’ve automatically made our UIKit-based code SwiftUI-compatible, just by modifying its structure — but by using a similar way of thinking around how we organize our various view configurations, we can at least start to become more familiar with increasingly declarative coding styles.
Configuration closures
Although much of the configuration code that we write when developing UI-based apps tends to be centered around the view layer, other parts of our code base often needs to be quite heavily configured as well — especially logic that’s written directly on top of system APIs.
For example, let’s say that we’re building a type for parsing some form of metadata from a string, and that we’d like to use a shared DateFormatter
among all instances of that type. To do that, we might define a private static property that is configured using a self-executing closure:
struct MetadataParser {
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}()
func metadata(from string: String) throws -> Metadata {
...
}
}
While self-executing closures are incredibly convenient, using them to configure properties can often “push” the core functionality of a type further and further down — which in turn can make it harder to quickly get an overview of what a type is actually doing. To mitigate that problem, let’s see if we can do something to make such configuration closures as compact as possible, without sacrificing readability.
Let’s start by defining a function called configure
, which simply takes any object or value, and lets us apply any sort of mutations to it inside of a closure, using the inout
keyword — like this:
func configure<T>(_ object: T, using closure: (inout T) -> Void) -> T {
var object = object
closure(&object)
return object
}
To configure the shared DateFormatter
for our metadata parsers, we can now simply pass it to the above function, and easily configure it using the $0
closure argument shorthand — leaving us with code that’s more compact, while still remaining just as readable:
struct MetadataParser {
private static let dateFormatter = configure(DateFormatter()) {
$0.dateFormat = "yyyy-MM-dd HH:mm"
$0.timeZone = TimeZone(secondsFromGMT: 0)
}
func metadata(from string: String) throws -> Metadata {
...
}
}
The above way of configuring properties is arguably even easier to understand than self-executing closures — since by adding the call to configure
, we’re making it crystal clear that the purpose of the accompanying closure is, in fact, to configure the instance that’s passed into it.
Conclusion
Just like any topic that relates to code style and structure, how to best configure objects and values will most likely always remain a matter of taste. However, regardless of how we actually end up configuring our code — if we can do so in a way that’s fully encapsulated, then those configurations tend to be much easier to both reuse and manage.
Starting to adopt increasingly declarative coding styles and patterns can also further help ease the transition into the world of SwiftUI and Combine, even if we might expect that it’ll take a year or two before we can actually start adopting those frameworks. Arguably, declarative programming is just as much about ways of thinking, as it is about APIs and syntax.
What do you think? How do you currently configure your views and other values and objects? Let me know — along with your questions, comments or feedback — either on Twitter or via email.
Thanks for reading! 🚀