Day 26 - 100 Days of Swift

9 minute read

Project 4 (part 3)

Day 26 is the review/challenge part of the fourth project. He gives you three challenges. Present an alert when the user tries to go to a url that isn’t allowed. Add forward and backward buttons to the tool bar. And add a table view controller as the initial view controller, that gives the user a list of websites to choose from instead of always loading the first by default.

The first challenge is a pretty simple one. I just added a method called presentCancelledAlert(for:) which presents an alert controller telling the user why they can’t follow that link:

1
2
3
4
5
6
7
8
9
10
11
12
func presentCancelledAlert(for host: String) {
    let message = "Sorry, \(host) is not on the approved list."
    let alertController = UIAlertController(title: "Can't go there!",
                                            message: message,
                                            preferredStyle: .alert)

    let cancelAction = UIAlertAction(title: "Cancel",
                                     style: .cancel)

    alertController.addAction(cancelAction)
    present(alertController, animated: true)
}

And then called that function inside of the if let statement, but after the for loop completes in webView(_:decidePolicyFor:decisionHandler):

1
2
3
    } // for loop ends
    presentCancelledAlert(for: host)
} // if let closes

This presents an alert that looks like this, when the user tries to navigate to disallowed webpages:

Screenshot of cancel alert that is presented to the user.

The second challenge required a little more effort, but was still pretty simple. The first thing I did, because I think it looks better aesthetically, is pull the progress view out of the toolbar, and just set it on top, taking up the full width of the screen. I did it with anchor constraints, which we haven’t covered yet, but it was the easiest way I knew how since we are making everything programmatically in this app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// In viewDidLoad()
progressView = UIProgressView(progressViewStyle: .default)
view.addSubview(progressView)
progressView.translatesAutoresizingMaskIntoConstraints = false

progressView.leadingAnchor
    .constraint(equalTo: view.leadingAnchor)
    .isActive = true
progressView.trailingAnchor
    .constraint(equalTo: view.trailingAnchor)
    .isActive = true
progressView.bottomAnchor
    .constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
    .isActive = true

