Day 69 - 100 Days of Swift

10 minute read

Project 19 (part 3)

Day 69 is the third part of the nineteenth project. You review the material you covered in the first two days and then he gives you three challenges to take the project farther. He challenges you to add a bar button that lets you select from some pre-written snippets. He challenges you to let the user save snippets and associate them with the host of the current website. And he challenges you to make a UITableView where the user can see a list of their saved snippets and select one.

View

The first thing I did was rework the UI a little bit. I changed the “done” bar button to a “cancel” button and moved it to the left side:

1
2
3
4
5
6
7
8
9
navigationItem.leftBarButtonItem =
    UIBarButtonItem(barButtonSystemItem: .cancel,
                    target: self,
                    action: #selector(cancel))

@IBAction func cancel() {
    extensionContext?
        .completeRequest(returningItems: extensionContext?.inputItems)
}

Then I added a floating “Run” button to the bottom which I hooked up to the done method. Then I added a “Saved Snippets” button to the right side of the navigation bar, with a segue to the next view controller, which is a UITableViewController where I will display the saved snippets. I gave it a UIBarButtonItem for adding a new snippet that segues to the next view controller, which is a UIViewController with a UITextField for setting the title and a UITextView for writing the snippet. Finally, that view controller has a UIBarButtonItem for saving the snippet. After all that, it looks like this:

Screenshot of storyboard

Model

Next, I added a Snippet class for modeling the data. I gave it a title and text properties, as well as a set of hosts so that I could associate it with as many websites as I wanted:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Snippet: Codable {
    var title: String
    var text: String
    var hosts: Set<String> = []

    init(title: String, text: String, host: String?) {
        self.title = title
        self.text = text
        if let host = host {
            self.hosts.insert(host)
        }
    }
}

Then I added a SnippetController for keeping track of all the data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SnippetController {
    static let shared = SnippetController()
    private init() { loadSnippets() }

    private var snippets: [Snippet] = [] {
        didSet {
            sortSnippets()
        }
    }

    private var sortedSnippets: [[Snippet]] = []
    private var sectionNames: [String] = []

    static var currentHost: String? {
        didSet {
            shared.sortSnippets()
        }
    }
}

I made it a singleton mostly because I was being lazy and I didn’t want to have to pass it around. The currentHost property keeps track of the host where the extension was called, so it can be saved on new/edited snippets. sortedSnippets and sectionNames are what the table view will pull its data from, so that it can be split into a top section that has the snippets with the current host and the bottom section with all the other snippets:

1
2
3
4
5
6
7
8
9
10
11
private func sortSnippets() {
    if let host = SnippetController.currentHost {
        let prioritySnippets = snippets.filter { $0.hosts.contains(host) }
        let otherSnippets = snippets.filter { !$0.hosts.contains(host) }
        sortedSnippets = [prioritySnippets, otherSnippets]
        sectionNames = ["Priority Snippets", "Everything Else"]
    } else {
        sortedSnippets = [snippets]
        sectionNames = ["All Snippets"]
    }
}

The api for the table view looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// MARK: - Tableview API
func numberOfSections() -> Int {
    return sortedSnippets.count
}

func title(for section: Int) -> String {
    return sectionNames[section]
}

func numberOfRows(in section: Int) -> Int {
    return sortedSnippets[section].count
}

func snippet(for indexPath: IndexPath) -> Snippet {
    return sortedSnippets[indexPath.section][indexPath.row]
}

And the api for the CRUD methods looks like this:

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
// MARK: - CRUD Methods
func addSnippet(title: String, text: String) {
    let snippet = Snippet(title: title,
                          text: text,
                          host: SnippetController.currentHost)
    snippets.append(snippet)
    saveSnippets()
}

func updateSnippet(_ snippet: Snippet,
                   title: String,
                   text: String) {
    snippet.title = title
    snippet.text = text
    if let host = SnippetController.currentHost {
        snippet.hosts.insert(host)
    }
    saveSnippets()
    sortSnippets()
}

func deleteSnippet(at indexPath: IndexPath) {
    let snippet = sortedSnippets[indexPath.section][indexPath.row]
    if let index = snippets.firstIndex(of: snippet) {
        snippets.remove(at: index)
        saveSnippets()
    }
}

The update method has to manually call sortSnippets() because changing properties on an existing Snippet in the snippets array won’t actually trigger the didSet observer.

Finally, the persistence methods look like this:

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
// MARK: - Persistence
private let saveURL: URL = {
    let url = FileManager.default.urls(for: .documentDirectory,
                                       in: .userDomainMask)
        .first!
        .appendingPathComponent("snippets")
        .appendingPathExtension("json")
    return url
}()

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

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

Controller

With that, my model and the interface to it are pretty much ready to go. Next, I hooked the table view up to it:

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
let snippetController = SnippetController.shared

// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
    return snippetController.numberOfSections()
}

