Preventing views from being model aware in Swift
As developers, we constantly need to strike a balance between writing code that is convenient and code that is easy to maintain. The best is - of course - if we can achieve both, but that's not always easy, or sometimes even possible.
Something that tends to be particularly tricky when it comes to finding a good convenience/maintainability balance is when setting up relationships between the view layer and the model layer. Regardless of what architectural pattern that might be used, it's easy to create too strong connections between these layers, resulting in code that is both hard to refactor and hard to reuse.
This week, let's take a look at a few different ways that we can decouple our UI code from our model code, and some of the benefits of doing so. Although all code samples in this post will be iOS-specific, the principles should be applicable to any kind of Swift UI code.
Specialized views
Let's start by taking a look at an example. When building apps it's quite common to start creating specialized views that are purpose-built to display a certain type of data. Let's say we want to display a list of users in a table view, and we want to customize each cell's image to be rounded. A common way to do that is to create a new cell subclass that is specialized for rendering a user, like this:
class UserTableViewCell: UITableViewCell {
override func layoutSubviews() {
super.layoutSubviews()
let imageView = self.imageView!
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = imageView.bounds.height / 2
}
}
Since the above cell is built specifically to render users, it's also very common to add a functionality that lets us easily populate a cell instance with a User
model:
extension UserTableViewCell {
func configure(with user: User) {
textLabel?.text = "\(user.firstName) \(user.lastName)"
imageView?.image = user.profileImage
}
}
Doing something like the above may seem harmless, but technically we have actually started leaking model layer details into our view layer. Our UserTableViewCell
class is now not only specialized for a single use case, but also aware of the User
model itself. At first, that might not be a problem, but if we continue down this path it's easy to end up with view code that contains essential parts of our app's logic:
extension UserTableViewCell {
func configure(with user: User) {
textLabel?.text = "\(user.firstName) \(user.lastName)"
imageView?.image = user.profileImage
// Since this is where we do our model->view binding,
// it may seem like the natural place for setting up
// UI events and responding to them.
if !user.isFriend {
let addFriendButton = AddFriendButton()
addFriendButton.closure = {
FriendManager.shared.addUserAsFriend(user)
}
accessoryView = addFriendButton
} else {
accessoryView = nil
}
}
}
Writing UI code like above may seem really convenient, but usually ends up making an app really hard to both test & maintain. With the above setup, we have to create dedicated, specialized views for all of our app's models (even if they share a lot of functionality or look the same) - making it much harder to introduce new app-wide features or perform refactors in the future.
Generalized views
One solution to the above problem is to stick to a more strict separation between our view and model code. In doing so we should make sure not only to remove the use of model types from our UI code, but to conceptually separate the two layers as well.
Let's go back and take a look at our UserTableViewCell
again. Instead of strongly coupling it with rendering a User
, we could instead name it to describe what it actually does, which is making its image view round. Let's call it RoundedImageTableViewCell
and remove its configure
method that was strictly tied to the User
type:
class RoundedImageTableViewCell: UITableViewCell {
override func layoutSubviews() {
super.layoutSubviews()
let imageView = self.imageView!
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = imageView.bounds.height / 2
}
}
The big benefit of making the above changes is that we can now easily reuse this cell type for any other models that we wish to render with a rounded image. Our UI code is now also not making any hard assumptions about what it's rendering - it simply renders what it's told to render, which is usually a good thing.
However, in generalizing and decoupling our model code from our view code, we've also made it less convenient to use. Before, we could simply call configure(with:)
to start rendering a User
model, but now with that method gone we need to find a new way to do that (without having to duplicate the same data binding code all over our app).
What we can do instead, is to create a dedicated object that configures cells for displaying users. In this case, we'll call it UserTableViewCellConfigurator
, but depending on your architectural pattern of choice you might call it a Presenter or Adapter instead (we'll take a closer look at various such patterns in future posts). Either way, here's what such an object could look like:
class UserTableViewCellConfigurator {
private let friendManager: FriendManager
init(friendManager: FriendManager) {
self.friendManager = friendManager
}
func configure(_ cell: UITableViewCell, forDisplaying user: User) {
cell.textLabel?.text = "\(user.firstName) \(user.lastName)"
cell.imageView?.image = user.profileImage
if !user.isFriend {
// We create a local reference to the friend manager so that
// the button doesn't have to capture the configurator.
let friendManager = self.friendManager
let addFriendButton = AddFriendButton()
addFriendButton.closure = {
friendManager.addUserAsFriend(user)
}
cell.accessoryView = addFriendButton
} else {
cell.accessoryView = nil
}
}
}
We now have kind of the best of both worlds - generalized UI code that can easily be reused, combined with a convenient way to display a User
instance in a table view cell. As an added bonus, we've also taken steps to make our code more testable by using dependency injection for FriendManager
, instead of relying on a singleton (more on that in Avoiding singletons in Swift).
Whenever we want to render a User
using a table view cell, we can now simply use our configurator:
class UserListViewController: UITableViewController {
override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let user = users[indexPath.row]
configurator.configure(cell, forDisplaying: user)
return cell
}
}
View factories
Configurators (or similar objects) are a great fit for reusable views such as table- or collection view cells, since they need to be continuously re-configured as they are reused for a new model. But for more "static" views, it's often enough to be able to configure them once, since the model they're rendering is not going to change during their lifetime.
In such situations, using the Factory pattern can be a great option. That way we can bundle the creation and configuration of a view, while still keeping the UI code itself simple and fully decoupled from any model code.
Let's say we want to create an easy way to render a message in our app. We might have a view controller that displays a message, as well as some form of notification view that pops up when the user receives a new one. To not have to duplicate any code for those different use cases, let's create a MessageViewFactory
that lets us easily create a view for a given Message
:
class MessageViewFactory {
func makeView(for message: Message) -> UIView {
let view = TextView()
view.titleLabel.text = message.title
view.textLabel.text = message.text
view.imageView.image = message.icon
return view
}
}
As you can see above, not only do we use a generalized TextView
class to display our message (instead of a specialized one, like MessageView
), but we've also hidden what exact view class we use from the outside world (our method simply returns any UIView
). Like we took a look at in "Code encapsulation in Swift", removing concrete types from APIs can be a great way to make code much easier to change and work with in the future.
Conclusion
Maintaining a strict boundary between our view layer and model layer often leads to much more flexible and easier to reuse code. As our app evolves, we don't need to constantly create new specialized view classes, and can instead build upon the UI code we have already written. We could also take this concept one step further, and even decouple the styling of our UI from the views themselves as well, but we'll save that for a future post 😉.
Does this mean that all UI code should always be fully generalized and ready to render any model? I personally don't think so. Creating specialized views is something we sometimes have to do - for example when creating very custom graphics or clusters of views that need each other in order to work. Attempting to generalize things in such situations could instead make us end up with code that is really complicated and hard to navigate. Like always, it's all about tradeoffs, and attempting to strike the right balance for each situation.
What do you think? Do you currently write your UI code in a more generalized fashion, or is it something you'll try out? Let me know - along with any questions, comments or feedback that you might have - on Twitter @johnsundell.
Thanks for reading! 🚀