Day 32 - 100 Days of Swift

13 minute read

Consolidation Day III (Project 4-6)

Day 32 is the third consolidation day. You review some of the important things that you learned over the last three projects: delegation, closures, try? and the visual format language for constraints. Then he gives you a challenge to make a simple shopping list app that lets the user add items to the list via an alert controller, clear the list, and share the list with a UIActivityViewController.

I wanted to take his challenge a little bit father, since I am pretty comfortable doing the things that he lays out. So the first thing I did was set up an Item class to hold the data and a model class for my shopping list called ShoppingListModel:

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
struct Item {
    var name: String
    var isCompleted = false

    init(name: String) {
        self.name = name
    }
}

class ShoppingListModel {

    private var shoppingList: [Item] = []

    func addToList(_ itemName: String) {
        let item = Item(name: itemName)
        shoppingList.insert(item, at: 0)
    }

    func numberOfSections() -> Int {
        return 1
    }

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

    func item(for indexPath: IndexPath) -> Item {
        return shoppingList[indexPath.row]
    }

    func clearList() {
        shoppingList.removeAll()
    }
}

Then I started getting my table view set up. Rather than making the whole thing a UITableViewController, I just added a UITableView to the view, and made it fill the whole safe area. I added an outlet from it to ViewController and set it to be the delegate and data source. I did it this way so I could add a floating button in the bottom right corner for adding tasks. I added the necessary tableView methods to ViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func numberOfSections(in tableView: UITableView) -> Int {
    return shoppingList.numberOfSections()
}

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

func tableView(_ tableView: UITableView,
               cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "ItemCell",
                                             for: indexPath)
    let item = shoppingList.item(for: indexPath)

    cell.textLabel?.text = item.name

    return cell
}

And then set up the button in the setupViews() method that I call from viewDidLoad:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// In setupViews(
tableView.dataSource = self
tableView.delegate = self

addButton = UIButton()
addButton.setBackgroundImage(UIImage(named: "plus"),
                             for: .normal)
addButton.tintColor = .blue
addButton.addTarget(self,
                    action: #selector(presentAddItemAlert),
                    for: .touchUpInside)
addButton.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(addButton)

addButton.heightAnchor.constraint(equalTo: addButton.widthAnchor,
                                  multiplier: 1).isActive = true
addButton.widthAnchor.constraint(equalToConstant: 64).isActive = true
addButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor,
                                    constant: -24).isActive = true
addButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor,
                                  constant: 0).isActive = true

This puts a little blue floating button down in the bottom right corner. I got the icon from Icons8. It looks like this:

Screenshot of add item button.

The presentAddItemAlert method 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
@objc func presentAddItemAlert() {
    let alertController = UIAlertController(title: "Add an item",
                                            message: nil,
                                            preferredStyle: .alert)
    var itemTextField: UITextField!
    alertController.addTextField { textField in
        textField.tintColor = .accentColor
        itemTextField = textField
    }

    let addAction = UIAlertAction(title: "Add",
                                  style: .default) { _ in
        guard let item = itemTextField.text else { return }
        self.shoppingList.addToList(item)
    }
    let cancelAction = UIAlertAction(title: "Cancel",
                                     style: .cancel)
    alertController.addAction(addAction)
    alertController.addAction(cancelAction)
    alertController.view.tintColor = .accentColor
    present(alertController, animated: true)
}

This presents an alert with a text field where the user can type in the name of the item they’d like to add. When/if they hit “Add”, it passes that text to the shoppingList’s addToList() method. So now the user has the ability to add an item to the model, but the problem is the model is not communicating that change back up to the view. (i.e. it won’t show up on the table view yet). So we need to do something about that.

I could have just added self.tableview.insertRows() right after the addToList call, but I wanted to play around with NotificationCenter. I also wanted the updates to the view to be driven by the model, so that if updates came in from elsewhere (syncing from another device or something) those changes would show up in the view as well. So I added this post to ShoppingListModel:

1
2
3
4
5
6
7
8
// in addToList
let userInfo: [AnyHashable: Any] = [
    Item.newIndexPath: IndexPath(row: 0, section: 0),
    Item.changeType: ItemUpdateType.add
]
NotificationCenter.default.post(name: Item.itemChangedNotification,
                                object: self,
                                userInfo: userInfo)

