Decouple UI from Model with View Models and Controls
Lammert Westerhoff compared MVVM to Presentation Controls recently. Since nobody likes massive view controllers, I had a look, too, and found a few interesting things. To write a real app with his tips in mind, we’re going to need a bit more, though, and refactor things a bit.
Especially noteworthy is:
- Lammert introduces Presentation Controls, which are like custom view containers made of various subviews, only they don’t inherit from
NSView
/UIView
. - This leaves room for
SomeCustomView
andSomeCustomControl
to co-exist. A view is passive and ready to display data, while a control is, well, controlling views.
I think we can make something greater with that. Before I show you my proposal, have a look at Lammert’s solution first:
Model, View Model, and Presentation Control (or Presenter)
To get us all on the same track, let’s revisit Lammert’s code.
This is our domain model at the core of the app:
class Trip {
let departure: NSDate
let arrival: NSDate
let actualDeparture: NSDate
let delay: NSTimeInterval
let delayed: Bool
let duration: NSTimeInterval
init(departure: NSDate, arrival: NSDate, actualDeparture: NSDate? = nil) {
self.departure = departure
self.arrival = arrival
self.actualDeparture = actualDeparture ?? departure
// calculations
duration = self.arrival.timeIntervalSinceDate(self.departure)
delay = self.actualDeparture.timeIntervalSinceDate(self.departure)
delayed = delay > 0
}
}
And this is the view model as Lammert proposed:
class TripViewModel {
let date: String
let departure: String
let arrival: String
let duration: String
let delay: String?
let delayHidden: Bool
let departureTimeColor: UIColor
init(_ trip: Trip) {
date = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
departure = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
arrival = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)
let durationFormatter = NSDateComponentsFormatter()
durationFormatter.allowedUnits = [.Hour, .Minute]
durationFormatter.unitsStyle = .Short
duration = durationFormatter.stringFromTimeInterval(trip.duration)!
delayHidden = !trip.delayed
if trip.delayed {
durationFormatter.unitsStyle = .Full
delay = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(trip.delay)!)
departureTimeColor = .redColor()
} else {
self.delay = nil
departureTimeColor = UIColor(red: 0, green: 0, blue: 0.4, alpha: 1)
}
}
}
He then uses the TripViewModel
in the controller like so:
class ViewController: UIViewController {
@IBOutlet weak var dateLabel: UILabel!
@IBOutlet weak var departureTimeLabel: UILabel!
@IBOutlet weak var arrivalTimeLabel: UILabel!
@IBOutlet weak var durationLabel: UILabel!
@IBOutlet weak var delayLabel: UILabel!
// Keeping it simple for this example
let tripModel = TripViewViewModel(Trip(departure: NSDate(timeIntervalSince1970: 1444396193), arrival: NSDate(timeIntervalSince1970: 1444397193), actualDeparture: NSDate(timeIntervalSince1970: 1444396493)))
override func viewDidLoad() {
super.viewDidLoad()
dateLabel.text = tripModel.date
departureTimeLabel.text = tripModel.departure
arrivalTimeLabel.text = tripModel.arrival
durationLabel.text = tripModel.duration
delayLabel.text = tripModel.delay
delayLabel.hidden = tripModel.delayHidden
departureTimeLabel.textColor = tripModel.departureTimeColor
}
}
Instead of setting tripModel
as a constant property on ViewController
, you’re probably going to pass it to the controller in prepareForSegue(_:)
of the presenting view controller.
This is already a very fine ViewController
. It doesn’t contain any data-transformation logic thanks to its view model.
Afterwards, Lammert proposes to replace the view model with a presentation control instead:
class TripPresentationControl: NSObject {
@IBOutlet weak var dateLabel: UILabel!
@IBOutlet weak var departureTimeLabel: UILabel!
@IBOutlet weak var arrivalTimeLabel: UILabel!
@IBOutlet weak var durationLabel: UILabel!
@IBOutlet weak var delayLabel: UILabel!
var trip: Trip! {
didSet {
dateLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
departureTimeLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
arrivalTimeLabel.text = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)
let durationFormatter = NSDateComponentsFormatter()
durationFormatter.allowedUnits = [.Hour, .Minute]
durationFormatter.unitsStyle = .Short
durationLabel.text = durationFormatter.stringFromTimeInterval(trip.duration)!
delayLabel.hidden = !trip.delayed
if trip.delayed {
durationFormatter.unitsStyle = .Full
delayLabel.text = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(trip.delay)!)
departureTimeLabel.textColor = .redColor()
}
}
}
}
class ViewController: UIViewController {
@IBOutlet var tripPresentationControl: TripPresentationControl!
let trip = Trip(departure: NSDate(timeIntervalSince1970: 1444396193), arrival: NSDate(timeIntervalSince1970: 1444397193), actualDeparture: NSDate(timeIntervalSince1970: 1444396493))
override func viewDidLoad() {
super.viewDidLoad()
tripPresentationControl.trip = trip
}
}
Pretty clean right?
Indeed, it is clean. And the ViewController
is even smaller!
Architecting a bit more
The problem I have with the presentation control as it stands is that a real domain model instance is known to the view. When the presenter, TripPresentationControl
, is coupled to Trip
, changes to the core will immediately affect or break the view. Conversely, if you want to provide additional details to the view, you’re probably going to touch your core domain model object (which may be Core Data entities) just to satisfy needs of the view.
Better decouple view from model.
Traditionally, this was done through dumb data transfer objects (DTO). The kind of view model Lammert proposed is quite similar to that. There’s also another kind of view model: one that’s coupled more closely to the view. I’ve written about this before.
Let’s stick with Lammerts DTO-like implementation for now.
If I had to organize all the types we just created into groups, I’d end up with this:
- Domain
Trip
- View
ViewController
TripViewModel
TripPresentationControl
TripViewModel
shouldn’t know about Trip
, either. It’s made to hold data the view needs. Refactoring the view code, the following is a lot cleaner:
// It's a value object (struct) now!
struct TripViewModel {
let date: String
let departure: String
let arrival: String
let duration: String
let delay: String?
let delayHidden: Bool
let departureTimeColor: UIColor // More like "departureDueSoonColor"
init(date: String, departure: String, arrival: String,
duration: String, delay: String?, delayHidden: Bool,
departureTimeColor: UIColor) {
self.date = date
self.departure = departure
self.arrival = arrival
self.duration = duration
self.delay = delay
self.delayHidden = delayHidden
self.departureTimeColor = departureTimeColor
}
}
class TripPresentationControl: NSObject {
@IBOutlet weak var dateLabel: UILabel!
@IBOutlet weak var departureTimeLabel: UILabel!
@IBOutlet weak var arrivalTimeLabel: UILabel!
@IBOutlet weak var durationLabel: UILabel!
@IBOutlet weak var delayLabel: UILabel!
var tripViewModel: TripViewModel! {
didSet {
dateLabel.text = tripViewModel.date
departureTimeLabel.text = tripViewModel.departure
arrivalTimeLabel.text = tripViewModel.arrival
durationLabel.text = tripViewModel.duration
let hideDelay = tripViewModel.delayHidden
delayLabel.hidden = hideDelay
if let delayText = tripViewModel.delay where !hideDelay {
durationFormatter.unitsStyle = .Full
delayLabel.text = delayText
departureTimeLabel.textColor = tripViewModel.departureTimeColor
}
}
}
}
class ViewController: UIViewController {
@IBOutlet var tripPresentationControl: TripPresentationControl!
var tripViewModel: TripViewModel!
override func viewDidLoad() {
super.viewDidLoad()
tripPresentationControl.tripViewModel = tripViewModel
}
}
I have now decoupled the view “module” from the model completely. It’s TripViewModel
, which now is a real value object, contains everything the view expects. The TripPresentationControl
still does a bit of conditional set-up work. That’s fine, since that’s its job.
Since TripViewModel
can now be constructed with values only, how do we create it from a Trip
entity?
Easy: through a special factory.
I’ll create the factory through cheating with extensions, though!
extension TripViewModel {
init(_ trip: Trip) {
date = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
departure = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
arrival = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)
let durationFormatter = NSDateComponentsFormatter()
durationFormatter.allowedUnits = [.Hour, .Minute]
durationFormatter.unitsStyle = .Short
duration = durationFormatter.stringFromTimeInterval(trip.duration)!
delayHidden = !trip.delayed
if trip.delayed {
durationFormatter.unitsStyle = .Full
delay = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(trip.delay)!)
departureTimeColor = .redColor()
} else {
self.delay = nil
departureTimeColor = UIColor(red: 0, green: 0, blue: 0.4, alpha: 1)
}
}
}
This belongs into the missing glue which makes a real app tick: event handlers. I like to create use case objects for this if it’s an important task:
class ShowExistingTrip {
let view: ViewController
init(view: ViewController) {
self.view = view
}
func showTrip() {
let trip = // ... use an Interactor to hydrate stored data into a Trip
let tripViewModel = TripViewModel(trip: trip)
view.tripViewModel = tripViewModel
// push the view to the navigation stack or similar
}
}
In fact, I’d put the extension on TripViewModel
into the same file as ShowExistingTrip
. Depending on the complexity of the transformation or mapping, I’d go as far as make the new initializer private so only this glue layer knows about it. I can still unit-test the result through a mocked ViewController
, inspecting its changed tripViewModel
property.
Meanwhile, the regular TripViewModel
initializer is still useful for unit testing the TripPresentationControl
.
Conclusion
The resulting components are as follows, naming the layers by convention:
- Domain
Trip
- Application
ShowExistingTrip
- View
ViewController
TripViewModel
TripPresentationControl
There’re a lot of cool patterns floating around the web. Trying them out usually involves a working application. The use case-based services helped me sort out this kind of problems a lot in the past already.