Follow along at https://www.hackingwithswift.com/100/25.
This day covers the second part of Project 4, Easy Browser
in Hacking with Swift.
I have a separate repository where I've been creating projects alongside the material in the book. And you can find Project 4 here. However, this day focused specifically on a number of topics:
- Monitoring page loads: UIToolbar and UIProgressView
- Refactoring for the win
Built-in, at the ready, all view controllers contain a UIToolbar
with an array of toolbarItems
that, when set to be shown, automatically appears at the bottom of the view.
This is extremely handy (obviously... it's called a toolbar), and this project uses it to introduce a way to show a web page progress loader:
func setupToolbar() {
progressView = UIProgressView(progressViewStyle: .default)
progressView.sizeToFit()
let progressButton = UIBarButtonItem(customView: progressView)
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let refresh = UIBarButtonItem(barButtonSystemItem: .refresh, target: webView, action: #selector(webView.reload))
toolbarItems = [progressButton, spacer, refresh]
navigationController?.isToolbarHidden = false
}
Now initially, I would have imagined that WKNavigationDelegate
s received a callback for whenever page loading progress advances. But they don't. I can see why that would make sense in hindsight: That's a lot of functions potentially being fired and potentially not needed. In any case, it's up to us, then to explicitly observe changes. Since we can't use didSet
— that only works for instance properties of a class itself — we need to use another pattern: Key-Value Observing (AKA KVO).
Basically, this consists of the following steps:
-
Calling
NSObject.addObserver
in our UIViewController'sviewWillAppear
hook — passing in anobserver
object to observe, akeyPath
for the property we're interested in.override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil) }
-
Overriding
NSObject.observeValue(forKeyPath:of:change:context:)
, using its arguments to figure out what fired, and handle accordingly.override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer? ) { if keyPath == "estimatedProgress" { progressView.progress = Float(webView.estimatedProgress) } }
-
Calling
NSObject.removeObserver
in our UIViewController'sviewWillAppear
hook — passing in the same object/keypath pair we used foraddObserver
:override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) }
After a user reaches an initial website, they can try to go anywhere — but we want our menu options to function as a whitelist. This can be achieved by overriding WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:)
, and making sure that the hostname of the site that's currently trying to be reached is contained within one of the sites in our siteNames
list:
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
let url = navigationAction.request.url
if let host = url?.host {
for siteName in siteNames {
if host.contains(siteName) {
decisionHandler(.allow)
return
}
}
}
decisionHandler(.cancel)
}
🔑 A further optimization, IMO, would be to make structs
for each white-listed site —with separate, decoupled properties for host
and displayName
. That would make if host.contains(siteName)
a lot less magical, and not reliant on the implementation detail of siteName
.