Categories

  • Development

Tags

  • swift
  • tutorial
  • mobile
  • ios

First feature I want to implement is local storage through NSUserDefaults, why? Because it must be simple to do such a small feature giving the opportunity to implement unit tests all along.

Here comes Quick

When it gets to test frameworks I rather use the ones that are behaviour driven, they are:

  • Easier to use
  • Human friendly
  • Helps us to understand how the code works

So after a quick search I found Quick… Yeah I know, bad joke.

Onward we go and so I used cocoapods to add Quick as instructed by it. Right after pod install finished testing continues to be easy:

  • Create a new swift file in your test
  • Then add Quick, Nimble and your module imports
  • Code your tests
  • Be happy, at least until they break…

Nimble is a matcher framework like Shoulda and Expecta

import Quick
import Nimble
import SwiftTODOs

class TodoModelSpec : QuickSpec {
    override func spec() {
        describe("TodoModel") {
            var todo: TodoModel!
            beforeEach() {
                todo = TodoModel(content:"Sample content")
            }

            context("after init") {
                it("set item with given content") {
                    expect(todo.content).toNot(beEmpty())
                }

                it("set item with completed false by default") {
                    expect(todo.completed).toNot(beTrue())
                }

                it("set uuid") {
                    expect(todo.getUUID().UUIDString).toNot(beEmpty())
                }
            }
        }
    }
}

Pro tip: In order to the tests work I had to change the model making the things public

Going further with NSUserDefaults

As an exercise and to understand things better, each TodoModel is going to be able to save itself into NSUserDefaults. In the end we can remove this feature, since only saving multiple todos is interesting for the app.

Following is my test written specification and right after it the Swift code.

When a TodoModel is using local storage I want it to:

  • save to user defaults
  • load from user defaults
  • delete from user defaults
  • optionally returns the Todo
  • avoid failing when deleting “nothing”

In order to save/load/delete the Todo must have some identifier, for that it receives an NSUUID property.

context("using local storage") {
                it("save/load to/from user defaults") {
                    todo.saveLocally()
                    let storedTodo = TodoModel.loadLocally(todo.getUUID())
                    expect(storedTodo!.getUUID().UUIDString).to(equal(todo.getUUID().UUIDString))
                }

                it("retrieves no object safely") {
                    let storedTodo = TodoModel.loadLocally(NSUUID())
                    expect(storedTodo).to(beNil())
                }

                it("deletes from user defaults") {
                    todo.saveLocally()
                    todo.deleteLocally()
                    expect(TodoModel.loadLocally(todo.getUUID())).to(beNil())
                }

                it("deletes nothing safely") {
                    let notSavedTodo = TodoModel(content:"Not in userdefaults")
                    expect(TodoModel.loadLocally(notSavedTodo.getUUID())).to(beNil())
                    notSavedTodo.deleteLocally()
                    expect(TodoModel.loadLocally(notSavedTodo.getUUID())).to(beNil())
                }
            }

Now, to continue there is something that maybe one is not aware of, storing custom objects into NSUserDefaults demands that TodoModel inherits from NSObject and conform to NSCoding protocol.

Conforming to NSCoding means to implement encodeWithCoder(aCoder: NSCoder) and init(coder aDecoder: NSCoder) with each property being encoded as it type like this aCoder.encodeObject(uuid, forKey: "uuid"). Still don’t get it? Check this excerpt from Apple’s doc

“The NSCoding protocol declares the two methods that a class must implement so that instances of that class can be encoded and decoded. This capability provides the basis for archiving (where objects and other structures are stored on disk) and distribution (where objects are copied to different address spaces).”

    public func encodeWithCoder(aCoder: NSCoder) {
        aCoder.encodeObject(uuid, forKey: "uuid")
        aCoder.encodeObject(content, forKey: "content")
        aCoder.encodeBool(completed, forKey: "completed")
    }

    public required init(coder aDecoder: NSCoder) {
        uuid = aDecoder.decodeObjectForKey("uuid") as! NSUUID
        content = aDecoder.decodeObjectForKey("content") as! String
        completed = aDecoder.decodeBoolForKey("completed")
    }

Yet I didn’t said why it’s NSUserDefaults demands such details, the reason is that it accepts only a given set of classes and to store something custom one must use NSData which demands a class to be encoded using NSCoding to be transformed into NSData, so that is why.

Finally it is all set for using NSUserDefaults

//MARK: Local Storage
    public func saveLocally() {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        let encodedData = NSKeyedArchiver.archivedDataWithRootObject(self);
        userDefaults.setObject(encodedData, forKey: self.uuid.UUIDString)
    }

    public static func loadLocally(uuid: NSUUID) -> TodoModel? {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        if let todoData = userDefaults.objectForKey(uuid.UUIDString) {
            return NSKeyedUnarchiver.unarchiveObjectWithData(todoData as! NSData) as? TodoModel
        }
        return nil
    }

    public func deleteLocally() {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        userDefaults.removeObjectForKey(self.uuid.UUIDString)
    }

Replicating to a TodosModel

Each Todo saving itself would be chaotic, yet a TodosModel that represents a list of todos is perfect to represent this app’s data.

public class TodosModel {
    public  var list: [TodoModel] = []

    public init() {}

    public func filter(completedFilter: Bool?) -> [TodoModel] {
        return list.filter { (TodoModel) -> Bool in
            return completedFilter == nil || TodoModel.completed == completedFilter
        }
    }

    public func saveLocally(key: String) {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        let encodedData = NSKeyedArchiver.archivedDataWithRootObject(list)
        userDefaults.setObject(encodedData, forKey: key)
    }

    public static func loadLocally(key: String) -> [TodoModel]? {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        if let todoData = userDefaults.objectForKey(key) {
            return NSKeyedUnarchiver.unarchiveObjectWithData(todoData as! NSData) as? [TodoModel]
        }
        return nil
    }

    public static func deleteLocally(key: String) {
        let userDefaults = NSUserDefaults.standardUserDefaults()
        userDefaults.removeObjectForKey(key)
    }
}

The extra here is the filter operation, which as refactored from ViewController to here. Also there is

Saving the todos

Finally the only thing missing right now is to call save throughout where it is needed. I added the following lines with the correct logic into TodosViewController

TodosModel.loadLocally(TODOS_LOCAL_STORAGE)
...
todosData.saveLocally(TODOS_LOCAL_STORAGE)

Conclusion

  1. As iOS features are the same for Swift, often is needed to be done the same strange paths, the best example is the travel made to put a simple class into NSUserDefaults.
  2. Swift has some quit restrictive control over class variables, check more here
  3. Test with Quick is quick and pleasant
  4. Knowing iOS and ObjC prior to change to Swift really matters