Building Better Views (Part I)
As iOS developers, a lot of our work involves taking models from a server, and transforming them to be displayed on an iPhone or iPad. This sounds like a job for some declarative architecture. đ¤
If you ask 3 programmers how to define MVVM, expect to get 7 different responses.
— ⨠Joe Fabisevich⢠⨠(@mergesort) April 14, 2016
Confession: Iâve never fully bought into MVVM. I donât think itâs worse than MVC. I use View Models as a place to store state and actions for View Controllers, and preferably stateless functions for manipulating data. In my experience, things become harder to maintain when they start becoming a crutch, as a place to put your code if it doesnât neatly fall into the Model, View, or Controller label.
With this in mind, I realized we need an answer for configuring our views in a way thatâs maintainable, and ultimately transforms one or multiple models into a view. This led me to the idea of ViewData
. I started working on this with @shengjundong at Timehop, and have been using it successfully across apps of varying sizes since.
There are three parts to this approach:
-
A
UIView
instance. This is your standard view that youâll be displaying in an app. It can be a regular class, or a custom subclass as you need. -
A
ViewData
protocol. This is whatâs going to keep track of the data that needs to be displayed in your view. Most commonly this will be a slice of a model, used specifically for rendering the view. -
A
configure(viewData: ViewData)
function. This is whatâs going to map your View to your ViewData.
An Example
Letâs start with an example, where weâre building a view to display a comment. It will have a few properties youâd expect from a comment view. A commenter, their avatar, some text, and a timestamp. To make it easier to visualize, letâs imagine it looks like this:
We start with a simple model. This is what weâll be later manipulating for display purposes.
public struct Comment {
let text: String
let commenter: String
let createdAt: Date
let avatarURL: URL?
}
A simple UIView
subclass to display the comment.
public final class CommentView: UIView {
let titleLabel = UILabel()
let subtitleLabel = UILabel()
let statusLabel = UILabel()
let replyButton = UIButton(type: .custom)
let avatarImageView = UIImageView()
}
Now we get a little to the fun stuff.
Weâll make our first ViewData
protocol. This represents how we will render the data weâre trying to populate the UIView
with.
protocol CommentViewData {
var title: String { get }
var subtitle: String { get }
var timestamp: String { get }
var replyText: String { get }
var avatarURL: URL? { get }
}
Letâs conform our model to our CommentViewData
protocol. This will be how we tell our CommentView
how it should display our model whenever it comes across an instance of it.
// The original data source is made to conform to the protocol which we are using for display, CommentViewData
extension Comment: CommentViewData {
var title: String {
return self.commenter
}
var subtitle: String {
return self.text
}
var replyText: String {
return NSLocalizedString("Reply", comment: "Text for replying to a comment")
}
var replyImage: UIImage? {
return UIImage(named: "reply")
}
var timestamp: String {
return self.createdAt.timeAgoSinceNow
}
}
One thing to note is that the avatarURL
property automatically conforms to the CommentViewData
! As long as we plan to use it directly, we donât have to add it to our extension.
Last but not least, we need to configure the CommentView
with a CommentViewData
.
extension CommentView {
func configure(viewData: CommentViewData) {
self.titleLabel.text = viewData.title
self.subtitleLabel.text = viewData.subtitle
self.statusLabel.text = viewData.timestamp
self.replyButton.setTitle(viewData.replyText, for: .normal)
self.replyButton.setImage(viewData.replyImage, for: .normal)
self.avatarImageView.setImage(from: viewData.avatarURL)
}
}
Weâve got everything configured in a nice declarative fashion, but how do we actually use this? This is in my opinion the best part. Letâs look at the call-site.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// My own homegrown solution, you're under no obligation to use it of course đ
let cell = tableView.dequeueReusableCell(forIndexPath: indexPath) as GenericTableCell<CommentView>
// This is of type `Comment`
let currentComment = self.comments[indexPath.row]
// Comment conforms to `CommentViewData`, so we can use it directly!
cell.customView.configure(viewData: currentComment)
return cell
}
And thatâs it! All you need to do is pass the original model object to the view, and as long as it conforms to the right protocol, youâve got it working without any intermediate objects.
This may seem like a lot of boilerplate, and to be honest, it's more than I would like. There are other languages with features such as row polymorphism or extensible records which would make this easier. Until Swift supports these language features, or macros, or more powerful tooling that can fill the gaps, this is the best solution Iâve found to enforcing good practices and leveraging compile-time safety for view configuration.
Now you may also be thinking âsometimes my models donât map to how theyâre displayed one to one, how can I make that work?â Follow along with part 2, where we'll cover that, and a few other questions you may have.
As always, I'm excited to hear your thoughts, and am receptive to feedback!
Joe Fabisevich is an indie developer creating software at Red Panda Club Inc. while writing about design, development, and building a company here at build.ms. Before all that he was working as an iOS developer on societal health issues @Twitter.
Like my writing? You can keep up with it in your favorite RSS reader, or get posts emailed in newsletter form. I promise to never spam you or send you anything other than my posts, it's just a way for you to read my writing wherever's most comfortable for you.
If you'd like to know more, wanna talk, or need some advice, feel free to sign up for office hours at no charge, I'm very friendly. đ