override func tableView(_ tableView: UITableView,
                        titleForHeaderInSection section: Int) -> String? {
    return snippetController.title(for: section)
}

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

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

    cell.textLabel?.text = snippet.title
    cell.detailTextLabel?.text = snippet.text

    return cell
}

In order to pass the selected snippet back to the main view controller I gave SnippetTableViewController a dismissHandler:

1
2
3
4
5
6
7
8
9
10
11
12
var dismissHandler: ((String) -> Void)?

override func tableView(_ tableView: UITableView,
                        didSelectRowAt indexPath: IndexPath) {
    let snippet = snippetController.snippet(for: indexPath)

    if let handler = dismissHandler {
        handler(snippet.text)
    }

    navigationController?.popViewController(animated: true)
}

And then gave it something to do in prepare(for:sender:) of ActionViewController:

1
2
3
4
5
if let destination = segue.destination as? SnippetTableViewController {
    destination.dismissHandler = { [weak self] text in
        self?.scriptView.text = text
    }
}

Finally, I added a couple of swipe actions to the table view, one to delete the snippet and one to edit the snippet:

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
override func tableView(_ tableView: UITableView,
                        leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let deleteAction = UIContextualAction(style: .destructive,
                                          title: "Delete")
    { (action, view, completion) in
        self.snippetController.deleteSnippet(at: indexPath)
        self.tableView.deleteRows(at: [indexPath],
                                  with: .automatic)
        completion(true)
    }
    let config = UISwipeActionsConfiguration(actions: [deleteAction])
    return config
}

override func tableView(_ tableView: UITableView,
                        trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let editAction = UIContextualAction(style: .normal,
                                        title: "Edit")
    { (action, view, completion) in
        let snippet = self.snippetController.snippet(for: indexPath)
        if let snippetVC = self.storyboard?
            .instantiateViewController(withIdentifier: "SnippetViewController")as? SnippetViewController {
            snippetVC.snippet = snippet
            self.navigationController?.pushViewController(snippetVC,
                                                          animated: true)
        }
    }
    editAction.backgroundColor = UIColor(red: 52/255,
                                         green: 199/255,
                                         blue: 89/255,
                                         alpha: 1)
    let config = UISwipeActionsConfiguration(actions: [editAction])
    return config
}

Next, I hooked up the SnippetViewController. It pretty much just has to configure itself if it is given a snippet and save the snippet if all the necessary information is present:

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
var snippet: Snippet?
var snippetController = SnippetController.shared

@IBOutlet weak var titleTextField: UITextField!
@IBOutlet weak var bodyTextView: UITextView!

override func viewDidLoad() {
    super.viewDidLoad()

    updateViews()
}

@IBAction func saveSnippet(_ sender: Any) {

    guard let title = titleTextField.text,
            !title.isEmpty,
            let text = bodyTextView.text,
            !text.isEmpty else { return }

    if let snippet = snippet {
        snippetController.updateSnippet(snippet,
                                        title: title,
                                        text: text)
    } else {
        snippetController.addSnippet(title: title,
                                     text: text)
    }

    navigationController?.popViewController(animated: true)
}

private func updateViews() {
    guard let snippet = snippet else {
        title = "New Snippet"
        return
    }

    title = snippet.title
    titleTextField.text = snippet.title
    bodyTextView.text = snippet.text
}

Finally, I added a helper function to ActionViewController that loads the host into the SnippetController:

1
2
3
4
5
6
7
8
9
10
11
private func loadHost(from urlString: String) {
    if let url = URL(string: urlString) {
        let host = url.host
        SnippetController.currentHost = host
    }
}

// When the pageURL is set
if let string = self?.pageURL {
    self?.loadHost(from: string)
}

And that’s it. Now I have a working extension that lets you save snippets, it remembers what website you were on when you made or edited it, and puts those snippets at the top of the list when you view your saved snippets.

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