You may notice that the to-do item you put in is gone when you restart the app. Such an app is useless for the user. The app needs to store the to-do items somehow and reload them when it is opened the next time. There are different possibilities to implement this. We could use Core Data, serialize the data using NSCoding
, or use a third-party framework. In this book, we will write the date into a property list (plist). A plist has the advantage that it can be opened and altered with Xcode or any other editor.
The data model we have implemented uses structs. Unfortunately, structs cannot be written to a plist. We have to convert the data into NSArrays and NSDictionarys. Add the following code to ToDoItemTests
:
func test_HasPlistDictionaryProperty() { let item = ToDoItem(title: "First") let dictionary = item.plistDict }
The static analyzer complains that there is no property with the name plistDict
. Let's add it. Open ToDoItem
and add the property:
var plistDict: String { return "" }
We use a calculated property here because we don't want to initialize it during initialization, and the value should be calculated from the current values of the other properties. Add the following assertions at the end of the test:
XCTAssertNotNil(dictionary) XCTAssertTrue(dictionary is NSDictionary)
As mentioned previously, to be able to write the date into a plist, it needs to be of the NSDictionary
type. Run the test. It fails because, right now, the calculated property is of the String
type. Replace the property with this code:
var plistDict: NSDictionary { return [:] }
Run all the tests. All the tests pass and there is nothing to refactor.
Now, we need to make sure that we can recreate an item from plistDict
. Add the following code to ToDo
ItemTests
:
func test_CanBeCreatedFromPlistDictionary() { let location = Location(name: "Home") let item = ToDoItem(title: "The Title", itemDescription: "The Description", timestamp: 1.0, location: location) let dict = item.plistDict let recreatedItem = ToDoItem(dict: dict) }
We have to stop writing the test because the static analyzer complains. The ToDoItem
struct does not have an initializer with a parameter named dict
. Open ToDoItem.swift
and add the following code to the ToDoItem
struct:
init?(dict: NSDictionary) { return nil }
This is enough to make the test compilable. Now, add the assertion to the test:
XCTAssertEqual(item, recreatedItem)
That assertion asserts that the recreated item is the same as the item used to create plistDict
. Run the test. The test fails because we haven't implemented writing the data of the struct to NSDictionary
and creating a to-do item from NSDictionary
. To write the complete information needed to recreate a to-do item into a dictionary, we first have to make sure that an instance of Location
can be written to and recreated from an NSDictionary
.
In TDD, it is important to always have only one failing test. So, before we can move to the tests for Location
, we have to disable the last test we wrote. During test execution, the test runner searches for methods in the test cases that begin with test. Change the name of the previous test method to xtest_CanBeCreatedFromPlistDictionary()
. Run the tests to make sure that all, tests, except this one, are executed.
Now, open LocationTests
and add the following code:
func test_CanBeSerializedAndDeserialized() { let location = Location(name: "Home", coordinate: CLLocationCoordinate2DMake(50.0, 6.0)) let dict = location.plistDict }
Again, the static analyzer complains because the property is missing. We already know how to make this compilable again. Add this code to Location
:
var plistDict: NSDictionary { return [:] }
With this change, the test compiles. Add the following code to the end of the test:
XCTAssertNotNil(dict) let recreatedLocation = Location(dict: dict)
Again, this does not compile because Location
does not have an initializer with one parameter called dict
. Let's add it:
init?(dict: NSDictionary) { return nil }
The test passes again. But it is not finished yet. We need to make sure that the recreated location is the same as the one we used to create the NSDictionary
. Add the assertion at the end of the test:
XCTAssertEqual(location, recreatedLocation)
Run the test. It fails. To make it pass, the plistDict
property has to have all the information needed to recreate the location. Replace the calculated property with this code:
private let nameKey = "nameKey" private let latitudeKey = "latitudeKey" private let longitudeKey = "longitudeKey" var plistDict: NSDictionary { var dict = [String:AnyObject]() dict[nameKey] = name if let coordinate = coordinate { dict[latitudeKey] = coordinate.latitude dict[longitudeKey] = coordinate.longitude } return dict }
The code explains itself. It just puts all the information of a location into an instance of NSDictionary
. Now, replace the initializer with the dict
argument with the following:
init?(dict: NSDictionary) { guard let name = dict[nameKey] as? String else { return nil } let coordinate: CLLocationCoordinate2D? if let latitude = dict[latitudeKey] as? Double, longitude = dict[longitudeKey] as? Double { coordinate = CLLocationCoordinate2DMake( latitude, longitude) } else { coordinate = nil } self.name = name self.coordinate = coordinate }
Run the tests. All the tests pass again.
As the location can be written to NSDictionary
, we can use it for the serialization of ToDoItem
. Open ToDoItemTests
again, and remove x
at the beginning of the method name of xtest_CanBeCreatedFromPlistDictionary()
. Run the tests to make sure that this test fails.
Now, replace the implementation of the calculated plistDict
property in ToDoItem
with this code:
private let titleKey = "titleKey" private let itemDescriptionKey = "itemDescriptionKey" private let timestampKey = "timestampKey" private let locationKey = "locationKey" var plistDict: NSDictionary { var dict = [String:AnyObject]() dict[titleKey] = title if let itemDescription = itemDescription { dict[itemDescriptionKey] = itemDescription } if let timestamp = timeStamp { dict[timestampKey] = timestamp } if let location = location { let locationDict = location.plistDict dict[locationKey] = locationDict } return dict }
Again, this is straightforward. We put all the values stored in the properties into a dictionary and return it. To recreate a to-do item from a plist dictionary, replace init?(dict:)
with this:
init?(dict: NSDictionary) { guard let title = dict[titleKey] as? String else { return nil } self.title = title self.itemDescription = dict[itemDescriptionKey] as? String self.timestamp = dict[timestampKey] as? Double if let locationDict = dict[locationKey] as? NSDictionary { self.location = Location(dict: locationDict) } else { self.location = nil } }
In this init
method, we fill the properties of ToDoItem
with the values from the dictionary. Run the tests. All the tests pass and there is nothing to refactor.
The next step is to write the list of checked and unchecked to-do items to the disk, and restore them when the app is started again. To drive the implementation, we will write a test that creates two to-do items and adds them to an item manager, sets the item manager to nil
, and then creates a new one. The created item manager should then have the same items as the one that got destroyed. Open ItemManagerTests
and add the following test in it:
func test_ToDoItemsGetSerialized() { var itemManager: ItemManager? = ItemManager() let firstItem = ToDoItem(title: "First") itemManager!.addItem(firstItem) let secondItem = ToDoItem(title: "Second") itemManager!.addItem(secondItem) NSNotificationCenter.defaultCenter().postNotificationName( UIApplicationWillResignActiveNotification, object: nil) itemManager = nil XCTAssertNil(itemManager) itemManager = ItemManager() XCTAssertEqual(itemManager?.toDoCount, 2) XCTAssertEqual(itemManager?.itemAtIndex(0), firstItem) XCTAssertEqual(itemManager?.itemAtIndex(1), secondItem) }
In this test, we first create an item manager, add two to-do items, and send UIApplicationWillResignActiveNotification
to signal to the app that it should write the data to disk. Next, we set the item manager to nil
to destroy it. Then, we create a new item manager and assert that it has the same items.
Run the test. The test crashes because we try to access a to-do item in the item manager but there is no item yet.
Before we write the code that writes the to-do items to disk, add the following code to tearDown()
, right before super.tearDown()
:
sut.removeAllItems() sut = nil
This is needed because, otherwise, all the tests would end up writing their to-do items to disk, and the tests would not start from a clean state.
As mentioned previously, the item manager should register as an observer for UIApplicationWillResignActiveNotification
and write the data to disk when the notification is sent. Add the following init
method to ItemManager
:
override init() { super.init() NSNotificationCenter.defaultCenter().addObserver( self, selector: "save", name: UIApplicationWillResignActiveNotification, object: nil) }
The constant UIApplicationWillResignActiveNotification
is defined in UIKit
, so replace import Foundation
with import UIKit
. Next, add the following calculated property to create a path URL for the plist:
var toDoPathURL: NSURL { let fileURLs = NSFileManager.defaultManager().URLsForDirectory( .DocumentDirectory, inDomains: .UserDomainMask) guard let documentURL = fileURLs.first else { print("Something went wrong. Documents url could not be found") fatalError() } return documentURL.URLByAppendingPathComponent("toDoItems.plist") }
This code gets the document
directory of the app and appends the toDoItems.plist
path component. Now, we can write the save
method:
func save() { var nsToDoItems = [AnyObject]() for item in toDoItems { nsToDoItems.append(item.plistDict) } if nsToDoItems.count > 0 { (nsToDoItems as NSArray).writeToURL(toDoPathURL, atomically: true) } else { do { try NSFileManager.defaultManager().removeItemAtURL(toDoPathURL) } catch { print(error) } } }
Firstly, we create an AnyObject
array and append the dictionaries of the to-do items to it. If the array has at least one item, we write it to disk. Otherwise, we remove whatever is stored at the location of the file path.
When a new item manager is created, we have to read the data from the plist and fill the toDoItems
array. The perfect place to read the data in is the init
method. Add the following code at the end of init()
:
if let nsToDoItems = NSArray(contentsOfURL: toDoPathURL) { for dict in nsToDoItems { if let toDoItem = ToDoItem(dict: dict as! NSDictionary) { toDoItems.append(toDoItem) } } }
Before we can run the tests, we need to do some housekeeping. We have added the item manager as an observer to NSNotification.defaultCenter()
. Like good citizens, we have to remove it when we aren't interested in notifications anymore. Add the following deinit
method to ItemManager
:
deinit { NSNotificationCenter.defaultCenter().removeObserver(self) save() }
In addition to removing the observer, we call save()
to trigger the save operation.
There are many lines of code needed to make one test pass. We could have broken these down into smaller steps. In fact, you should experiment with the test and the implementation and see what happens when you comment out parts of it.
Run all tests. Uh!? A lot of unrelated tests fail. We haven't changed the code the other tests are testing. But we changed the way ItemManager
works. If you have a look at ItemListDataProviderTests
and DetailViewControllerTests
, we add items to an item manager instance in there. This means that we need to clean up after the tests have been executed. Open ItemListDataProviderTests
and add the following code to tearDown()
, right before super.tearDown()
:
sut.itemManager?.removeAllItems() sut.itemManager = nil
Now, add the following to tearDown()
in DetailViewControllerTests
:
sut.itemInfo?.0.removeAllItems()
Run the tests again. All the tests pass. We will move to the next section, but you should implement the tests and code for the serialization and deserialization of the done items in the ItemManager
.