Three UIKit Protips
There are three patterns I use in most of my UIKit projects that I've never seen anyone else talk about. I think they help readability a lot, so I'm sharing them here:
- An
addSubviews
method to define your view hierarchy all at once - An
@AssignedOnce
property wrapper - A pattern for keeping view creation at the bottom of a file to keep the top clean
addSubviews
I've seen a lot of view and view controller code that looks like this:
override func loadView() {
view = UIView()
let scrollView = UIScrollView()
view.addSubview(scrollView)
let contentView = MyContentView()
scrollView.addSubview(contentView)
let topLabel = UILabel()
let button = UIButton()
contentView.addSubview(topLabel)
contentView.addSubview(button)
}
This style of setup is straightforward, but I usually have a difficult time understanding the view hierarchy without spending more time than I'd like.
In most of my projects, I use this simple extension to help with this problem:
extension UIView {
func addSubviews(_ subviews: UIView...) -> UIView {
subviews.forEach { self.addSubview($0) }
return self
}
}
Now, it's possible for the calls to addSubviews()
to visually resemble the view hierarchy!
override func loadView() {
view = UIView()
let scrollView = UIScrollView()
let contentView = MyContentView()
let topLabel = UILabel()
let button = UIButton()
view.addSubviews(
scrollView.addSubviews(
contentView.addSubviews(
topLabel,
bottomLabel)))
}
You can also use this pattern in UIView
initializers.
@AssignedOnce
When using storyboards, you commonly need to use force-unwrapped optional var
s to keep references to views and other things. fine, but there is no compile-time guarantee that the property can't be overwritten. Kotlin solves this problem with the lateinit
keyword, but Swift has no equivalent.
You can at least prevent multiple writes to vars
at runtime by using this simple property wrapper, which throws an assertion failure in debug builds if you write to the property more than once. It's not as good as a compile-time guarantee, but it does double as inline documentation.
@propertyWrapper
public struct AssignedOnce<T> {
#if DEBUG
private var hasBeenAssignedNotNil = false
#endif
public private(set) var value: T!
public var wrappedValue: T! {
get { value }
// Normally you don't want to be running a bunch of extra code when storing values, but
// since you should only be doing it one time, it's not so bad.
set {
#if DEBUG
assert(!hasBeenAssignedNotNil)
if newValue != nil {
hasBeenAssignedNotNil = true
}
#endif
value = newValue
}
}
public init(wrappedValue initialValue: T?) {
wrappedValue = initialValue
}
}
In practice, you can just add @AssignedOnce
in front of any properties you want to prevent multiple assignment to:
class MyViewController: UIViewController {
@AssignedOnce var button: UIButton! // assigned by storyboard
@AssignedOnce var label: UILabel! // assigned by storyboard
}
Looks pretty nice, right?
View Factories
The most critical part of any source code file is the first hundred lines. If you're browsing through code, it really helps to not have to scroll very much to see what's going on.
Unfortunately, it's very easy to gum up the top of a view view controller file by creating subviews over multiple lines, especially if (like me) you don't use storyboards at all. Here's what I mean:
class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
// I'm declaring all these as FUO `var`s instead of `let`s
// so I can instantiate them in loadView().
@AssignedOnce private var headerLabel: UILabel!
@AssignedOnce private var tableView: UITableView!
@AssignedOnce private var continueButton: UIButton!
override func loadView() {
view = UIView()
headerLabel = UILabel()
headerLabel.text = NSLocalizedString("List of things:", comment: "")
headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
headerLabel.textAlignment = .center
headerLabel.textColor = UIColor.systemBlue
continueButton = UIButton()
continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
tableView = UITableView()
tableView.dataSource = self
tableView.delegate = self
// more semi-arbitrary table view configuration
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
view.am_addSubviews(
headerLabel,
tableView,
continueButton)
// ... add constraints ...
}
// MARK: Actions
@objc private func continueAction() {
dismiss(animated: true, completion: nil)
}
// MARK: UITableViewDataSource
/* ... */
// MARK: UITableViewDelegate
/* ... */
}
This is OK, but do you really need to know the implementation details of all the views so near the top of the file? In my experience, those parts of the code are written once and then never touched again.
Additionally, it's not great to use force-unwrapped optionals to store anything. But if we use let
instead, then all views will be created at init time instead of in loadView()
.
View factories
We can solve a lot of problems by moving all view creation to the bottom of the file and using lazy var
.
class MyViewController: UIViewController: UITableViewDataSource, UITableViewDelegate {
private lazy var headerLabel = makeHeaderLabel()
private lazy var tableView = makeTableView()
private lazy var continueButton = makeContinueButton()
override func loadView() {
view = UIView()
view.am_addSubviews(
headerLabel,
tableView,
continueButton)
// ... add constraints ...
}
// MARK: Actions
@objc private func continueAction() {
dismiss(animated: true, completion: nil)
}
// MARK: UITableViewDataSource
/* ... */
// MARK: UITableViewDelegate
/* ... */
// MARK: View factories
private func makeHeaderLabel() -> UILabel {
let headerLabel = UILabel()
headerLabel.text = NSLocalizedString("List of things:", comment: "")
headerLabel.font = UIFont.preferredFont(forTextStyle: .largeTitle)
headerLabel.textAlignment = .center
headerLabel.textColor = UIColor.systemBlue
return headerLabel
}
private func makeTableView() -> UITableView {
let tableView = UITableView()
tableView.dataSource = self
tableView.delegate = self
// more semi-arbitrary table view configuration
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
return tableView
}
private func makeContinueButton() -> UIButton {
let continueButton = UIButton()
continueButton.setTitle(NSLocalizedString("Continue", comment: ""), for: .normal)
// `self` is available inside `lazy var` method calls!
continueButton.addTarget(self, action: #selector(continueAction), for: .touchUpInside)
return continueBUtton
}
}
The main advantage of this approach is that rarely-touched view creation code is both in a predictable place, and completely out of the way if you're browsing lots of files quickly. A bonus is that FUOs are not necessary due to the use of lazy var
. And the factory method return types enable you to remove the explicit types from the property declarations.
That's all, thanks for reading!