In the previous installments of Swift from Scratch, we created a functional to-do application. The data model could use some love though. In this final tutorial, we're going to refactor the data model by implementing a custom model class.
1. Data Model
The data model we're about to implement includes two classes, a Task
class and a ToDo
class that inherits from the Task
class. While we create and implement these model classes, we continue our exploration of object-oriented programming in Swift. In this tutorial, we zoom in on the initialization of class instances and what role inheritance plays during initialization.
Task
Class
Let's start with the implementation of the Task
class. Create a new Swift file by selecting New > File... from Xcode's File menu. Choose Swift File from the iOS > Source section. Name the file Task.swift and hit Create.
The basic implementation is short and simple. The Task
class inherits from NSObject
, defined in the Foundation framework, and has a variable property name
of type String
. The class defines two initializers, init
and init(name:)
. There are a few details that might trip you up so let me explain what's happening.
import Foundation class Task: NSObject { var name: String convenience override init() { self.init(name: "New Task") } init(name: String) { self.name = name } }
Because the init
method is also defined in the NSObject
class, we need to prefix the initializer with the override
keyword. We covered overriding methods earlier in this series. In the init
method, we invoke the init(name:)
method, passing in "New Task"
as the value for the name
parameter.
The init(name:)
method is another initializer, accepting a single parameter name
of type String
. In this initializer, the value of the name
parameter is assigned to the name
property. This is easy enough to understand. Right?
Designated and Convenience Initializers
What's with the convenience
keyword prefixing the init
method? Classes can have two types of initializers, designated initializers and convenience initializers. Convenience initializers are prefixed with the convenience
keyword, which implies that init(name:)
is a designated initializer. Why is that? What is the difference between designated and convenience initializers?
Designated initializers fully initialize an instance of a class, meaning that every property of the instance has an initial value after initialization. Looking at the Task
class, for example, we see that the name
property is set with the value of the name
parameter of the init(name:)
initializer. The result after initialization is a fully initialized Task
instance.
Convenience initializers, however, rely on a designated initializer to create a fully initialized instance of the class. That's why the init
initializer of the Task
class invokes the init(name:)
initializer in its implementation. This is referred to as initializer delegation. The init
initializer delegates initialization to a designated initializer to create a fully initialized instance of the Task
class.
Convenience initializers are optional. Not every class has a convenience initializer. Designated initializers are required and a class needs to have at least one designated initializer to create a fully initialized instance of itself.
NSCoding
Protocol
The implementation of the Task
class isn't complete though. Later in this article, we will write an array of ToDo
instances to disk. This is only possible if instances of the ToDo
class can be encoded and decoded.
Don't worry though, this isn't rocket science. We only need to make the Task
and ToDo
classes conform to the NSCoding
protocol. That's why the Task
class inherits form the NSObject
class since the NSCoding
protocol can only be implemented by classes inheriting—directly or indirectly—from NSObject
. Like the NSObject
class, the NSCoding
protocol is defined in the Foundation framework.
Adopting a protocol is something we already covered in this series, but there are a few gotchas that I want to point out. Let's start by telling the compiler that the Task
class conforms to the NSCoding
protocol.
class Task: NSObject, NSCoding { var name: String ... }
Next, we need to implement the two methods declared in the NSCoding
protocol, init(coder:)
and encodeWithCoder(_:)
. The implementation is straightforward if you're familiar with the NSCoding
protocol.
import Foundation class Task: NSObject, NSCoding { var name: String @objc required init(coder aDecoder: NSCoder) { name = aDecoder.decodeObjectForKey("name") as! String } @objc func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(name, forKey: "name") } convenience override init() { self.init(name: "New Task") } init(name: String) { self.name = name } }
The init(coder:)
initializer is a designated initializer that initializes a Task
instance. Even though we implement the init(coder:)
method to conform to the NSCoding
protocol, you won't ever need to invoke this method directly. The same is true for encodeWithCoder(_:)
, which encodes an instance of the Task
class.
The required
keyword prefixing the init(coder:)
method indicates that every subclass of the Task
class needs to implement this method. The required
keyword only applies to initializers, which is why we don't need to add it to the encodeWithCoder(_:)
method.
Before we move on, we need to talk about the @objc
attribute. Because the NSCoding
protocol is an Objective-C protocol, protocol conformance can only be checked by adding the @objc
attribute. In Swift, there's no such thing as protocol conformance or optional protocol methods. In other words, if a class adheres to a particular protocol, the compiler verifies and expects that every method of the protocol is implemented.
ToDo
Class
With the Task
class implemented, it's time to implement the ToDo
class. Create a new Swift file and name is ToDo.swift. Let's look at the implementation of the ToDo
class.
import Foundation class ToDo: Task { var done: Bool @objc required init(coder aDecoder: NSCoder) { self.done = aDecoder.decodeObjectForKey("done") as! Bool super.init(coder: aDecoder) } @objc override func encodeWithCoder(aCoder: NSCoder) { aCoder.encodeObject(done, forKey: "done") super.encodeWithCoder(aCoder) } init(name: String, done: Bool) { self.done = done super.init(name: name) } }
The ToDo
class inherits from the Task
class and declares a variable property done
of type Bool
. In addition to the two required methods of the NSCoding
protocol it inherits from the Task
class, it also declares a designated initializer, init(name:done:)
.
As in Objective-C, the super
keyword refers to the superclass, the Task
class in this example. There is one important detail that deserves attention. Before you invoke the init(name:)
method on the superclass, every property declared by the ToDo
class needs to be initialized. In other words, before the ToDo
class delegates initialization to its superclass, every property the ToDo
class declares, needs to have a valid initial value. You can verify this by switching the order of the statements and inspect the error that pops up.
The same applies to the init(coder:)
method. We first initialize the done
property before invoking init(coder:)
on the superclass. Also note that we downcast and force unwrap the result of decodeObjectForKey(_:)
to a Bool
using as!
.
Initializers and Inheritance
When dealing with inheritance and initialization, there are a few rules to keep in mind. The rule for designated initializers are simple.
- A designated initializer needs to invoke a designated initializer from its superclass. In the
ToDo
class, for example, theinit(coder:)
method invokes theinit(coder:)
method of its superclass. This is also referred to as delegating up.
The rules for convenience initializers are a bit more complex. There are two rules to keep in mind.
- A convenience initializer always needs to invoke another initializer of the class it's defined in. In the
Task
class, for example, theinit
method is a convenience initializer and delegates initialization to another initializer,init(name:)
in the example. This is known as delegating across. - Even though a convenience initializer doesn't have to delegate initialization to a designated initializer, a convenience initializer needs to call a designated initializer at some point. This is necessary to fully initialize the instance that's being initialized.
With both model classes implemented, it is time to refactor the ViewController
and AddItemViewController
classes. Let's start with the latter.
2. Refactoring AddItemViewController
Step 1: Updating AddItemViewControllerDelegate
Protocol
The only changes we need to make in the AddItemViewController
class are related to the AddItemViewControllerDelegate
protocol. In the protocol declaration, change the type of didAddItem
from String
to ToDo
, the model class we implemented earlier.
protocol AddItemViewControllerDelegate { func controller(controller: AddItemViewController, didAddItem: ToDo) }
Step 2: Update create
Action
This means that we also need to update the create
action in which we invoke the delegate method. In the updated implementation, we create a ToDo
instance, passing it to the delegate method.
@IBAction func create(sender: AnyObject) { let name = self.textField.text let item = ToDo(name: name, done: false) if let delegate = self.delegate { delegate.controller(self, didAddItem: item) } }
3. Refactoring ViewController
Step 1: Updating items
Property
The ViewController
class requires a bit more work. We first need to change the type of the items
property to [ToDo]
, an array of ToDo
instances.
var items: [ToDo] = [] { didSet { let hasItems = items.count > 0 self.tableView.hidden = !hasItems self.messageLabel.hidden = hasItems } }
Step 2: Table View Data Source Methods
This also means that we need to refactor a few other methods, such as the cellForRowAtIndexPath(_:)
method shown below. Because the items
array now contains ToDo
instances, checking if an item is marked as done is much simpler. We use Swift's ternary conditional operator to update the table view cell's accessory type.
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell { // Fetch Item let item = self.items[indexPath.row] // Dequeue Table View Cell let tableViewCell = tableView.dequeueReusableCellWithIdentifier("TableViewCell", forIndexPath: indexPath) as! UITableViewCell // Configure Table View Cell tableViewCell.textLabel?.text = item.name tableViewCell.accessoryType = item.done ? .Checkmark : .None return tableViewCell }
When the user deletes an item, we only need to update the items
property by removing the corresponding ToDo
instance. This is reflected in the implementation of the tableView(_:commitEditingStyle:forRowAtIndexPath:)
method shown below.
func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) { if editingStyle == .Delete { // Fetch Item let item = self.items[indexPath.row] // Update Items self.items.removeAtIndex(indexPath.row) // Save State self.saveItems() // Update Table View tableView.deleteRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Right) } }
Step 3: Table View Delegate Methods
Updating the state of an item when the user taps a row is handled in the tableView(_:didSelectRowAtIndexPath:)
method. The implementation of this UITableViewDelegate
method is much simpler thanks to the ToDo
class.
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) { tableView.deselectRowAtIndexPath(indexPath, animated: true) // Fetch Item let item = self.items[indexPath.row] // Fetch Table View Cell let tableViewCell = tableView.cellForRowAtIndexPath(indexPath) // Update Item item.done = !item.done // Update Table View Cell tableViewCell?.accessoryType = item.done ? .Checkmark : .None // Save State self.saveItems() }
The corresponding ToDo
instance is updated and this change is reflected by the table view. To save the state, we invoke saveItems
instead of saveCheckedItems
.
Step 4: Add Item View Controller Delegate Methods
Because we updated the AddItemViewControllerDelegate
protocol, we also need to update the ViewController
's implementation of this protocol. The change, however, is simple. We only need to update the method signature.
func controller(controller: AddItemViewController, didAddItem: ToDo) { // Update Data Source self.items.append(didAddItem) // Save State self.saveItems() // Reload Table View self.tableView.reloadData() // Dismiss Add Item View Controller self.dismissViewControllerAnimated(true, completion: nil) }
Step 5: Saving Items
pathForItems
Instead of storing the items in the user defaults database, we're going to store them in the application's documents directory. Before we update the loadItems
and saveItems
methods, we're going to implement a helper method named pathForItems
. The method is private and returns a path, the location of the items in the documents directory.
private func pathForItems() -> String { let documentsDirectory = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true).first as! String return documentsDirectory.stringByAppendingPathComponent("items") }
We first fetch the path to the documents directory in the application's sandbox by invoking NSSearchPathForDirectoriesInDomains(_:_:_:)
. Because this method returns an array of strings, we grab the first item, force unwrap it, and downcast it to a String
. The value we return from pathForItems
is composed of the path to the documents directory with the string "items"
appended to it.
loadItems
The loadItems method changes quite a bit. We first store the result of pathForItems
in a constant named path
. We then unarchive the object archived at that path and downcast it to an optional array of ToDo
instances. We use optional binding to unwrap the optional and assign it to a constant named items
. In the if
clause, we assign the value stored in items
to the items
property.
private func loadItems() { let path = self.pathForItems() if let items = NSKeyedUnarchiver.unarchiveObjectWithFile(path) as? [ToDo] { self.items = items } }
saveItems
The saveItems method is short and simple. We store the result of pathForItems
in a constant named path
and invoke archiveRootObject(_:toFile:)
on NSKeyedArchiver
, passing in the items
property and path
. We print the result of the operation to the console.
private func saveItems() { let path = self.pathForItems() if NSKeyedArchiver.archiveRootObject(self.items, toFile: path) { println("Successfully Saved") } else { println("Saving Failed") } }
Step 6: Cleaning Up
Let's end with the fun part, deleting code. Start by removing the checkedItems
property at the top since we no longer need it. As a result, we can also remove the loadCheckedItems
and saveCheckedItems
methods, and every reference to these methods in the ViewController
class.
Build and run the application to see if everything is still working. The data model makes the application's code much simpler and reliable. Thanks to the ToDo
class, managing the items in our list much is now easier and less error-prone.
Conclusion
In this final tutorial of the series, we refactored the data model of our application. You learned more about object-oriented programming and inheritance. Instance initialization is an important concept in Swift so make sure you understand what we've covered in this tutorial. You can read more about initialization and initializer delegation in The Swift Programming Language.
Congratulations on completing the Swift From Scratch series. You've now got a good base to build on as you continue your journey to becoming an expert Swift app developer!
Comments