There are a couple of new things here. I added a static property on Item to hold a string “newIndexPath”, and one to hold the Notification.Name, and I added an enum to hold the type of changes I would be making to items:

1
2
3
4
5
6
7
static let itemChangedNotification = Notification.Name("ItemChanged")
static let newIndexPath = "newIndexPath"
}

enum ItemUpdateType {
    case add, remove, update, move
}

Then, in ViewController I added an observer for that notification:

1
2
3
4
5
// In setupViews
NotificationCenter.default.addObserver(self,
                                       selector: #selector(updateTableView),
                                       name: Item.itemChangedNotification,
                                       object: nil)

updateTableView looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
@objc private func updateTableView(_ notification: Notification) {
    guard let updateType = notification.userInfo?[Item.changeType] as? ItemUpdateType else { return }
    let newIndexPath = notification.userInfo?[Item.newIndexPath] as? IndexPath
    switch updateType {
    case .add:
        if let newIndexPath = newIndexPath {
            tableView.insertRows(at: [newIndexPath], with: .automatic)
        }
    default:
        print("Didn't hit one of the other cases")
        tableView.reloadData()
    }
}

With that, the full cycle is complete and the user can add items to the list.

Next, I wanted the user to be able to check an item off the list without deleting it, so I started looking into UISwipeActionConfiguration and UIContextualAction. After I was satisfied that that was the way to go, I needed to make some changes to be able to handle that. I thought the simplest way to go was to make shoppingList an array of arrays, instead of an array. So I made that change and went through and fixed all the errors it caused:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private var shoppingList: [[Item]] = [[],[]]

// In addToList
shoppingList[0].insert(item, at: 0)

// In numberOfSections
return 2

// In numberOfRows
return shoppingList[section].count

// In item(for:)
return shoppingList[indexPath.section][indexPath.row]

// In clearList()
for section in 0..<shoppingList.count {
    shoppingList[section].removeAll()
}

Then I added a couple of methods in to update the isCompleted property on an item:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func toggleCompletedOnItem(at indexPath: IndexPath) -> Bool {
    shoppingList[indexPath.section][indexPath.row].isCompleted.toggle()
    let newSection = indexPath.section == 0 ? 1 : 0
    moveItem(at: indexPath, to: IndexPath(row: 0, section: newSection))
    return true
}

func moveItem(at oldIndexPath: IndexPath,
              to newIndexPath: IndexPath) {
    let item = shoppingList[oldIndexPath.section].remove(at: oldIndexPath.row)
    shoppingList[newIndexPath.section].insert(item, at: newIndexPath.row)
    let userInfo: [AnyHashable: Any] = [
        Item.newIndexPath: newIndexPath,
        Item.oldIndexPath: oldIndexPath,
        Item.changeType: ItemUpdateType.move
    ]
    NotificationCenter.default.post(name: Item.itemChangedNotification,
                                    object: self,
                                    userInfo: userInfo)

}

Then, in ViewController I updated updateTableView to handle the move:

1
2
3
4
5
case .move:
    if let oldIndexPath = oldIndexPath, let newIndexPath = newIndexPath {
        tableView.moveRow(at: oldIndexPath, to: newIndexPath)
        tableView.reloadRows(at: [newIndexPath], with: .fade)
    }

I also updated the appearance of items in the second section like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// In tableView(cellForRowAt:)
if item.isCompleted {
    let attributedString = NSMutableAttributedString(string: item.name)
    let attributes: [NSAttributedString.Key: Any] = [
        .strikethroughStyle: 2,
        .foregroundColor: UIColor.gray
    ]
    let range = NSMakeRange(0, attributedString.length)
    attributedString.addAttributes(attributes,
                                   range: range)
    cell.textLabel?.attributedText = attributedString
} else {
    cell.textLabel?.text = item.name
}

With that, I could add the swipe action that would toggle the isCompleted state:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func tableView(_ tableView: UITableView,
               leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath)
    -> UISwipeActionsConfiguration? {
    let completeAction = contextualCompleteAction(for: indexPath)

    let swipeConfiguration = UISwipeActionsConfiguration(actions: [completeAction])
    return swipeConfiguration
}

