Posted 7/22/2018.
Apple's documentation on Model View Controller (MVC) says the following about views:
A major purpose of view objects is to display data from the application’s model objects and to enable the editing of that data. Despite this, view objects are typically decoupled from model objects in an MVC application.
In practice, it is not uncommon for view subclasses to be coupled with model objects. It's typical to see User
and UserTableViewCell
or Project
and ProjectHeaderView
classes.
In this tutorial, we'll see how we can use protocols to make custom view subclasses more generic. The benefit is that they'll be easier to test and reuse, and our code will be easier to read and maintain.
Let's start with the example of a User
model and a UserTableViewCell
view. The UITableViewCell subclass might look like this:
import UIKit
class UserTableViewCell: UITableViewCell {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var locationLabel: UILabel!
@IBOutlet weak var profileImageView: UIImageView!
func configure(with user: User) {
nameLabel.text = [user.firstName, user.lastName].joined(separator: " ")
locationLabel.text = user.location
guard let profileImageURL = user.profileImageURL else { return }
loadImage(withURL: profileImageURL)
}
func loadImage(withURL url: String) {
// loads image into UIImageView
}
}
This cell has two labels and an image view, displaying the name, location, and profile image of a user.
The corresponding User
model could be:
class User {
let id: String
let firstName: String
let lastName: String
let email: String
let location: String
let profileImageURL: String?
let birthday: Date
init(id: String, firstName: String, lastName: String, email: String, location: String, profileImageURL: String?, birthday: Date) {
self.id = id
self.firstName = firstName
self.lastName = lastName
self.email = email
self.location = location
self.profileImageURL = profileImageURL
self.birthday = birthday
}
}
The first thing you probably noticed is that this User
model contains more information than the view needs. The properties id
, email
, and birthday
are not used in the view. This makes the view more cumbersome to test than it needs to be. To test the configure
method, we need to pass in instances of User
, which means we need to pass in unnecessary IDs, email addresses, and birthdays.
This extra code also makes the code more difficult to read and understand. Really all the view needs for the first label is a "name", but our User
model has separate properties for first and last names. The view then becomes responsible for determining how the full name of a User
is displayed when it shouldn't be.
To improve the situation we can add the following protocol:
protocol UserCellItem {
var name: String { get }
var location: String { get }
var imageURL: String? { get }
}
User
can conform as follows:
extension User: UserCellItem {
var name: String { return [firstName, lastName].joined(separator: " ") }
var imageURL: String? { return profileImageURL }
}
Now in UserTableViewCell
, the configure
method can be replaced with:
func configure(with item: UserCellItem) {
nameLabel.text = item.name
locationLabel.text = item.location
guard let profileImageURL = item.imageURL else { return }
loadImage(withURL: profileImageURL)
}
Much cleaner. The cell no longer needs to care about a User
model and its extra properties, and it doesn't need to know how a "name" is derived from a User
.
But why is this cell tied to the idea of a "user" at all? It's easy to imagine the following Team
model displayed by the same cell:
class Team {
let id: String
let name: String
let location: String
let profileImageURL: String?
init(id: String, name: String, location: String, profileImageURL: String?) {
self.id = id
self.name = name
self.location = location
self.profileImageURL = profileImageURL
}
}
extension Team: UserCellItem {
var imageURL: String? { return profileImageURL }
}
You probably see where this is going. Our cell class is really a basic UITableViewCell that displays a title, detail, and image. Our protocol should really look like this:
protocol BasicCellItem {
var title: String { get }
var detail: String { get }
var imageURL: String? { get }
}
With some renaming, our cell class becomes:
class BasicTableViewCell: UITableViewCell {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var detailLabel: UILabel!
@IBOutlet weak var profileImageView: UIImageView!
func configure(with item: BasicCellItem) {
titleLabel.text = item.title
detailLabel.text = item.detail
guard let imageURL = item.imageURL else { return }
loadImage(withURL: imageURL)
}
func loadImage(withURL url: String) {
// loads image into UIImageView
}
}
Testing this class is now very easy:
struct BasicItem: BasicCellItem {
let title: String
let detail: String
let imageURL: String?
}
class BasicTableViewCellTests: XCTestCase {
func testConfigure() {
let bundle = Bundle(for: BasicTableViewCell.self)
guard let cell = bundle.loadNibNamed("BasicTableViewCell", owner: nil)?.first as? BasicTableViewCell else { return XCTFail() }
let item = BasicItem(title: "Test", detail: "Description", imageURL: nil)
cell.configure(with: item)
XCTAssertEqual(cell.titleLabel.text, "Test")
XCTAssertEqual(cell.detailLabel.text, "Description")
}
}
We have a simple struct conforming to BasicCellItem
with the sole purpose of testing the view. We don't need to worry about generating values for extra User
, Team
, or any other model's properties. In fact, because this view is now no longer coupled with a model, we could extract it into a separate framework and reuse it in other apps with very different models.
When starting a new app, it can be easy to create more specialized views than we need. We may not know exactly which information the view will eventually need to display or how similar it will be to views displaying other data. So we may default to a 1:1 coupling of model to view.
In this tutorial, we saw the benefits of using protocols to make views more generic. They become reusable and easier to test. Our code also becomes more modular. When views are tightly coupled with models, we can't extract them into reusable frameworks. Once freed from the model layer, we can use them in other apps and share them with the open source community.