Have you ever seen the term ‘MVVM’ (Model-View-ViewModel) and been intimidated by yet another acronym in our industry that you don’t understand? In this article, I’ll explain how you are very likely already doing MVVM, and you’ll see how to tidy it up into a neat little state machine.
You’re probably doing something like this in your view controllers. Nothing wrong with it, it’s a good place to start:
class ProductListViewController: UIViewController {
var isLoaded: Bool = false
var isLoading: Bool = false
var products: [Product]?
var error: NSError?
...
}
See those four instance variables? Those are your ViewModel - see, you’re doing MVVM already, without realising it - no big deal.
So here’s a few stabs at a working definition of a ViewModel: It’s the variables that drive what is being viewed. Or the model for what’s happening in your views. As opposed to your real model, which has eg Products, Customers, Orders, etc.
A ‘state machine’ is one of those computer-science concepts that goes pretty deep. But for our purposes, all I mean is that your view controller has a limited set of possible states it can be in.
Here’s a good analogy: It’s very much like the gear selector in your car, you only have limited options: F, N, R, D (or 1..5+R if you love driving a manual!).
So what are the kind of states you’re likely to see in a view controller:
So lets bring the ViewModel and state machine concepts together into one nice package.
Now if the above sounds like an Enum, you’re right! So lets tidy up our original bunch of variables into an enum and a single state variable - emphasis on single:
class ProductListViewController: UIViewController {
enum State {
case Loading
case Empty
case Loaded([Product])
case Error(NSError)
}
var state = State.Loading
...
}
One advantage here is that there is zero ambiguity about which state you’re in. In the earlier example, it is possible for isLoaded and isLoading to both be true if you make a coding mistake, which is a confusing situation. But with an enum that is simply impossible.
Next, I recommend using a didSet
handler on the variable to update your UI. Eg:
var state = State.Loading {
didSet {
... update views ...
}
}
Now it’s a simple matter of simply setting the value of the state variable whenever you want your UI to change. Eg your data fetching code will look as simple as the following:
func loadProducts() {
state = .Loading
ProductManager.sharedManager.requestProducts(success: { products in
if products.count > 0 {
self.state = .Loaded(products)
} else {
self.state = .Empty
}
}, failure: { error in
self.state = .Error(error)
})
}
To make the above example make more sense, here’s some example code for the product manager:
struct Product {
// ...
}
class ProductManager {
static let sharedManager = ProductManager()
func requestProducts(
success success: [Product] -> (),
failure: NSError -> ()) {
// ...
}
}
And for the sake of a half-fleshed-out example, here’s something I commonly do: I have a view controller with a table view. For the loaded state, normal data rows show. For error state, one entire-screen-height cell shows with an error message. For empty state, one big cell with a helpful message shows. And for loading state, we have one big cell with an activity indicator. It all comes together beautifully as we’ll go over now:
When setting the state, all that is required to update is to call the table’s reloadData method:
var state = State.Loading {
didSet {
tableView?.reloadData()
}
}
The table data source then looks like below. It is responsible for showing one special cell for the loading/empty/error states, as well as the typical product cells:
extension ProductsViewController: UITableViewDataSource {
func tableView(tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
switch state {
case .Loading, .Empty, .Error:
return 1
case .Loaded(let items):
return items.count
}
}
func tableView(tableView: UITableView,
cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
switch state {
case .Loading:
return tableView.dequeueReusableCellWithIdentifier(LoadingCell.cellId, forIndexPath: indexPath)
case .Error(let error):
let cell = tableView.dequeueReusableCellWithIdentifier(CaptionCell.cellId, forIndexPath: indexPath) as! CaptionCell
cell.caption.text = error.localizedDescription
return cell
case .Empty:
let cell = tableView.dequeueReusableCellWithIdentifier(CaptionCell.cellId, forIndexPath: indexPath) as! CaptionCell
cell.caption.text = "There are no products to view today, sorry!"
return cell
case .Loaded(let products):
let product = products[indexPath.row]
let cell = tableView.dequeueReusableCellWithIdentifier(ProductCell.cellId, forIndexPath: indexPath) as! ProductCell
cell.textLabel?.text = product.name
cell.detailTextLabel?.text = product.description
return cell
}
}
}
And the table view delegate is responsible for making those special cells fill the whole screen:
extension ProductViewController: UITableViewDelegate {
func tableView(tableView: UITableView,
heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
switch state {
case .Loading, .Empty, .Error:
return tableView.bounds.height
case .Loaded:
return tableView.rowHeight
}
}
}
And that’s pretty much it for this post! Read on if you’re curious about advanced enums.
A friend asked me to write about this one: An interesting technique you can use is nested enums. Now it can be a bit over-the-top, so use it judiciously, but here goes:
Say your state machine, when drawn out on paper, consists of maybe two ‘top-level’ states, but if you drill down there are more subtle states that are possible. Basically a hierarchy of states, like so:
Logged in
Playing
Paused
Stopped
Logged out
Registered
Unregistered
You may want to consider nesting your enums like so:
enum UserState {
case LoggedIn(LoggedInState)
case LoggedOut(LoggedOutState)
}
enum LoggedInState {
case Playing
case Paused
case Stopped
}
enum LoggedOutState {
case Unregistered
case Registered
}
var x = UserState.LoggedIn(.Playing)
var y = UserState.LoggedIn(.Stopped)
var z = UserState.LoggedOut(.Unregistered)
I’ll leave this as an exercise to the reader.
Hope this has been helpful!
Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.
(Comp Sci, Hons - UTS)
Software Developer (Freelancer / Contractor) in Australia.
I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!
Get in touch:
[email protected]
github.com/chrishulbert
linkedin