In the previous lesson of Swift From Scratch, we created a functional to-do application. The data model could use some love, though. In this final lesson, we’re going to refactor the data model by implementing a custom model class.
1. The 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’ll continue our exploration of object-oriented programming in Swift. In this lesson, we’ll zoom in on the initialization of class instances and what role inheritance plays during initialization.
The 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’s 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.
The NSCoding
Protocol
The implementation of the Task
class isn’t complete, though. Later in this lesson, 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 from 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.
import Foundation class Task: NSObject, NSCoding { var name: String ... }
Next, we need to implement the two methods declared in the NSCoding
protocol, init?(coder:)
and encode(with:)
. 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.decodeObject(forKey: "name") as! String } @objc func encode(with aCoder: NSCoder) { aCoder.encode(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 encode(with:)
, 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 encode(with:)
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.
The ToDo
Class
With the Task
class implemented, it’s time to implement the ToDo
class. Create a new Swift file and name it 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.decodeBool(forKey: "done") super.init(coder: aDecoder) } @objc override func encode(with aCoder: NSCoder) { aCoder.encode(done, forKey: "done") super.encode(with: 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 that 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 defined by the ToDo
class needs to have a valid initial value. You can verify this by switching the order of the statements and inspecting 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.
Initializers and Inheritance
When dealing with inheritance and initialization, there are a few rules to keep in mind. The rule for designated initializers is 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: Update the 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 the 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: Any) { if let name = textField.text { // Create Item let item = ToDo(name: name, done: false) // Notify Delegate delegate?.controller(self, didAddItem: item) } }
3. Refactoring ViewController
Step 1: Update the 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(oldValue) { let hasItems = items.count > 0 tableView.isHidden = !hasItems messageLabel.isHidden = hasItems } }
Step 2: Table View Data Source Methods
This also means that we need to refactor a few other methods, such as the tableView(_:cellForRowAt:)
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, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // Fetch Item let item = items[indexPath.row] // Dequeue Cell let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) // Configure Cell cell.textLabel?.text = item.name cell.accessoryType = item.done ? .checkmark : .none return cell }
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(_:commit:forRowAt:)
method shown below.
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { // Update Items items.remove(at: indexPath.row) // Update Table View tableView.deleteRows(at: [indexPath], with: .right) // Save State saveItems() } }
Step 3: Table View Delegate Methods
Updating the state of an item when the user taps a row is handled in the tableView(_:didSelectRowAt:)
method. The implementation of this UITableViewDelegate
method is much simpler thanks to the ToDo
class.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) // Fetch Item let item = items[indexPath.row] // Update Item item.done = !item.done // Fetch Cell let cell = tableView.cellForRow(at: indexPath) // Update Cell cell?.accessoryType = item.done ? .checkmark : .none // Save State 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 items.append(didAddItem) // Save State saveItems() // Reload Table View tableView.reloadData() // Dismiss Add Item View Controller dismiss(animated: true) }
Step 5: Save Items
The pathForItems()
Method
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 { guard let documentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first, let url = URL(string: documentsDirectory) else { fatalError("Documents Directory Not Found") } return url.appendingPathComponent("items").path }
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.
Notice that we use a guard
statement to make sure the value returned by NSSearchPathForDirectoriesInDomains(_:_:_:)
is valid. We throw a fatal error if this operation fails. This immediately terminates the application. Why do we do this? If the operating system is unable to hand us the path to the documents directory, we have bigger problems to worry about.
The value we return from pathForItems()
is composed of the path to the documents directory with the string "items"
appended to it.
The loadItems()
Method
The loadItems method changes quite a bit. We first store the result of pathForItems()
in a constant, 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, items
. In the if
clause, we assign the value stored in items
to the items
property.
private func loadItems() { let path = pathForItems() if let items = NSKeyedUnarchiver.unarchiveObject(withFile: path) as? [ToDo] { self.items = items } }
The saveItems()
Method
The saveItems()
method is short and simple. We store the result of pathForItems()
in a constant, 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 = pathForItems() if NSKeyedArchiver.archiveRootObject(self.items, toFile: path) { print("Successfully Saved") } else { print("Saving Failed") } }
Step 6: Clean 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 more reliable. Thanks to the ToDo
class, managing the items in our list much is now easier and less error-prone.
Conclusion
In this lesson, 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 lesson. You can read more about initialization and initializer delegation in The Swift Programming Language.
In the meantime, check out some of our other courses and tutorials about Swift language iOS development!
-
SwiftCreate iOS Apps With Swift 3
-
iOSGo Further With Swift: Animation, Networking, and Custom Controls
-
SwiftCode a Side-Scrolling Game With Swift 3 and SpriteKit
-
iOS SDKThe Right Way to Share State Between Swift View Controllers
I love your blog.. very nice colors & theme. Did you make this website yourself or did you hire someone to do it for you? Plz reply as I’m looking to design my own blog and would like to know where u got this from. thank you
Yes, thank you for comments. We can design and build your website with a similar theme. Please contact us for further details.