Day 74 - 100 Days of Swift

11 minute read

Consolidation VIII

Day 74 is a consolidation day where you review projects nineteen through twenty-one. After the review, he gives you a challenge to recreate a basic version of Apple’s Notes app. He challenges you to create a table view that lists notes, inside of a navigation controller. Tapping on a cell in the table view should take you to a detail view that is a full-screen text view for writing a note. The notes should be saved and loaded using Codable. (He says you can use UserDefaults for that, but that doesn’t seem like a great idea to me, so I just wrote to disk.) He challenges you to add a toolbar with a few options and a share button that opens a UIActivityViewController to let the user share their note. He also suggests that you play around with the UI a little bit to try to match Apple Notes.

This first thing I did was layout a UITableViewController, embedded in a UINavigationController and with a .add UIBarButtonItem that segues to the detail view controller. I also added a segue from the prototype cell to the detail view controller. The detail view controller is a UIViewController with a full-screen UITextView and a UIToolBar where I added .delete .save and .compose UIBarButtonItems to it.

Screenshot of the storyboard layout for this app.

Next, I built out my model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Note: Codable, Equatable {
    var title: String
    var text: String
    var created: Date
    var modified: Date
    let id = UUID()

    init(title: String,
         text: String,
         created: Date = Date(),
         modified: Date = Date()) {

        self.title = title
        self.text = text
        self.created = created
        self.modified = modified
    }

    static func == (lhs: Note, rhs: Note) -> Bool {
        return lhs.id == rhs.id
    }
}

I also added a formatter to it and a computed property that will return all the text except the first line, to be the preview in the table view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static let dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateStyle = .short

    return dateFormatter
}()

var formattedModified: String {
    return Note.dateFormatter.string(from: modified)
}

var previewText: String {
    var array = text.components(separatedBy: .newlines)
    array.removeFirst()

    return array.joined(separator: "\n")
}

Then I wrote the model controller. I won’t spend a lot of time explaining it because it is very similar to the controller from the Javascript snippets project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
class NotesController {
    static let shared = NotesController()
    private init() { loadNotes() }

    var notes: [Note] = []
    private let center = NotificationCenter.default

    // MARK: - CRUD Methods
    func addNote(text: String) {
        let title = getTitle(for: text)
        let note = Note(title: title, text: text)

        notes.append(note)
        saveNotes()

        center.post(name: .notesChanged, object: self)
    }

    func updateNote(_ note: Note,
                    with text: String,
                    at date: Date = Date()) {
        let title = getTitle(for: text)

        note.title = title
        note.text = text
        note.modified = date

        saveNotes()

        center.post(name: .notesChanged, object: self)
    }

    func deleteNote(at indexPath: IndexPath) {
        notes.remove(at: indexPath.row)

        saveNotes()

        center.post(name: .notesChanged, object: self)
    }

    func deleteNote(_ note: Note) {
        guard let index = notes.firstIndex(of: note) else { return }

        notes.remove(at: index)

        saveNotes()

        center.post(name: .notesChanged, object: self)
    }

    private func getTitle(for text: String) -> String {
        var title = String(text.prefix { $0 != "\n" })
        title = title.isEmpty ? "New Note" : title
        return title
    }

    // MARK: - TableView API
    func numberOfSections() -> Int {
        return 1
    }

    func numberOfRows(in section: Int) -> Int {
        return notes.count
    }

    func note(for indexPath: IndexPath) -> Note {
        return notes[indexPath.row]
    }

    // MARK: - Persistence
    private let saveURL: URL = {
        let url = FileManager.default.urls(for: .documentDirectory,
                                           in: .userDomainMask)
            .first!
            .appendingPathComponent("notes")
            .appendingPathExtension("json")
        return url
    }()

    func saveNotes() {
        do {
            let data = try JSONEncoder().encode(notes)
            try data.write(to: saveURL)
        } catch {
            print("Error saving notes\n\(error)")
        }
    }

    func loadNotes() {
        guard let data = FileManager
            .default
            .contents(atPath: saveURL.path) else { return }
        do {
            let notes = try JSONDecoder().decode([Note].self,
                                                    from: data)
            self.notes = notes
        } catch {
            print("Error loading notes\n\(error)")
        }
    }
}

extension Notification.Name {
    static let notesChanged = Notification.Name("NotesChanged")
}

With those built out, hooking up the table view controller is pretty trivial:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let notesController = NotesController.shared

override func numberOfSections(in tableView: UITableView) -> Int {
    return notesController.numberOfSections()
}

override func tableView(_ tableView: UITableView,
                        numberOfRowsInSection section: Int) -> Int {
    return notesController.numberOfRows(in: section)
}

override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "NoteCell",
                                             for: indexPath)
    let note = notesController.note(for: indexPath)

    cell.textLabel?.text = note.title
    cell.detailTextLabel?.text = "\(note.formattedModified)\t\(note.previewText)"

    return cell
}