private func contextualCompleteAction(for indexPath: IndexPath) -> UIContextualAction {
    let item = shoppingList.item(for: indexPath)
    let action = UIContextualAction(style: .normal,
                                    title: "Complete")
    { (action, view, completion) in
        if self.shoppingList.toggleCompletedOnItem(at: indexPath) {
            completion(true)
        } else {
            completion(false)
        }
    }
    action.image = UIImage(named: "ok")
    action.backgroundColor = item.isCompleted ? .gray : .orange
    return action
}

And with that, you can swipe over on an item to complete it and it will be crossed out and moved to the bottom of the list.

The final two things I did were to add a Clear All button to the navigation bar and a share button that would let the user share a text version of the list. I needed to make one update to clearList to get the animations to be a little nicer. I needed to collect a list of the indexPaths and pass those in the notification:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var indexPaths: [IndexPath] = []
for section in 0..<shoppingList.count {
    for row in 0..<shoppingList[section].count {
        indexPaths.append(IndexPath(row: row,
                                    section: section))
    }
    shoppingList[section].removeAll()
}
let userInfo: [AnyHashable: Any] = [
    Item.changeType: ItemUpdateType.remove,
    Item.indexPaths: indexPaths
]
NotificationCenter.default.post(name: Item.itemChangedNotification,
                                object: self,
                                userInfo: userInfo)

Then I made a corresponding update in ViewController.updateTableViews:

1
2
3
4
case .remove:
    if let indexPaths = indexPaths {
        tableView.deleteRows(at: indexPaths, with: .automatic)
    }

Then I just needed to add a button and point it at that method:

1
2
3
4
5
6
7
8
9
10
// In setupViews
navigationItem.leftBarButtonItem =
    UIBarButtonItem(title: "Clear All",
                    style: .plain,
                    target: self,
                    action: #selector(clearList))

@objc private func clearList() {
    shoppingList.clearList()
}

For sharing, I added a method to ShoppingListModel that would generate a text version of the list in its current state:

1
2
3
4
5
6
7
8
9
func generateSharableList() -> String {
    var string = ""
    let incompleteList = shoppingList[0].map { "- [ ] \($0.name)" }
    let completeList = shoppingList[1].map { "- [x] \($0.name)" }
    string += incompleteList.joined(separator: "\n")
    if incompleteList.count > 0 { string += "\n" }
    string += completeList.joined(separator: "\n")
    return string
}

Then I added a button and a method to present the UIActivityViewController:

1
2
3
4
5
6
7
8
9
10
11
12
13
// in setupViews
navigationItem.rightBarButtonItem =
    UIBarButtonItem(barButtonSystemItem: .action,
                    target: self,
                    action: #selector(presentActivityViewController))

@objc private func presentActivityViewController() {
    let list = shoppingList.generateSharableList()
    let activityViewController =
        UIActivityViewController(activityItems: [list],
                                 applicationActivities: nil)
    present(activityViewController, animated: true)
}

Finally, I added a custom accent color, to make things feel a little less like a system app:

1
2
3
4
5
6
7
8
9
10
11
12
13
extension UIColor {
    static let accentColor = UIColor(red: 0.2,
                                     green: 0.2,
                                     blue: 0.75,
                                     alpha: 1)
}

// In setupViews
navigationController?.navigationBar.tintColor = UIColor.accentColor
addButton.tintColor = UIColor.accentColor

// In presetAddItemAlert
alertController.view.tintColor = UIColor.accentColor

When it is all said and done, it looks like this:

Screenshots of completing an item in the working app.
Screenshot of adding an item in the working app.
Screenshots of sharing a list in the working app.

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

Reflections

Today was a lot of fun. I tried a bunch of stuff out, and ran into things that didn’t work, and learned a lot. I spent way too much time working on this project that doesn’t really matter, but I feel like I gained a lot from doing it. Especially starting to work out the communication between the model and the view. I’m not totally happy with where it is yet, because it still feels pretty “pieced together” and like it might break pretty easily, but I like the direction where it is headed. I’m also not really happy with how the model is split up into two arrays. That feels like I’m doing the work in two places. It works, for now. But I’m going to try to think of a better way to do that. All in all, it was a great day though.