Day 25
Day 25 관련
Project 4, part two
If there’s one Martin Fowler quote that I love, it’s this: “I'm not a great programmer; I'm just a good programmer with great habits.” Today we need to add some more functionality to our project, but we’re faced with a choice: do we take the easy route or take the harder route?
As you’ll see, sometimes the “easy” route ends up being hard in the long term, because we need to maintain that code for a long time. The harder route takes a little rewriting of our code, but it’s one of many steps you’ll take towards having better coding habits – an important skill to have!
Today you have two topics to work through, and you’ll meet UIProgressView
, key-value observing, and more.
Monitoring page loads: UIToolbar
and UIProgressView
Monitoring page loads: `UIToolbar` and `UIProgressView`
Now is a great time to meet two new UIView
subclasses: UIToolbar
and UIProgressView. UIToolbar
holds and shows a collection of UIBarButtonItem
objects that the user can tap on. We already saw how each view controller has a rightBarButton
item, so a UIToolbar
is like having a whole bar of these items. UIProgressView
is a colored bar that shows how far a task is through its work, sometimes called a "progress bar."
The way we're going to use UIToolbar
is quite simple: all view controllers automatically come with a toolbarItems
array that automatically gets read in when the view controller is active inside a UINavigationController
.
This is very similar to the way rightBarButtonItem
is shown only when the view controller is active. All we need to do is set the array, then tell our navigation controller to show its toolbar, and it will do the rest of the work for us.
We're going to create two UIBarButtonItems
at first, although one is special because it's a flexible space. This is a unique UIBarButtonItem
type that acts like a spring, pushing other buttons to one side until all the space is used.
In viewDidLoad()
, put this new code directly below where we set the rightBarButtonItem
:
let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
let refresh = UIBarButtonItem(barButtonSystemItem: .refresh, target: webView, action: #selector(webView.reload))
toolbarItems = [spacer, refresh]
navigationController?.isToolbarHidden = false
The first line is new, or at least part of it is: we're creating a new bar button item using the special system item type .flexibleSpace
, which creates a flexible space. It doesn't need a target or action because it can't be tapped. The second line you've seen before, although now it's calling the reload()
method on the web view rather than using a method of our own.
The last two lines are new: the first creates an array containing the flexible space and the refresh button, then sets it to be our view controller's toolbarItems
array. The second sets the navigation controller's isToolbarHidden
property to be false, so the toolbar will be shown – and its items will be loaded from our current view.
That code will compile and run, and you'll see the refresh button neatly aligned to the right – that's the effect of the flexible space automatically taking up as much room as it can on the left.
The next step is going to be to add a UIProgressView
to our toolbar, which will show how far the page is through loading. However, this requires two new pieces of information:
- You can't just add random
UIView
subclasses to aUIToolbar
, or to therightBarButtonItem
property. Instead, you need to wrap them in a specialUIBarButtonItem
, and use that instead. - Although
WKWebView
tells us how much of the page has loaded using itsestimatedProgress
property, theWKNavigationDelegate
system doesn't tell us when this value has changed. So, we're going to ask iOS to tell us using a powerful technique called key-value observing, or KVO.
First, let's create the progress view and place it inside the bar button item. Begin by declaring the property at the top of the ViewController
class next to the existing WKWebView
property:
var progressView: UIProgressView!
Now place this code directly before the let spacer =
line in viewDidLoad()
:
progressView = UIProgressView(progressViewStyle: .default)
progressView.sizeToFit()
let progressButton = UIBarButtonItem(customView: progressView)
All three of those lines are new, so let's go over them:
- The first line creates a new
UIProgressView
instance, giving it the default style. There is an alternative style called.bar
, which doesn't draw an unfilled line to show the extent of the progress view, but the default style looks best here. - The second line tells the progress view to set its layout size so that it fits its contents fully.
- The last line creates a new
UIBarButtonItem
using thecustomView
parameter, which is where we wrap up ourUIProgressView
in aUIBarButtonItem
so that it can go into our toolbar.
With the new progressButton
item created, we can put it into our toolbar items anywhere we want it. The existing spacer will automatically make itself smaller to give space to the progress button, so I'm going to modify my toolbarItems
array to this:
toolbarItems = [progressButton, spacer, refresh]
That is, progress view first, then a space in the center, then the refresh button on the right.
If you run the app now, you'll just see a thin gray line for our progress view – that's because it's default value is 0, so there's nothing colored in. Ideally we want to set this to match our webView's estimatedProgress
value, which is a number from 0 to 1, but WKNavigationDelegate doesn't tell us when this value has changed.
Apple's solution to this is huge. Apple's solution is powerful. And, best of all, Apple's solution is almost everywhere in its toolkits, so once you learn how it works you can apply it elsewhere. It's called key-value observing (KVO), and it effectively lets you say, "please tell me when the property X of object Y gets changed by anyone at any time."
We're going to use KVO to watch the estimatedProgress
property, and I hope you'll agree that it's useful. First, we add ourselves as an observer of the property on the web view by adding this to viewDidLoad()
:
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil)
The addObserver()
method takes four parameters: who the observer is (we're the observer, so we use self
), what property we want to observe (we want the estimatedProgress
property of WKWebView
), which value we want (we want the value that was just set, so we want the new one), and a context value.
forKeyPath
and context
bear a little more explanation. forKeyPath
isn't named forProperty
because it's not just about entering a property name. You can actually specify a path: one property inside another, inside another, and so on. More advanced key paths can even add functionality, such as averaging all elements in an array! Swift has a special keyword, #keyPath
, which works like the #selector
keyword you saw previously: it allows the compiler to check that your code is correct – that the WKWebView
class actually has an estimatedProgress
property.
context
is easier: if you provide a unique value, that same context value gets sent back to you when you get your notification that the value has changed. This allows you to check the context to make sure it was your observer that was called. There are some corner cases where specifying (and checking) a context is required to avoid bugs, but you won't reach them during any of this series.
Warning: in more complex applications, all calls to addObserver()
should be matched with a call to removeObserver()
when you're finished observing – for example, when you're done with the view controller.
Once you have registered as an observer using KVO, you must implement a method called observeValue()
. This tells you when an observed value has changed, so add this method now:
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "estimatedProgress" {
progressView.progress = Float(webView.estimatedProgress)
}
}
As you can see it's telling us which key path was changed, and it also sends us back the context we registered earlier so you can check whether this callback is for you or not.
In this project, all we care about is whether the keyPath
parameter is set to estimatedProgress
– that is, if the estimatedProgress
value of the web view has changed. And if it has, we set the progress
property of our progress view to the new estimatedProgress
value.
Minor note: estimatedProgress
is a Double
, which as you should remember is one way of representing decimal numbers like 0.5 or 0.55555. Unhelpfully, UIProgressView
's progress
property is a Float
, which is another (lower-precision) way of representing decimal numbers. Swift doesn't let you put a Double
into a Float
, so we need to create a new Float
from the Double
.
If you run your project now, you'll see the progress view fills up with blue as the page loads.
Refactoring for the win
Refactoring for the win
Our app has a fatal flaw, and there are two ways to fix it: double up on code, or refactor. Cunningly, the first option is nearly always the easiest, and yet counter-intuitively also the hardest.
The flaw is this: we let users select from a list of websites, but once they are on that website they can get pretty much anywhere else they want just by following links. Wouldn't it be nice if we could check every link that was followed so that we can make sure it's on our safe list?
One solution – doubling up on code – would have us writing the list of accessible websites twice: once in the UIAlertController
and once when we're checking the link. This is extremely easy to write, but it can be a trap: you now have two lists of websites, and it's down to you to keep them both up to date. And if you find a bug in your duplicated code, will you remember to fix it in the other place too?
The second solution is called refactoring, and it's effectively a rewrite of the code. The end result should do the same thing, though. The purpose of the rewrite is to make it more efficient, make it easier to read, reduce its complexity, and to make it more flexible. This last use is what we'll be shooting for: we want to refactor our code so there's a shared array of allowed websites.
Up where we declared our two properties webView
and progressView
, add this:
var websites = ["apple.com", "hackingwithswift.com"]
That's an array containing the websites we want the user to be able to visit.
With that array, we can modify the web view's initial web page so that it's not hard-coded. In viewDidLoad()
, change the initial web page to this:
let url = URL(string: "https://" + websites[0])!
webView.load(URLRequest(url: url))
So far, so easy. The next change is to make our UIAlertController
use the websites for its list of UIAlertActions
. Go down to the openTapped()
method and replace these two lines:
ac.addAction(UIAlertAction(title: "apple.com", style: .default, handler: openPage))
ac.addAction(UIAlertAction(title: "hackingwithswift.com", style: .default, handler: openPage))
…with this loop:
for website in websites {
ac.addAction(UIAlertAction(title: website, style: .default, handler: openPage))
}
That will add one UIAlertAction
object for each item in our array. Again, not too complicated.
The final change is something new, and it belongs to the WKNavigationDelegate
protocol. If you find space for a new method and start typing "web" you'll see the list of WKWebView
-related code completion options. Look for the one called decidePolicyFor
and let Xcode fill in the method for you.
This delegate callback allows us to decide whether we want to allow navigation to happen or not every time something happens. We can check which part of the page started the navigation, we can see whether it was triggered by a link being clicked or a form being submitted, or, in our case, we can check the URL to see whether we like it.
Now that we've implemented this method, it expects a response: should we load the page or should we not? When this method is called, you get passed in a parameter called decisionHandler
. This actually holds a function, which means if you "call" the parameter, you're actually calling the function.
In project 2 I talked about closures: chunks of code that you can pass into a function like a variable and have executed at a later date. This decisionHandler
is also a closure, except it's the other way around – rather than giving someone else a chunk of code to execute, you're being given it and are required to execute it.
And make no mistake: you are required to do something with that decisionHandler
closure. That might make sound an extremely complicated way of returning a value from a method, and that's true – but it's also underestimating the power a little! Having this decisionHandler
variable/function means you can show some user interface to the user "Do you really want to load this page?" and call the closure when you have an answer.
You might think that already sounds complicated, but I’m afraid there’s one more thing that might hurt your head. Because you might call the decisionHandler
closure straight away, or you might call it later on (perhaps after asking the user what they want to do), Swift considers it to be an escaping closure. That is, the closure has the potential to escape the current method, and be used at a later date. We won’t be using it that way, but it has the potential and that’s what matters.
Because of this, Swift wants us to add the special keyword @escaping
when specifying this method, so we’re acknowledging that the closure might be used later. You don’t need to do anything else – just add that one keyword, as you’ll see in the code below.
So, we need to evaluate the URL to see whether it's in our safe list, then call the decisionHandler
with a negative or positive answer. Here's the code for the method:
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url
if let host = url?.host {
for website in websites {
if host.contains(website) {
decisionHandler(.allow)
return
}
}
}
decisionHandler(.cancel)
}
There are some easy bits, but they are outweighed by the hard bits so let's go through every line in detail to make sure:
- First, we set the constant
url
to be equal to theURL
of the navigation. This is just to make the code clearer. - Second, we use
if let
syntax to unwrap the value of the optionalurl.host
. Remember I said thatURL
does a lot of work for you in parsing URLs properly? Well, here's a good example: this line says, "if there is a host for this URL, pull it out" – and by "host" it means "website domain" like apple.com. Note: we need to unwrap this carefully because not all URLs have hosts. - Third, we loop through all sites in our safe list, placing the name of the site in the
website
variable. - Fourth, we use the
contains()
String method to see whether each safe website exists somewhere in the host name. - Fifth, if the website was found then we call the decision handler with a positive response - we want to allow loading.
- Sixth, if the website was found, after calling the
decisionHandler
we use thereturn
statement. This means "exit the method now." - Last, if there is no host set, or if we've gone through all the loop and found nothing, we call the decision handler with a negative response: cancel loading.
You give the contains()
method a string to check, and it will return true if it is found inside whichever string you used with contains()
. You've already met the hasPrefix()
method in project 1, but hasPrefix()
isn't suitable here because our safe site name could appear anywhere in the URL. For example, slashdot.org redirects to m.slashdot.org for mobile devices, and hasPrefix()
would fail that test.
The return
statement is new, but it's one you'll be using a lot from now on. It exits the method immediately, executing no further code. If you said your method returns a value, you'll use the return
statement to return that value.
Your project is complete: press Cmd+R to run the finished app, and enjoy!