override func tableView(_ tableView: UITableView,
                        commit editingStyle: UITableViewCell.EditingStyle,
                        forRowAt indexPath: IndexPath) {
    guard editingStyle == .delete else { return }
    notesController.deleteNote(at: indexPath)
}

I also needed a way to be notified of when the tableview needs to be refreshed, so I set up an observer for the notification:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@objc private func updateTableView() {
    self.tableView.reloadData()
}

private func setupNotifications() {
    let center = NotificationCenter.default

    center.addObserver(self,
                       selector: #selector(updateTableView),
                       name: .notesChanged,
                       object: nil)
}

// In viewDidLoad
setupNotifications()

Then the only thing left to do in the table view controller is to pass the selected note to the detail view controller when the user taps on a cell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
override func prepare(for segue: UIStoryboardSegue,
                      sender: Any?) {
    switch segue.identifier {
    case "AddNoteSegue":
            break
    case "ShowNoteSegue":
        guard let destinationVC = segue.destination as? NoteViewController,
            let indexPath = tableView.indexPathForSelectedRow else { return }
        let note = notesController.note(for: indexPath)
        destinationVC.note = note
    default:
        print("Found an unsupported segue identifier: \(segue.identifier ?? "")")
        assert(false, "Shouldn't have any identifiers that aren't supported")
    }
}

Finally, to start the detail view controller I added observers to adjust the content insets on the text view when the keyboard appears:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private func setupKeyboardNotifications() {
    let notificationCenter = NotificationCenter.default
    notificationCenter.addObserver(self,
                                   selector: #selector(adjustForKeyboard),
                                   name: UIResponder.keyboardWillHideNotification,
                                   object: nil)

    notificationCenter.addObserver(self,
                                   selector: #selector(adjustForKeyboard),
                                   name: UIResponder.keyboardWillChangeFrameNotification,
                                   object: nil)
}

@objc private func adjustForKeyboard(notification: Notification) {
    let key = UIResponder.keyboardFrameEndUserInfoKey
    guard let keyboardValue = notification.userInfo?[key] as? NSValue else {
        return
    }

    let keyboardScreenEndFrame = keyboardValue.cgRectValue
    let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame,
                                            from: view.window)

    if notification.name == UIResponder.keyboardWillHideNotification {
        noteTextView.contentInset = .zero
    } else {
        let bottom = keyboardViewEndFrame.height - view.safeAreaInsets.bottom
        noteTextView.contentInset = UIEdgeInsets(top: 0,
                                               left: 0,
                                               bottom: bottom,
                                               right: 0)
    }

    noteTextView.scrollIndicatorInsets = noteTextView.contentInset

    let selectedRange = noteTextView.selectedRange
    noteTextView.scrollRangeToVisible(selectedRange)
}

// In viewDidLoad
setupKeyboardNotifications()

Then I wanted the title to update to reflect the first line that the user types. I thought this was a nice touch because it looks pretty good and it gives a visual representation of the fact that the app will use the first line of the text as the “title” of the note. To do that I adopted UITextViewDelegate and set the view controller as the text view’s delegate. Then I watched for any time the text changes and update the title when it does:

1
2
3
4
5
6
7
8
9
10
11
12
// In viewDidLoad
noteTextView.delegate = self

func textViewDidChange(_ textView: UITextView) {
    updateTitle()
}

private func updateTitle() {
    let title = String(noteTextView.text.prefix { $0 != "\n" })
    guard !title.isEmpty else { return }
    self.title = title
}

I also call updateTitle in the function that updates the view initially:

1
2
3
4
5
6
7
8
9
10
11
12
13
private func updateViews() {
    defer { updateTitle() }
    guard let note = note else {
        title = "New Note"
        return
    }

    title = note.title
    noteTextView.text = note.text
}

// In viewDidLoad
updateViews()

Then I just need a few actions for deleting, saving and creating notes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@IBAction func saveNote(_ sender: Any) {
    guard let text = noteTextView.text, !text.isEmpty else { return }

    if let note = note {
        notesController.updateNote(note, with: text)
    } else {
        notesController.addNote(text: text)
    }

    navigationController?.popViewController(animated: true)
}

@IBAction func deleteNote(_ sender: Any) {
    if let note = note {
        notesController.deleteNote(note)
    }

    navigationController?.popViewController(animated: true)
}

@IBAction func newNote(_ sender: Any) {
    let newNoteVC = storyboard!.instantiateViewController(withIdentifier: "NoteViewController")

    navigationController?.pushViewController(newNoteVC, animated: true)
}

And with that, I decided I was finished. The end result looks like this:

Screenshots of working app

It isn’t an amazing notes app, it doesn’t support attachments or rich text, it has limited means of organization, and it would definitely have some problems if you have a large number of notes or if you have some notes that are particularly long (though I tried to minimize those issues as much as I could in a relatively simple way). But, for a few hours in an evening I’d say it is still pretty useful if you need a simple notes app.

You can find my version of this project at the end of day 74 on Github here.