31.
Polishing the App
Written by Fahim Farook
Apps with appealing visuals sell better than ugly ones. Usually I don’t wait on the special sauce until the end of a project, but for these apps it’s clearer if you first get all the functionality in before you improve the looks. Now that the app works as it should, let’s make it look good!
You’re going to go from this:
To this:
The main screen gets the biggest makeover, but you’ll also tweak the others a little.
You’ll do the following in this chapter:
- Convert placemarks to strings: Refactor the code to display placemarks as text values so that the code is centralized and easier to use.
- Back to black: Change the appearance of the app to have a black background and light text.
- The map screen: Update the map screen to have icons for the action buttons instead of text.
- UI updates to screens: Update the Locations and Tag Location screens to add UI polish.
- Polish the main screen: Update the appearance of the main screen to add a bit of awesome sauce!
- Make some noise: Add sound effects to the app.
- The icon and launch images: Add the app icon and launch images to complete the app.
Convert placemarks to strings
Let’s begin by improving the code. I’m not really happy with the way the reverse geocoded street address gets converted from a CLPlacemark object into a string. It works, but the code is unwieldy and repetitive.
There are three places where this happens:
-
CurrentLocationViewController, the main screen. -
LocationDetailsViewController, the Tag/Edit Location screen. -
LocationsViewController, the list of saved locations.
Let’s start with the main screen. CurrentLocationViewController.swift has a method named string(from:) where this conversion happens. It’s supposed to return a string that looks like this:
subThoroughfare thoroughfare
locality administrativeArea postalCode
This string goes into a UILabel that has room for two lines, so you use the \n character sequence to create a line-break between the thoroughfare and locality.
The problem is that any of these properties may be nil. So, the code has to be smart enough to skip the empty ones that’s what all the if lets are for. What I don’t like is that there’s a lot of repetition going on in this method. You can refactor this.
Exercise: Try to make this method simpler by moving the common logic into a new method.
Answer: Here is how I did it. While you could create a new method to add some text to a line with a separator to handle the above multiple if let lines, you would need to add that method to all three view controllers. Of course, you could add the method to the Functions.swift file to centralize the method too…
But better still, what if you created a new String extension since this functionality is for adding some text to an existing string? Sounds like a plan?
➤ Add a new empty file to the project and name it String+AddText.
➤ Add the following to String+AddText.swift:
extension String {
mutating func add(
text: String?,
separatedBy separator: String
) {
if let text = text {
if !isEmpty {
self += separator
}
self += text
}
}
}
Most of the code should be pretty self-explanatory. You ask the string to add some text to itself, and if the string is currently not empty, you add the specified separator first before adding the new text.
Mutating
Notice the
mutatingkeyword. You haven’t seen this before. Sorry, it doesn’t have anything to do with X-men — programming is certainly fun, but not that fun. When a method changes the value of astruct, it must be marked asmutating. Recall thatStringis astruct, which is a value type, and therefore cannot be modified when declared withlet. Themutatingkeyword tells Swift that theadd(text:separatedBy:)method can only be used on strings that are made withvar, but not on strings made withlet.If you try to modify
selfin a method on astructthat is not marked asmutating, Swift considers this an error.You don’t need to use the
mutatingkeyword on methods inside aclassbecause classes are reference types and can always be mutated, even if they are declared withlet.
➤ Switch over to CurrentLocationViewController.swift and replace string(from:) with the following:
func string(from placemark: CLPlacemark) -> String {
var line1 = ""
line1.add(text: placemark.subThoroughfare, separatedBy: "")
line1.add(text: placemark.thoroughfare, separatedBy: " ")
var line2 = ""
line2.add(text: placemark.locality, separatedBy: "")
line2.add(text: placemark.administrativeArea, separatedBy: " ")
line2.add(text: placemark.postalCode, separatedBy: " ")
line1.add(text: line2, separatedBy: "\n")
return line1
}
That looks a lot cleaner. The logic that decides whether or not to add a CLPlacemark property to the string now lives in your new String extension, so you no longer need all those if let statements. You also use add(text:separatedBy:) to add line2 to line1 with a newline character in between.
➤ Run the app to see if it works.
There’s still a small thing you can do to improve the new add(text:separatedBy:) method. Remember default parameter values? You can use them here.
➤ In String+AddText.swift, change the line that defines the method to:
mutating func add(text: String?, separatedBy separator: String = "") {
Now, instead of:
line1.add(text: placemark.subThoroughfare, separatedBy: "")
You can write:
line1.add(text: placemark.subThoroughfare)
The default value for separator is an empty string. If the separatedBy parameter is left out, separator will be set to "".
➤ Make these changes in CurrentLocationViewController.swift:
func string(from placemark: CLPlacemark) -> String {
. . .
line1.add(text: placemark.subThoroughfare)
. . .
line2.add(text: placemark.locality)
. . .
Where the separator is an empty string, you leave out the separatedBy: "" part of the method call. Note that the other instances of add(text:separatedBy:) in the method don’t have empty strings as the separator but instead, have a space.
Now you have a pretty clean solution that you can re-use in the other two view controllers.
➤ In LocationDetailsViewController.swift, replace the string(from:) code with:
func string(from placemark: CLPlacemark) -> String {
var line = ""
line.add(text: placemark.subThoroughfare)
line.add(text: placemark.thoroughfare, separatedBy: " ")
line.add(text: placemark.locality, separatedBy: ", ")
line.add(text: placemark.administrativeArea, separatedBy: ", ")
line.add(text: placemark.postalCode, separatedBy: " ")
line.add(text: placemark.country, separatedBy: ", ")
return line
}
It’s slightly different from how the main screen does it. There are no newline characters and some of the elements are separated by commas instead of just spaces. Newlines aren’t necessary here because the label will wrap.
The final place where placemarks are shown is LocationsViewController. However, this class doesn’t have a string(from:) method. Instead, the logic for formatting the address lives in LocationCell.
➤ Go to LocationCell.swift. Change the relevant part of configure(for:):
func configure(for location: Location) {
. . .
if let placemark = location.placemark {
var text = ""
text.add(text: placemark.subThoroughfare)
text.add(text: placemark.thoroughfare, separatedBy: " ")
text.add(text: placemark.locality, separatedBy: ", ")
addressLabel.text = text
} else {
. . .
You only show the street and the city, so the conversion is simpler.
And that’s it for placemarks.
Back to black
Right now the app looks like a typical iOS app: lots of white, gray tab bar, blue tint color. Let’s go for a radically different look and paint the whole thing black.
Back in the day, changing the interface black would have required a lot of manual work. You would have had to change the background for the views, change the text color of the labels, and then do some additional work to change table views and so on.
But as of iOS 13, there’s a new way to have a dark interface on — dark mode.
Dark mode is really supposed to be used in conjunction with the operating system itself so that when the user switches the device to dark mode — for example, at night — the interface of your app changes to match the device mode.
However, you can also enable dark mode to be always on in your app and it’s as simple as — mostly — changing one setting :]
➤ Go to the Info tab and add a new row. Set its key to Appearance — you can select this from the available list of keys. The value for this key is a string – set it to Dark.
➤ Build and run your app and voila! Your app now has a fully dark theme!
➤ Check each of the screens in the app — note the instances where everything works correctly, and where things don’t work so well.
Here’s what I noticed:
- The blue tint for buttons and actions doesn’t look so hot against the black background
- The second line of text on the Locations tab’s list of items does not show up at all against the black background.
- The main screen items might need something to make the “pop”.
Let’s fix those.
The tint color
➤ Open the asset catalog and select AccentColor.
You are used to using images from the Asset Catalog, but this is a color value. The special AccentColor value defines the tint color for the whole app.
By default, the AccentColor has no color value set and can be configured for a universal setting so that the color value you specify is used for any appearance – Dark or Light. You can change it to define custom colors based on appearance — dark mode vs. light mode — by changing the Appearance dropdown under the Attributes Inspector.
But since we only have one appearance mode all the time, we won’t do that.
➤ Select AccentColor, click on Universal, and then in the Attributes Inspector, click Show Color Panel under the Color section. Set the color to Red: 255, Green: 238, Blue: 136. That will make the buttons and other interactive elements yellow, which stands out nicely against the black background.
➤ Build and run the app.
Now the blue tint is gone and the buttons and action items look much better with the yellow against the black background.
And that fixes all of the tint color issues in one fell swoop!
Tab bar icons
While we’re at it, let’s also add some icons for the tab bar items. Tab bar images should be basic grayscale images of up to 30 × 30 points — that is 60 × 60 pixels for Retina and 90 × 90 pixels for Retina HD. You don’t have to tint the images; iOS will automatically draw them in the proper color.
➤ The resources for this tutorial include an Images directory. Add the files from this folder to the asset catalog.
➤ Go to the storyboard. Select the Tab Bar Item of the navigation controller embedding the Current Location screen. In the Attributes inspector, under Image choose Tag — this is the name of one of the images you’ve just added.
➤ For the Tab Bar Item of the navigation controller attached to the Locations screen, choose the Locations image.
➤ For the Tab Bar Item of the navigation controller embedding the Map View Controller, choose the Map image.
Now the tab bar looks a lot more appealing:
Storyboard dark mode
Now that you are using dark mode for your user interface, it would be helpful if you could see all your storyboard items in dark mode, wouldn’t it? It’s really easy to do.
➤ Open the storyboard.
➤ Tap the Appearance button on the Interface Builder toolbar once to set the storyboard to use Dark Appearance – if you tap the button again, it toggle the appearance.
Your screens now appear as they would under dark mode. There’s one issue – the storyboard does not show the yellow tint color that we set via the Asset Catalog. But, there’s a very easy fix.
Select the File Inspector while the storyboard is open and set the Global Tint to the yellow color you set in the Asset Catalog earlier – you can even select the AccentColor value straight from the color dropdown.
The Address label
You can now see which labels display correctly under dark mode and which don’t.
The Address label in the Location scene’s prototype cell does not display correctly. This is because we set a custom text color for this label.
Here’s the secret – for dark mode to work correctly, you need the correct colors set on your interface elements. By default, labels have their text color set to Default (Label Color). This is a special setting that iOS understands and which is required for dark mode to work.
When the device (and subsequently the app) is in light mode, the Default (Label Color) is black and so labels display black color against a white background. When the device switches to dark mode, then the system automatically switches the label color to white so that the label will still display correctly.
However, if you change the label color to a custom value, then iOS does not know what the dark or light mode variants for your custom color should be. So it will display your label with just the value you set.
So let’s say you want to have your own custom color which switches between dark mode and light mode. How do you do that?
Remember how you set the tint color using the special AccentColor? Similar to that, you can create your own colors that you can add to the Asset Catalog. And since colors in the Asset Catalog can have dark and light variants, if you set up a color with these variants and then assign that color to any of your UI elements, they’ll switch colors depending on the appearance.
We aren’t going to do all that though. MyLocations will permanently be displaying in dark mode. So we don’t need a light mode variant for the Address label.
➤ Select the Address label on the Locations scene. and set its Label - Color and Label - Highlighted color to white with 60% Opacity.
That’s it :]
Make the main screen pop
➤ In the Current Location scene, change the Font of the (Latitude/Longitude goes here) labels to System Bold 17.
➤ Select the two buttons and change their Style to Default.
➤ Then, change their Font to System Bold 20, to make them slightly larger.
➤ Select the Get My Location button and change its Text Color to White Color. This provides some contrast between the two buttons.
The storyboard should now look like this:
In previous iOS versions, when you ran the app, there is one minor issue that you might notice. The splash screen for the app, which used to be just a blank white screen, is now a blank black screen. But for the second that the splash screen displays, you don’t see the status bar on the screen because it is black text on a black background. This is no longer the case with iOS 18, but just in case this is useful to you, let us go into the details behind this.
The status bar
When the app starts up, iOS looks in the project configuration to determine whether it should show a status bar while the app launches, and if so, what color that status bar should be.
Right now, it’s set to Default. In previous versions of iOS, this meant that you got the black status bar. However, with iOS 18, this does not appear to be the case and the status bar displays correctly when you switch to dark mode.
But in case you want to change the status bar appearance, here’s how you do it:
➤ Go to the Project Settings screen. In the General tab, under Deployment Info is a Status Bar Style option. You can change this to Light Content to show the light/white status bar.
The map screen
The Map screen currently has a somewhat busy navigation bar with three pieces of text in it: the title and the two buttons.
The design advice that Apple gives is to prefer text to icons because icons tend to be harder to understand. The disadvantage of using text is that it makes your navigation bar more crowded.
There are two possible solutions:
-
Remove the title. If the purpose of the screen is obvious, which it is in this case, then the title “Map” is superfluous. You might as well remove it.
-
Keep the title but replace the button labels with icons.
For this app, you’ll choose the second option.
➤ Go to the Map scene in the storyboard and select the Locations bar button item. In the Attributes inspector, under Image choose Pin. This will remove the text from the button.
➤ For the User bar button item, choose the User image.
The Map screen now looks like this:
Notice that the dot for the user’s current location is drawn in the yellow tint color – it was a blue dot before.
UI updates to screens
The app is starting to shape up, but there are still some details to take care of for the following screens:
- Locations screen
- Tag Location screen
The Locations screen
The section headers on the Locations screen are a bit on the heavy side. There is no easy way to customize the existing headers, but you can replace them with a view of your own.
➤ Go to LocationsViewController.swift and add the following table view delegate method:
override func tableView(
_ tableView: UITableView,
viewForHeaderInSection section: Int
) -> UIView? {
let labelRect = CGRect(
x: 15,
y: tableView.sectionHeaderHeight - 14,
width: 300,
height: 14)
let label = UILabel(frame: labelRect)
label.font = UIFont.boldSystemFont(ofSize: 11)
label.text = tableView.dataSource!.tableView!(
tableView,
titleForHeaderInSection: section)
label.textColor = UIColor(white: 1.0, alpha: 0.6)
label.backgroundColor = UIColor.clear
let separatorRect = CGRect(
x: 15, y: tableView.sectionHeaderHeight - 0.5,
width: tableView.bounds.size.width - 15,
height: 0.5)
let separator = UIView(frame: separatorRect)
separator.backgroundColor = tableView.separatorColor
let viewRect = CGRect(
x: 0, y: 0,
width: tableView.bounds.size.width,
height: tableView.sectionHeaderHeight)
let view = UIView(frame: viewRect)
view.backgroundColor = UIColor(white: 0, alpha: 0.85)
view.addSubview(label)
view.addSubview(separator)
return view
}
This method gets called once for each section in the table view. Here, you create a label for the section name, a 1-pixel high view that functions as a separator line, and a container view to hold these two subviews.
It looks like this:
Note: Did you notice anything special about the following line?
label.text = tableView.dataSource!.tableView!(tableView, titleForHeaderInSection: section)This asks the table view’s data source for the text to put in the header. The
dataSourceproperty is an optional so you’re using!to unwrap it. But that’s not the only!in this line…
You’re calling the
tableView(_:titleForHeaderInSection:)method on the table view’s data source, which is of course theLocationsViewControlleritself.But this method is an optional method — not all data sources need to implement it. Because of that you have to unwrap the method with the exclamation mark in order to use it. Unwrapping methods… does it get any crazier than that?
By the way, you can also write this as:
label.text = self.tableView(tableView, titleForHeaderInSection: section)Here you use
selfto directly access that method onLocationsViewController. Both ways achieve exactly the same thing, since the view controller happens to be the table view’s data source.
Another small improvement you can make is to always put the section headers in uppercase.
➤ Change tableView(_:titleForHeaderInSection:) to:
override func tableView(
_ tableView: UITableView,
titleForHeaderInSection section: Int
) -> String? {
let sectionInfo = fetchedResultsController.sections![section]
return sectionInfo.name.uppercased()
}
Now the section headers look even better:
Currently, if a location does not have a photo, there is a black gap where the thumbnail is supposed to be. That doesn’t look very professional. It’s better to show a placeholder image. You already added one to the asset catalog when you imported the Images folder.
➤ In LocationCell.swift’s thumbnail(for:), replace the last line that returns an empty UIImage with:
return UIImage(named: "No Photo")!
Recall that UIImage(named:) is a failable initializer, so it returns an optional. Don’t forget the exclamation point at the end to unwrap the optional.
Now locations without photos appear like so:
That makes it a lot clearer to the user that the photo is missing – as opposed to, say, being a photo of a black hole. The placeholder image is round.
That’s the fashion for thumbnail images on iOS these days, and it’s pretty easy to make the other thumbnails rounded too.
➤ Still in LocationCell.swift, add the following lines to the end of awakeFromNib():
// Rounded corners for images
photoImageView.layer.cornerRadius = photoImageView.bounds.size.width / 2
photoImageView.clipsToBounds = true
separatorInset = UIEdgeInsets(top: 0, left: 82, bottom: 0, right: 0)
This gives the image view rounded corners with a radius that is equal to half the width of the image, which makes it a perfect circle.
The clipsToBounds setting makes sure that the image view respects these rounded corners and does not draw outside them.
The separatorInset moves the separator lines between the cells a bit to the right so there are no lines between the thumbnail images.
Note: As you’ll notice from the above image, the rounded thumbnails don’t look very good if the original photo isn’t square. You may want to change the Mode of the image view to Aspect Fill or Scale to Fill so that the thumbnail always fills up the entire image view.
The Tag Location screen
➤ Open the storyboard and go to the Tag Location scene.
➤ Select the detail label from all the cells with the Right Detail style and set their Label - Color and Label - Highlighted color to white with 60% Opacity.
➤ Select the Address detail label and set its Label - Color and Label - Highlighted color to white with 60% Opacity.
➤ Run the app. The Tag Location screen should now look like this:
Polish the main screen
I’m pretty happy with all the other screens, but the main screen needs a bit more work to be presentable.
Here’s what you’ll do:
- Show a logo when the app starts up. Normally, such splash screens are bad for the user experience, but here I think we can get away with it.
- Make the logo disappear with an animation when the user taps Get My Location.
- While the app is fetching the coordinates, show an animated activity spinner to make it even clearer to the user that something is going on.
- Hide the Latitude: and Longitude: labels until the app has found coordinates.
You will first hide the text labels from the screen until the app actually has some coordinates to display. The only label that will be visible until then is the one at the top and it will say “Searching…” or give some kind of error message.
In order to do this, you must have outlets for the labels.
➤ Add the following properties to CurrentLocationViewController.swift:
@IBOutlet weak var latitudeTextLabel: UILabel!
@IBOutlet weak var longitudeTextLabel: UILabel!
You’ll put the logic for updating these labels in a single place, updateLabels(), so that hiding and showing them is pretty straightforward.
➤ Change updateLabels() in CurrentLocationViewController.swift:
func updateLabels() {
if let location = location {
. . .
latitudeTextLabel.isHidden = false
longitudeTextLabel.isHidden = false
} else {
. . .
latitudeTextLabel.isHidden = true
longitudeTextLabel.isHidden = true
}
}
➤ Connect the Latitude: and Longitude: labels in the storyboard to the latitudeTextLabel and longitudeTextLabel outlets.
➤ Run the app and verify that the Latitude: and Longitude: labels only appear when you have obtained GPS coordinates.
The first impression
The main screen looks decent and is completely functional, but it could do with more pizzazz. It lacks the “Wow!” factor. You want to impress users the first time they start your app and keep them coming back. To pull this off, you’ll add a logo and a cool animation. When the user hasn’t yet pressed the Get My Location button, there are no GPS coordinates and the Tag Location button is hidden. Instead of showing a completely blank upper panel, you can show a large version of the app’s icon.
When the user taps the Get My Location button, the icon rolls out of the screen — it’s round so that kinda makes sense — while a panel with the GPS status will slide in.
This is pretty easy to program thanks to the power of Core Animation and it makes the app a whole lot more impressive for first-time users.
First, you need to move the labels into a new container subview.
➤ Open the storyboard and go to the Current Location View Controller. In the Document Outline, select the six labels and the Tag Location button. With these seven views selected, choose Editor ▸ Embed In ▸ View Without Inset from the Xcode menu bar.
This creates a new UIView and puts these labels and the button inside that new view.
The layout of the screen hasn’t changed; you have simply reorganized the view hierarchy so that you can easily manipulate and animate this group of views as a whole. Grouping views in a container view is a common technique for building complex layouts.
➤ To avoid problems on smaller screens, make sure that the Get My Location button sits higher up in the view hierarchy than the container view. If the button sits under another view you cannot tap it anymore.
Non-intuitively, in the Document Outline, the button must sit below the container view. If it doesn’t, drag to rearrange:
Note: When you drag the Get My Location button, make sure you’re not dropping it into the container view. The view you just added and the Get My Location button should sit at the same level in the view hierarchy.
When you embedded the six labels and the button in the container view, the Auto Layout constraints that those seven controls had to the main view were broken. Makes sense, right? Because those controls are now inside a different view.
We have to fix a few Auto Layout constraints so that the controls are laid out correctly within the container view.
➤ Select the Container View and set its Auto Layout constraints as follows: left=16, top=16, and right=16.
➤ Select the (Message Label) at the top and set its Auto Layout Constraints to: left=0, top=0, and right=0.
➤ Select the Latitude:, Longitude:, and (Address goes here) labels and set their Auto Layout Constraints to: left=0.
➤ Select the (Latitude goes here), (Longitude goes here), and (Address goes here) labels and set their Auto Layout Constraints to: right=0.
➤ Finally, set the Tag Location button’s Auto Layout Constraints to: left=0, bottom=0, and right=0.
➤ Add the following outlet to CurrentLocationViewController.swift:
@IBOutlet weak var containerView: UIView!
➤ In the storyboard, connect the new container UIView to the containerView outlet.
Now on to the good stuff!
➤ Add the following instance variables to CurrentLocationViewController.swift:
var logoVisible = false
lazy var logoButton: UIButton = {
let button = UIButton(type: .custom)
button.setBackgroundImage(
UIImage(named: "Logo"), for: .normal)
button.sizeToFit()
button.addTarget(
self, action: #selector(getLocation), for: .touchUpInside)
button.center.x = self.view.bounds.midX
button.center.y = 220
return button
}()
The logo image is actually a button, so that you can tap the logo to get started. The app will show this button when it starts up, and when it doesn’t have anything better to display — for example, after you press Stop and there are no coordinates and no error. To orchestrate this, you’ll use the boolean logoVisible.
The button is a “custom” type UIButton, meaning that it has no title text or other frills. It draws the Logo.png image and calls the getLocation() method when tapped.
This is another one of those lazily loaded properties; I did that because it’s nice to keep all the initialization logic inline with the declaration of the property.
➤ Add the following method:
func showLogoView() {
if !logoVisible {
logoVisible = true
containerView.isHidden = true
view.addSubview(logoButton)
}
}
This hides the container view so the labels disappear, and puts the logoButton object on the screen. This is the first time logoButton is accessed, so at this point the lazy loading kicks in.
➤ In updateLabels(), change the line that says,
statusMessage = "Tap 'Get My Location' to Start"
to:
statusMessage = ""
showLogoView()
This new logic makes the logo appear when there are no coordinates or error messages to display. That’s also the state at startup time, so when you run the app now, you should be greeted by the logo.
➤ Run the app to check it out.
When you tap the logo (or Get My Location), the logo should disappear and the panel with the labels ought to show up. That doesn’t happen yet, so let’s add some more code to do that.
➤ Add the following method:
func hideLogoView() {
logoVisible = false
containerView.isHidden = false
logoButton.removeFromSuperview()
}
This is the counterpart to showLogoView(). For now, it simply removes the button with the logo and un-hides the container view with the GPS coordinates.
➤ Add the following to getLocation(), right after the authorization status checks:
if logoVisible {
hideLogoView()
}
Before it starts/stops the location manager, this first removes the logo from the screen if it was visible.
Currently, there is no animation code to be seen. When doing complicated layout stuff such as this, I always first want to make sure the basics work. If they do, you can make it look fancy with an animation afterwards.
➤ Run the app. You should see the screen with the logo. Press the Get My Location button and the logo is replaced by the coordinate labels.
Great! Now you can add the animation. The only method you have to change is hideLogoView().
➤ First, give CurrentLocationViewController the ability to handle animation events by making it a CAAnimationDelegate:
class CurrentLocationViewController: UIViewController, CLLocationManagerDelegate, CAAnimationDelegate {
➤ Then replace hideLogoView() with:
func hideLogoView() {
if !logoVisible { return }
logoVisible = false
containerView.isHidden = false
containerView.center.x = view.bounds.size.width * 2
containerView.center.y = 40 + containerView.bounds.size.height / 2
let centerX = view.bounds.midX
let panelMover = CABasicAnimation(keyPath: "position")
panelMover.isRemovedOnCompletion = false
panelMover.fillMode = CAMediaTimingFillMode.forwards
panelMover.duration = 0.6
panelMover.fromValue = NSValue(cgPoint: containerView.center)
panelMover.toValue = NSValue(
cgPoint: CGPoint(x: centerX, y: containerView.center.y))
panelMover.timingFunction = CAMediaTimingFunction(
name: CAMediaTimingFunctionName.easeOut)
panelMover.delegate = self
containerView.layer.add(panelMover, forKey: "panelMover")
let logoMover = CABasicAnimation(keyPath: "position")
logoMover.isRemovedOnCompletion = false
logoMover.fillMode = CAMediaTimingFillMode.forwards
logoMover.duration = 0.5
logoMover.fromValue = NSValue(cgPoint: logoButton.center)
logoMover.toValue = NSValue(
cgPoint: CGPoint(x: -centerX, y: logoButton.center.y))
logoMover.timingFunction = CAMediaTimingFunction(
name: CAMediaTimingFunctionName.easeIn)
logoButton.layer.add(logoMover, forKey: "logoMover")
let logoRotator = CABasicAnimation(
keyPath: "transform.rotation.z")
logoRotator.isRemovedOnCompletion = false
logoRotator.fillMode = CAMediaTimingFillMode.forwards
logoRotator.duration = 0.5
logoRotator.fromValue = 0.0
logoRotator.toValue = -2 * Double.pi
logoRotator.timingFunction = CAMediaTimingFunction(
name: CAMediaTimingFunctionName.easeIn)
logoButton.layer.add(logoRotator, forKey: "logoRotator")
}
This creates three animations that are played at the same time:
- The
containerViewis placed outside the screen (somewhere on the right) and moved to the center. - The logo image view slides out of the screen.
- The logo image also rotates around its center, giving the impression that it’s rolling away.
Because the “panelMover” animation takes longest, you set a delegate on it so that you will be notified when the entire animation is over.
➤ Now add the necessary CAAnimationDelegate method:
// MARK: - Animation Delegate Methods
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
containerView.layer.removeAllAnimations()
containerView.center.x = view.bounds.size.width / 2
containerView.center.y = 40 + containerView.bounds.size.height / 2
logoButton.layer.removeAllAnimations()
logoButton.removeFromSuperview()
}
This cleans up after the animations and removes the logo button, as you no longer need it.
➤ Run the app. Tap on Get My Location to make the logo disappear. I think the animation looks pretty cool.
Tip: To get the logo back so you can try again, first choose Location ▸ None from the Simulator’s Features menu. Then tap Get My Location followed by Stop to make the logo reappear. Apple says that good apps should “surprise and delight”, and modest animations such as these really make your apps more interesting to use — as long as you don’t overdo it!
Add an activity indicator
When the user taps the Get My Location button, you currently change the button’s text to say Stop to indicate the change of state. You can make it even clearer to the user that something is going on by adding an animated activity “spinner”.
It will look like this:
UIKit comes with a standard control for this, UIActivityIndicatorView. You could add the spinner to the storyboard — and that’s the way I generally prefer to do things. However, it’s good to learn different techniques and so you’ll create the spinner in code this time. The code to change the appearance of the Get My Location button sits in the configureGetButton() method. That’s also a good place to show and hide the spinner.
➤ Replace configureGetButton() with the following:
func configureGetButton() {
let spinnerTag = 1000
if updatingLocation {
getButton.setTitle("Stop", for: .normal)
if view.viewWithTag(spinnerTag) == nil {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.center = messageLabel.center
spinner.center.y += spinner.bounds.size.height / 2 + 25
spinner.startAnimating()
spinner.tag = spinnerTag
containerView.addSubview(spinner)
}
} else {
getButton.setTitle("Get My Location", for: .normal)
if let spinner = view.viewWithTag(spinnerTag) {
spinner.removeFromSuperview()
}
}
}
In addition to changing the button text to “Stop”, you create a new instance of UIActivityIndicatorView. Then you do some calculations to position the spinner view below the message label at the top of the screen. The call to addSubview() actually adds the spinner to the container view and makes it visible.
To keep track of this spinner view, you give it a tag of 1000. You could use an instance variable but this is just as easy and it keeps everything local to the configureGetButton() method. It’s nice to have everything in one place.
When it’s time to revert the button to its old state, you call removeFromSuperview() to remove the activity indicator view from the screen.
And that’s all you need to do.
➤ Run the app. There should now be a cool little animation while the app is busy talking to the GPS satellites.
Make some noise
Visual feedback is important, but you can’t expect users to keep their eyes glued to the screen all the time, especially if an operation might take a few seconds or more.
Emitting an unobtrusive sound is a good way to alert the user that a task is complete — for example, when your iPhone sends an email, you hear a soft “whoosh” sound.
You’re going to add a sound effect to the app too, which is to be played when the first reverse geocoding successfully completes. That seems like a reasonable moment to alert the user that GPS and address information has been captured.
There are many ways to play sounds on iOS, but you’re going to use one of the simplest: system sounds. The System Sound API is intended for short beeps and other notification sounds, which is exactly the type of sound that you want to play here.
➤ Add an import for AudioToolbox, the framework for playing system sounds, to the top of CurrentLocationViewController.swift:
import AudioToolbox
➤ Add a soundID instance variable:
var soundID: SystemSoundID = 0
Because writing just 0 would normally give you a variable of type Int, you explicitly mention the type that you want it to be: SystemSoundID. This is a numeric identifier — sometimes called a “handle” — that refers to a system sound object. 0 means no sound has been loaded yet.
➤ Add the following methods to the class:
// MARK: - Sound effects
func loadSoundEffect(_ name: String) {
if let path = Bundle.main.path(forResource: name, ofType: nil) {
let fileURL = URL(fileURLWithPath: path, isDirectory: false)
let error = AudioServicesCreateSystemSoundID(fileURL as CFURL, &soundID)
if error != kAudioServicesNoError {
print("Error code \(error) loading sound: \(path)")
}
}
}
func unloadSoundEffect() {
AudioServicesDisposeSystemSoundID(soundID)
soundID = 0
}
func playSoundEffect() {
AudioServicesPlaySystemSound(soundID)
}
The loadSoundEffect() method loads the sound file and puts it into a new sound object. The specifics don’t really matter, but you end up with a reference to that object in the soundID instance variable.
➤ Call loadSoundEffect() from viewDidLoad():
loadSoundEffect("Sound.caf")
➤ In locationManager(_:didUpdateLocations:), in the geocoder’s completion closure, change the following code:
if error == nil, let places = placemarks, !places.isEmpty {
// New code block
if self.placemark == nil {
print("FIRST TIME!")
self.playSoundEffect()
}
// End new code
self.placemark = places.last!
} else {
. . .
The new if statement simply checks whether the self.placemark instance variable is nil, in which case this is the first time you’ve reverse geocoded an address. It then plays a sound using the playSoundEffect() method. Of course, you shouldn’t forget to add the actual sound effect to the project!
➤ Add the Sound folder from this app’s Resources to the project. Make sure Copy files to destination is selected before completing the operation.
➤ Run the app and see if it makes some noise. The sound should only be played for the first address it finds — when you see the FIRST TIME! log message — even if more precise locations keep coming in afterwards.
Note: If you don’t hear the sound on the Simulator, try the app on a device. Sometimes system sounds will not play on the simulators.
CAF audio files
The Sound folder contains a single file, Sound.caf. The caf extension stands for Core Audio Format, and it’s the preferred file format for these kinds of short audio files on iOS.
If you want to use your own sound file but it is in a different format than CAF and your audio software can’t save CAF files, then you can use the
afconvertutility to convert the audio file. You need to run it from the Terminal:
$ /usr/bin/afconvert -f caff -d LEI16 Sound.wav Sound.cafThis converts the Sound.wav file into Sound.caf. You don’t need to do this for the audio file from this app’s Sound folder because that file is already in the correct format. But if you want to experiment with your own audio files, then knowing how to use
afconvertmight be useful.By the way, iOS can play .wav files just fine, but .caf is more optimal.
The icon and launch images
The Resources folder for this app contains an Icon folder with the app icon.
➤ Import the icon image into the asset catalog — you can simply drag them from Finder into the AppIcon group into the Any Appearnace slot.
The app currently also has a launch file, LaunchScreen.storyboard, that provides the splash image for when the app is still loading. We’ll modify it quickly to fit in with the look and feel of the rest of the app.
➤ Open LaunchScreen.storyboard and change the Appearance to Dark Appearance.
➤ Also switch the view to iPhone SE (3rd Generation) since that’s what we’ve been designing the other screens for.
➤ Add an Image View from the Library and set its Image property in the Attributes Inspector to Logo. This puts the logo image from the main screen on the splash screen as well.
➤ Set the new image view to align Horizontally in Container in its parent view using the Auto Layout Align menu.
➤ Set the image view’s top alignment to be 100 points.
➤ The Resources folder for this app contains a Launch Images folder. Add the tabbar@2x.png image it contains to the Asset Catalog.
➤ Add another Image View from the Library and set its Image property in the Attributes Inspector to tabbar. This adds an image of the app’s tab bar.
➤ With the new image view selected, use Editor ▸ Size to Fit Content to size the image view to fit the image.
➤ Set the Auto Layout constraints for the new image as: left=0, right=0, bottom=0.
Your LaunchScreen.storyboard should now look like this:
The launch screen only shows the tab bar and the logo button, but no status bar or any buttons. The reason it has no “Get My Location” button is that you don’t want users to try and tap it while the app is still loading since it’s not really a button!
There’s one tiny issue that might not be evident at this point.
➤ Switch to an iPhone 16 Pro Max via the IB toolbar.
You will see that the tab bar image does not cover the width of the screen – it might be easier to see if you toggle the Appearance to Light Mode.
This is due to our old friend, the Content Mode setting for the image.
➤ Change the Content Mode for the image in the Attributes Inspector to Aspect Fill.
Done. That was easy. :] And with that, MyLocations is complete! Woohoo!
You can find the final project files for the app under 31-Polishing-the-app in the Source Code folder.
The end
Congrats on making it this far! It has been a long and winding road with a lot of theory to boot. I hope you learned a lot of useful stuff.
The final storyboard for MyLocations looks like this:
In this section you took a more detailed look at Swift, but there’s still plenty to discover. To learn more about the Swift programming language, I recommend that you read the following books:
-
The Swift Programming Language by Apple. This is a free download on the iBooks Store. If you don’t want to read the whole thing, at least take the Swift tour. It’s a great introduction to the language.
-
Swift Apprentice: Fundamentals and Swift Apprentice: Beyond the Basics by Team Kodeco. These books teach you everything you need to know about Swift, from beginning to advanced topics. These are sister books to the UIKit Apprentice; the UIKit Apprentice focuses more on making apps, while the Swift Apprentice books focus more on the Swift language itself. https://www.kodeco.com/books/swift-apprentice-fundamentals and https://www.kodeco.com/books/swift-apprentice-beyond-the-basics
There are several good Core Data beginner books on the market. Here are two recommendations:
-
Core Data by Tutorials by Team Kodeco. One of the few Core Data books that is completely up-to-date with the latest iOS and Swift versions. This book is for intermediate iOS developers who already know the basics of iOS and Swift development, but want to learn how to use Core Data to save data in their apps. https://www.kodeco.com/books/core-data-by-tutorials.
-
Core Data Programming Guide by Apple. If you want to get into the nitty gritty, then Apple’s official guide is a must-read. You can learn a ton from this guide. apple.co/2wNgiRu.
Credits for this tutorial:
-
Sound effect based on a flute sample by elmomo, downloaded from The Freesound Project (freesound.org).
-
Image resizing category is based on code by Trevor Harmon (http://vocaro.com/trevor/blog/2009/10/12/resize-a-uiimage-the-right-way/).
-
HudView code is based on MBProgressHud by Matej Bukovinski (github.com/matej/MBProgressHUD).
Are you ready for the final app? Then continue on to the next chapter, where you’ll make an app that communicates with a web service over the network!