10.
Model-View-ViewModel Pattern
Written by Jay Strawn
Model-View-ViewModel (MVVM) is a structural design pattern that separates objects into three distinct groups:
- Models hold app data. They’re usually structs or simple classes.
-
Views display visual elements and controls on the screen. They’re typically subclasses of
UIView. - View models transform model information into values that can be displayed on a view. They’re usually classes, so they can be passed around as references.
Does this pattern sound familiar? Yep, it’s very similar to Model-View-Controller (MVC). Note that the class diagram at the top of this page includes a view controller; view controllers do exist in MVVM, but their role is minimized.
In this chapter, you’ll learn how to implement view models and organize your projects to include them. You’ll start with a simple example on what a view model does, then you’ll take a MVC project and refactor it into MVVM.
When should you use it?
Use this pattern when you need to transform models into another representation for a view. For example, you can use a view model to transform a Date into a date-formatted String, a Decimal into a currency-formatted String, or many other useful transformations.
This pattern compliments MVC especially well. Without view models, you’d likely put model-to-view transformation code in your view controller. However, view controllers are already doing quite a bit: handling viewDidLoad and other view lifecycle events, handling view callbacks via IBActions and several other tasks as well.
This leads to what developers jokingly refer to as “MVC: Massive View Controller”.
How can you avoid overstuffing your view controllers? It’s easy — use other patterns besides MVC! MVVM is a great way to slim down massive view controllers that require several model-to-view transformations.
Playground example
Open IntermediateDesignPatterns.xcworkspace in the Starter directory, and then open the MVVM page.
For the example, you’ll make a “Pet View” as part of an app that adopts pets. Add the following after Code Example:
import PlaygroundSupport
import UIKit
// MARK: - Model
public class Pet {
public enum Rarity {
case common
case uncommon
case rare
case veryRare
}
public let name: String
public let birthday: Date
public let rarity: Rarity
public let image: UIImage
public init(name: String,
birthday: Date,
rarity: Rarity,
image: UIImage) {
self.name = name
self.birthday = birthday
self.rarity = rarity
self.image = image
}
}
Here, you define a model named Pet. Every pet has a name, birthday, rarity and image. You need to show these properties on a view, but birthday and rarity aren’t directly displayable. They’ll need to be transformed by a view model first.
Next, add the following code to the end of your playground:
// MARK: - ViewModel
public class PetViewModel {
// 1
private let pet: Pet
private let calendar: Calendar
public init(pet: Pet) {
self.pet = pet
self.calendar = Calendar(identifier: .gregorian)
}
// 2
public var name: String {
return pet.name
}
public var image: UIImage {
return pet.image
}
// 3
public var ageText: String {
let today = calendar.startOfDay(for: Date())
let birthday = calendar.startOfDay(for: pet.birthday)
let components = calendar.dateComponents([.year],
from: birthday,
to: today)
let age = components.year!
return "\(age) years old"
}
// 4
public var adoptionFeeText: String {
switch pet.rarity {
case .common:
return "$50.00"
case .uncommon:
return "$75.00"
case .rare:
return "$150.00"
case .veryRare:
return "$500.00"
}
}
}
Here’s what you did above:
-
First, you created two private properties called
petandcalendar, setting both withininit(pet:). -
Next, you declared two computed properties for
nameandimage, where you return the pet’snameandimagerespectively. This is the simplest transformation you can perform: returning a value without modification. If you wanted to change the design to add a prefix to every pet’s name, you could easily do so by modifyingnamehere. -
Next, you declared
ageTextas another computed property, where you usedcalendarto calculate the difference in years between the start of today and the pet’sbirthdayand return this as aStringfollowed by"years old". You’ll be able to display this value directly on a view without having to perform any other string formatting. -
Finally, you created
adoptionFeeTextas a final computed property, where you determine the pet’s adoption cost based on itsrarity. Again, you return this as aStringso you can display it directly.
Now you need a UIView to display the pet’s information. Add the following code to the end of the playground:
// MARK: - View
public class PetView: UIView {
public let imageView: UIImageView
public let nameLabel: UILabel
public let ageLabel: UILabel
public let adoptionFeeLabel: UILabel
public override init(frame: CGRect) {
var childFrame = CGRect(x: 0,
y: 16,
width: frame.width,
height: frame.height / 2)
imageView = UIImageView(frame: childFrame)
imageView.contentMode = .scaleAspectFit
childFrame.origin.y += childFrame.height + 16
childFrame.size.height = 30
nameLabel = UILabel(frame: childFrame)
nameLabel.textAlignment = .center
childFrame.origin.y += childFrame.height
ageLabel = UILabel(frame: childFrame)
ageLabel.textAlignment = .center
childFrame.origin.y += childFrame.height
adoptionFeeLabel = UILabel(frame: childFrame)
adoptionFeeLabel.textAlignment = .center
super.init(frame: frame)
backgroundColor = .white
addSubview(imageView)
addSubview(nameLabel)
addSubview(ageLabel)
addSubview(adoptionFeeLabel)
}
@available(*, unavailable)
public required init?(coder: NSCoder) {
fatalError("init?(coder:) is not supported")
}
}
Here, you create a PetView with four subviews: an imageView to display the pet’s image and three other labels to display the pet’s name, age and adoption fee.
You create and position each view within init(frame:). Lastly, you throw a fatalError within init?(coder:) to indicate it’s not supported.
You’re ready to put these classes into action! Add the following code to the end of the playground:
// MARK: - Example
// 1
let birthday = Date(timeIntervalSinceNow: (-2 * 86400 * 366))
let image = UIImage(named: "stuart")!
let stuart = Pet(name: "Stuart",
birthday: birthday,
rarity: .veryRare,
image: image)
// 2
let viewModel = PetViewModel(pet: stuart)
// 3
let frame = CGRect(x: 0, y: 0, width: 300, height: 420)
let view = PetView(frame: frame)
// 4
view.nameLabel.text = viewModel.name
view.imageView.image = viewModel.image
view.ageLabel.text = viewModel.ageText
view.adoptionFeeLabel.text = viewModel.adoptionFeeText
// 5
PlaygroundPage.current.liveView = view
Here’s what you did:
- First, you created a new
Petnamedstuart. - Next, you created a
viewModelusingstuart. - Next, you created a
viewby passing a commonframesize on iOS. - Next, you configured the subviews of
viewusingviewModel. - Finally, you set
viewto thePlaygroundPage.current.liveView, which tells the playground to render it within the standard Assistant editor.
To see this in action, select Editor ▸ Live View to check out the rendered view.
What type of pet is Stuart exactly? He’s a cookie monster, of course! They’re very rare.
There’s one final improvement you can make to this example. Add the following extension right after the class closing curly brace for PetViewModel:
extension PetViewModel {
public func configure(_ view: PetView) {
view.nameLabel.text = name
view.imageView.image = image
view.ageLabel.text = ageText
view.adoptionFeeLabel.text = adoptionFeeText
}
}
You’ll use this method to configure the view using the view model instead of doing this inline.
Find the following code you entered previously:
// 4
view.nameLabel.text = viewModel.name
view.imageView.image = viewModel.image
view.ageLabel.text = viewModel.ageText
view.adoptionFeeLabel.text = viewModel.adoptionFeeText
…and replace that code with the following:
viewModel.configure(view)
This is a neat way to put all of the view configuration logic into the view model. You may or may not want to do this in practice. If you’re only using the view model with one view, then it can be useful to put the configure method into the view model. However, if you’re using the view model with more than one view, then you might find that putting all that logic in the view model clutters it. Having the configure code separately for each view may be simpler in that case.
Your output should be the same as before.
Hey Stuart, are you going to share that cookie? No? Aww, come on…!
What should you be careful about?
MVVM works well if your app requires many model-to-view transformations. However, not every object will neatly fit into the categories of model, view or view model. Instead, you should use MVVM in combination with other design patterns.
Furthermore, MVVM may not be very useful when you first create your application. MVC may be a better starting point. As your app’s requirements change, you’ll likely need to choose different design patterns based on your changing requirements. It’s okay to introduce MVVM later in an app’s lifetime when you really need it.
Don’t be afraid of change — instead, plan ahead for it.
Tutorial project
Throughout this section, you’ll add functionality to an app called Coffee Quest.
In the Starter directory, open CoffeeQuest ▸ CoffeeQuest.xcworkspace (not the .xcodeproj) in Xcode.
This app displays nearby coffee shops provided by Yelp. It uses CocoaPods to pull in YelpAPI, a helper library for searching Yelp. If you haven’t used CocoaPods before, that’s OK! Everything you need has been included for you in the starter project. The only thing you need to remember is to open CoffeeQuest.xcworkspace, instead of the CoffeeQuest.xcodeproj file.
Note: If you’d like to learn more about CocoaPods, read our tutorial about it here: http://bit.ly/cocoapods-tutorial.
Before you can run the app, you’ll first need to register for a Yelp API key.
Navigate to this URL in your web browser:
Create an account if you don’t have one, or sign in. Next, enter the following in the Create App form (or if you’ve created an app before, use your existing API Key):
- App Name: “Coffee Quest”
- App Website: (leave this blank)
- Industry: Select “Business”
- Company: (leave this blank)
- Contact Email: (your email address)
- Description: “Coffee search app”
- I have read and accepted the Yelp API Terms: check this
Your form should look as follows:
Press Create New App to continue, and you should see a success message:
Copy your API key and return to CoffeeQuest.xcworkspace in Xcode.
Open CoffeeQuest\Resources\APIKeys.swift, and paste your API key where indicated.
Build and run to see the app in action.
The simulator’s default location is set to San Francisco. Wow, there are a lot of coffee shops in that city!
Note: You can change the location of the simulator by clicking Debug ▸ Location and then selecting a different option.
These map pins are kind of boring. Wouldn’t it be great if they showed which coffee shops were actually good?
Open CoffeeQuest\Models\MapPin.swift. MapPin takes a coordinate, title, and rating, then converts those into something a map view can display… does this sound familiar? Yes, it’s actually a view model!
First, you need to give this class a better name. Right click on MapPin at the top of the file and select Refactor ▸ Rename.
Click Rename and enter BusinessMapViewModel for the new name. This will rename both the class name and file name in the File hierarchy.
Next, select the Models group in the File hierarchy and press Enter to edit its name. Rename this to ViewModels.
Finally, click on the yellow CoffeeQuest group and select Sort by name. Ultimately, your File hierarchy should look like this:
This makes it obvious to other developers you’re using the MVVM pattern. Clarity is good!
BusinessMapViewModel needs a few more properties in order to show exciting map annotations, instead of the plain-vanilla pins provided by MapKit.
Still inside BusinessMapViewModel.swift, replace import Foundation with the following:
import UIKit
You import UIKit here since you’re going to add components which require UIKit.
Next, add the following properties after the existing ones; ignore the resulting compiler errors for now:
public let image: UIImage
public let ratingDescription: String
You’ll use image instead of the default pin image, and you’ll display ratingDescription as a subtitle whenever the user taps the annotation.
Next, replace init(coordinate:name:rating:) with the following:
public init(coordinate: CLLocationCoordinate2D,
name: String,
rating: Double,
image: UIImage) {
self.coordinate = coordinate
self.name = name
self.rating = rating
self.image = image
self.ratingDescription = "\(rating) stars"
}
You accept image via this initializer and set ratingDescription from the rating.
Next, add the following computed property to the end of the MKAnnotation extension:
public var subtitle: String? {
return ratingDescription
}
This tells the map to use ratingDescription as the subtitle shown on annotation callout when one is selected. Now you can fix the compiler error. Open ViewController.swift and scroll down to the end of the file.
Replace addAnnotations() with the following:
private func addAnnotations() {
for business in businesses {
guard let yelpCoordinate =
business.location.coordinate else {
continue
}
let coordinate = CLLocationCoordinate2D(
latitude: yelpCoordinate.latitude,
longitude: yelpCoordinate.longitude)
let name = business.name
let rating = business.rating
let image: UIImage
// 1
switch rating {
case 0.0..<3.5:
image = UIImage(named: "bad")!
case 3.5..<4.0:
image = UIImage(named: "meh")!
case 4.0..<4.75:
image = UIImage(named: "good")!
case 4.75...5.0:
image = UIImage(named: "great")!
default:
image = UIImage(named: "bad")!
}
let annotation = BusinessMapViewModel(
coordinate: coordinate,
name: name,
rating: rating,
image: image)
mapView.addAnnotation(annotation)
}
}
This method is similar to before, except now you’re switching on rating (see // 1) to determine which image to use. High-quality caffeine is like catnip for developers, so you label anything less than 3.5 stars as “bad”. You gotta have high standards, right? ;]
Build and run your app. It should now look… the same? What gives?
The map doesn’t know about image. Rather, you’re expected to override a delegate method to provide custom pin annotation images. That’s why it looks the same as before.
Add the following method right after addAnnotations():
public func mapView(_ mapView: MKMapView,
viewFor annotation: MKAnnotation)
-> MKAnnotationView? {
guard let viewModel =
annotation as? BusinessMapViewModel else {
return nil
}
let identifier = "business"
let annotationView: MKAnnotationView
if let existingView = mapView.dequeueReusableAnnotationView(
withIdentifier: identifier) {
annotationView = existingView
} else {
annotationView = MKAnnotationView(
annotation: viewModel,
reuseIdentifier: identifier)
}
annotationView.image = viewModel.image
annotationView.canShowCallout = true
return annotationView
}
This simply creates an MKAnnotationView, which shows the correct image for the given annotation; this is one of your BusinessMapViewModel objects.
Build and run, and you should see the custom images! Tap on one, and you’ll see the coffee shop’s name and rating.
It appears most San Francisco coffee shops are actually 4 stars or above, and you can now find the very best shops at a glance.
Key points
You learned about the Model-View-ViewModel (MVVM) pattern in this chapter. Here are its key points:
-
MVVM helps slim down view controllers, making them easier to work with. Thus combatting the “Massive View Controller” problem.
-
View models are classes that take objects and transform them into different objects, which can be passed into the view controller and displayed on the view. They’re especially useful for converting computed properties such as
DateorDecimalinto aStringor something else that actually can be shown in aUILabelorUIView. -
If you’re only using the view model with one view, it can be good to put all the configurations into the view model. However, if you’re using more than one view, you might find that putting all the logic in the view model clutters it. Having the configure code separated into each view may be simpler.
-
MVC may be a better starting point if your app is small. As your app’s requirements change, you’ll likely need to choose different design patterns based on your changing requirements.
You’ve added a really nice feature to Coffee Quest that shows coffee shops by rating! However, there’s still a lot more you can do with this app. Continue onto the next chapter to learn about the factory pattern and continue building out Coffee Quest.