I think this looks nicer and it also frees up room for the two new buttons in the tool bar. In order to get the layout I wanted, I added another spacer, and two new UIBarButtonItems:

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
// Still in viewDidLoad()
let backward =
    UIBarButtonItem(title: "←",
                    style: .plain,
                    target: webView,
                    action: #selector(webView.goBack))

let spacer1 =
    UIBarButtonItem(barButtonSystemItem: .flexibleSpace,
                    target: nil,
                    action: nil)

let refresh =
    UIBarButtonItem(barButtonSystemItem: .refresh,
                    target: webView,
                    action: #selector(webView.reload))
let spacer2 =
    UIBarButtonItem(barButtonSystemItem: .flexibleSpace,
                    target: nil,
                    action: nil)

let forward =
    UIBarButtonItem(title: "→",
                    style: .plain,
                    target: webView,
                    action: #selector(webView.goForward))

toolbarItems = [backward, spacer1, refresh, spacer2, forward]
navigationController?.isToolbarHidden = false

This leads to a layout where the back button is on the far left, the refresh button is in the middle, and the forward button is on the far right:

Screenshot of new toolbar with three buttons.

Finally, the third challenge is a little more involved. Because I wanted to keep the list of approved website in a central place, and I wanted to keep references as simple as possible, I decided to pull that array out into its own class, which I called WebsiteController:

1
2
3
4
5
6
7
8
class WebsiteController {
    var allowedWebsites = [
        "dillon-mce.com",
        "apple.com",
        "hackingwithswift.com",
        "github.com"
    ]
}

Then I went through and changed everything in ViewController to pull from a new websiteController property:

1
2
3
4
5
6
7
8
9
10
11
// At the top of the class
var websiteController = WebsiteController()

// In viewDidLoad()
let url = URL(string: "https://" + websiteController.allowedWebsites[0])!

// In openTapped()
for website in websiteController.allowedWebsites {

// In webView(_:decidePolicyFor:decisionHandler:)
for website in websiteController.allowedWebsites {

I started by doing this, because it let me test and make sure I didn’t break anything before I moved on to the table view. I also added a var selectedWebsite: String? property to give the table view controller somewhere to hand that information off, but I didn’t use it anywhere yet.

Next I created a new WebsiteTableViewController class added a table view controller to the storyboard, and embedded it into the navigation controller instead of the the view controller. I set its class and title and gave the cell a reuse identifier:

Screenshot of storyboad with new table view controller.

Then I went through and implemented the necessary UITableViewDataSource methods to get the table to display the data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let websiteController = WebsiteController()

// MARK: - Table View Data Source
override func tableView(_ tableView: UITableView,
                        numberOfRowsInSection section: Int)
    -> Int {

    return websiteController.numberOfRows()
}

override func tableView(_ tableView: UITableView,
                        cellForRowAt indexPath: IndexPath)
    -> UITableViewCell {

    let cell = tableView
        .dequeueReusableCell(withIdentifier: "WebsiteCell",
                             for: indexPath)
    let website = websiteController.website(for: indexPath)

    cell.textLabel?.text = website

    return cell
}

As you can see, I took this opportunity to do a little refactoring, to improve readability and increase the separation of concerns, by adding these methods to WebsiteController:

1
2
3
4
5
6
7
func numberOfRows() -> Int {
    return allowedWebsites.count
}

func website(for indexPath: IndexPath) -> String {
    return allowedWebsites[indexPath.row]
}

Neither of these is really necessary right now, but I like how readable it is, and it gives a good consistent interface for table views to get the data that they need, and if we need to change the logic to (say to split things into categories or something), we could do that in WebsiteController without any table view having to know about it.

Next, I gave the view controller a storyboard id and adopted this UITableViewDelegate method, to present the right website when the users taps on a cell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// MARK: - Table View Delegate
override func tableView(_ tableView: UITableView,
                        didSelectRowAt indexPath: IndexPath) {
    let websiteVC = storyboard?
        .instantiateViewController(withIdentifier: "WebsiteVC")
        as! ViewController
    let website = websiteController.website(for: indexPath)

    websiteVC.websiteController = websiteController
    websiteVC.selectedWebsite = website

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

This hands off the website controller and the selected website to the newly instantiated ViewController instance. But for it to actually work, I also had to change a couple of things in ViewController:

1
2
3
4
5
6
7
8
9
10
// At the top of the class
var websiteController: WebsiteController!

// In viewDidLoad()
if let website = selectedWebsite {
    title = website
    let url = URL(string: "https://" + website)!
    webView.load(URLRequest(url: url))
    webView.allowsBackForwardNavigationGestures = true
}

Now the app works as it should. It presents a list of the approved websites in a table view when the app is launched, and when you tap on any cell in the list, it will present a new view controller that will load that website. I made one more refactoring decision though. Instead of looping through all the websites in webView(_:decidePolicyFor:decisionHandler:), I decided that task should really be the responsibility of the WebsiteController , so I added another method to it:

1
2
3
4
5
6
7
8
func isAllowedToGo(to host: String) -> Bool {
    for website in allowedWebsites {
        if host.contains(website) {
            return true
        }
    }
    return false
}

And changed my code in the web view method to this much more readable version:

1
2
3
4
5
6
7
8
if let host = url?.host {
    if websiteController.isAllowedToGo(to: host) {
        decisionHandler(.allow)
        return
    } else {
        presentCancelledAlert(for: host)
    }
}

In the end, the app looks like this:

Screenshots of working app.

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

Reflections

I had a lot of fun with today’s project. I spent way more time on it than I normally do, partially because today is Saturday and I can, but mostly because I was having fun and I kept thinking of things I could change and small improvements I could make. And the app is cool. Not super practical in its current state, but it isn’t too far of a leap to get to a really locked-down parental-control version of a web browser from what we’ve built so far. Or maybe a browser where you only whitelist your work websites to keep yourself from wasting time during the day.