42.
The iPad
Written by Eli Ganim
Even though the apps you’ve written so far will work fine on the iPad, they are not optimized for the iPad. There isn’t much difference between the iPhone and the iPad. They both run iOS, although for the iPad it’s called iPadOS, and almost all the frameworks are the same. But the iPad has a much bigger screen — 768×1024 points for the regular iPad, 834x1112 points for the 10.5-inch iPad Pro, 1024×1366 points for the 12.9-inch iPad Pro — and that makes all the difference.
Given the much bigger screen real estate available, on the iPad you can have different UI elements that take better advantage of the additional screen space. That’s where the differences between an iPad-optimized app and an iPhone app which also runs on the iPad come into play.
In this chapter, you will cover the following:
-
Universal apps: A brief explanation of universal apps and how to switch from universal mode to support a specific platform only.
-
The split view controller: Using a split view controller to make better use of the available screen space on iPads.
-
Improve the detail pane: Re-using the Detail screen from the iPhone version, with some adjustments, to display detail information on iPad.
-
Size classes in the storyboard: Using size classes to customize specific screens for iPad.
-
Your own popover: Create a menu popover to be displayed on the iPad.
-
Send e-mail from the app: Send a support e-mail from within the app using the iOS e-mail functionality.
-
Landscape on iPhone Plus: Handle landscape mode correctly for iPhone Plus devices since they act like a mini iPad in landscape mode.
-
Dark Mode support: Support dark mode if the user chooses to activate it.
Universal apps
All new apps you create with Xcode are universal apps by default. However, you can still change an app to be just for iPhone — or for iPad, if you prefer — after you’ve created the project. You will not be doing that for StoreSearch. However, in case you want to know how to change your app from a universal app to one which supports a particular platform, here’s how you do it:
➤ Go to the Project Settings screen and select the StoreSearch target.
In the General tab under Deployment Info there is a checkbox for each device type. Both iPhone and iPad should be selected by default. That’s where you want it to be. But, if you wanted to, you could uncheck iPad.
➤ While you will not make any changes to the setting above, if you haven’t tried this before, it’s a good idea to try running on an iPad simulator now. Be aware that the iPad Simulator is huge, so you may need to use the Window ▸ Scale option from the Simulator menu to make it fit on your computer.
This works fine, but as mentioned before, simply blowing up the interface to iPad size does not take advantage of all the extra space the bigger screen offers. Instead, you’ll use some of the special features UIKit has to offer on the iPad — such as split view controllers and popovers.
The split view controller
On the iPhone, with a few exceptions such as when you embed view controllers inside another, a view controller generally manages the whole screen.
On the iPad, because the display is so much bigger, it is common for view controllers to manage just a section of the screen. Often, you will want to combine different types of content on the same screen.
A excellent example of this is the split view controller. It has two panes: A smaller on the left — the “master” pane — usually containing a list of items, and a larger right pane — the “detail” pane — showing more information about the thing you have selected in the master list. Each pane has its own view controller.
If you’ve used an iPad before, then you’ve seen the split view controller in action. It’s used in many standard apps such as Mail and Settings.
If the iPad is in landscape mode, the split view controller has enough room to show both panes at the same time. However, in portrait mode, only the detail view controller is visible, and the app provides a button that will slide the master pane into view. Or, you can swipe the screen to reveal/hide it.
In this section, you’ll convert the app to use a split view controller. This has some consequences for the organization of the user interface.
Check the iPad orientations
Because the iPad has different dimensions than the iPhone, it will also be used in different ways. Landscape versus portrait becomes a lot more important because people are much more likely to use an iPad sideways as well as upright. Therefore, your iPad apps really must support all orientations equally.
This implies that an iPad app shouldn’t make landscape show a completely different UI than portrait. So, what you did with the iPhone version of the app won’t fly on the iPad — you can no longer show the LandscapeViewController when the user rotates the device. That feature goes out of the window.
➤ Open Info.plist. There will be a Supported interface orientations item with three items under it, and a Supported interface orientations (iPad) item with four items under it.
The iPad has its own supported orientations. On the iPhone, you usually don’t want to enable Upside Down, but on the iPad you do. If the settings do not correspond to the above, make sure to change them to match the screenshot.
Next, run the app on the iPad simulator and verify that the app always rotates so that the search bar is on top, no matter what orientation you put the iPad in.
Now, let’s put that split view controller into the app.
Add a split view controller
On the latest Xcode versions, you can simply add a split view controller object to the storyboard. The split view is only visible on the iPad. On the iPhone, it stays hidden. This is a lot simpler than in previous iOS versions where you had to make two different storyboard files, one for the iPhone and one for the iPad. Now you just design your entire UI in a single storyboard, and it magically works across all device types.
➤ Open Main.storyboard. If you are still in landscape mode, switch back to portrait mode now.
➤ Drag a new split view controller on to the canvas.
➤ The split view controller comes with several scenes pre-attached. Remove the white View Controller. Also, remove the one that says Root View Controller. Keep just the Master View Controller and the Navigation Controller.
Here’s how the final result should look:
A split view controller has a relationship segue with two child view controllers, one for the smaller master pane on the left and one for the bigger detail pane on the right.
The obvious candidate for the master pane is the SearchViewController, and the DetailViewController will go — where else? — into the detail pane.
➤ Control-drag from the split view controller to the Search scene. Choose Relationship Segue – master view controller.
This puts a new arrow between the split view and the Search screen. This arrow used to be connected to the navigation controller.
You won’t put the detail view controller directly into the split view’s detail pane. It’s better to wrap it inside a navigation controller first. That is necessary for portrait mode where you need a button to slide the master pane into view. What better place for this button than a navigation bar?
➤ Control-drag from the split view controller to the navigation controller. Choose Relationship Segue – detail view controller.
➤ Control-drag from the navigation controller to the detail view controller. Make this a Relationship Segue – root view controller.
The split view must become the initial view controller, so it gets loaded by the storyboard first.
➤ Pick up the arrow that currently points to the Search scene — tap on the arrow to select it first and then drag — and drag it over to the split view controller. You can also check the Is Initial View Controller option in the Attributes inspector for the split view controller instead of dragging the arrow.
Now, everything is connected:
That should be enough to get the app up and running with a split view — albeit with some issues:
It will still take a bit of effort to make everything look good and work well, but this was the first step.
If you play with the app, you’ll notice that it still uses the logic from the iPhone version, and that doesn’t always work so well now that the UI sits in a split view. For example, tapping the price button from the new Detail pane crashes the app.
You’ll fix the app throughout this chapter to make sure it doesn’t do anything funny on the iPad!
Fixing the master pane
The master pane works fine in landscape, but in portrait mode, it’s not visible. You can make it appear by swiping from the left edge of the screen — try it. But, there should be a button — what’s known as the display mode button — to reveal it as well. The split view controller takes care of most of this logic, but you still need to put that button somewhere.
That’s why you put DetailViewController in a navigation controller, so you can add this button — which is a UIBarButtonItem — to its navigation bar.
For the record, it’s not mandatory to use a navigation controller for this. For example, you could also add a toolbar to the DetailViewController or use a different button altogether. But generally, a navigation controller is the easiest way to achieve this.
➤ Add the following properties to AppDelegate.swift, inside the class:
// MARK:- Properties
var splitVC: UISplitViewController {
return window!.rootViewController as! UISplitViewController
}
var searchVC: SearchViewController {
return splitVC.viewControllers.first as! SearchViewController
}
var detailNavController: UINavigationController {
return splitVC.viewControllers.last as! UINavigationController
}
var detailVC: DetailViewController {
return detailNavController.topViewController
as! DetailViewController
}
These four computed properties refer to the various view controllers in the app:
-
splitVC: The top-level view controller. -
searchVC: The Search screen in the master pane of the split view. -
detailNavController: TheUINavigationControllerin the detail pane of the split view. -
detailVC: The Detail screen inside theUINavigationController.
By making properties for these view controllers, you can easily refer to them without having to go digging through the view hierarchy as you did for the previous apps.
➤ Add the following line to application(_:didFinishLaunchingWithOptions:):
detailVC.navigationItem.leftBarButtonItem =
splitVC.displayModeButtonItem
This looks up the Detail screen and puts a button into its navigation item for switching between the split view display modes. Because the DetailViewController is embedded in a UINavigationController, this button will automatically end up in the navigation bar.
If you run the app now, all you get in portrait mode is a back arrow:
It would be better if this back button said “Search.” You can fix that by giving the view controller from the master pane a title.
➤ In SearchViewController.swift, add the following line to viewDidLoad():
title = NSLocalizedString("Search", comment: "split view master button")
Of course, you’re using NSLocalizedString() because this is text that appears to the user. Hint: The Dutch translation is “Zoeken.”
➤ Run the app, and now you should have a proper button for bringing up the master pane in portrait mode:
Exercise: On the iPad, rotating to landscape doesn’t bring up the special landscape view controller anymore. That’s good because we don’t want to use it in the iPad version of the app, but you haven’t changed anything in the code. Can you explain what stops the landscape view from appearing?
Answer: The clue is in SearchViewController’s willTransition(). This shows the landscape view when the new vertical size classes becomes compact. But on the iPad, both the horizontal and vertical size class are always regular, regardless of the device orientation. As a result, nothing happens upon rotation.
Improving the detail pane
The detail pane needs some more work. It just doesn’t look very good yet. Also, tapping a row in the search results should fill in the split view’s detail pane, not bring up a new pop-up.
You’re using DetailViewController for both purposes — pop-up and detail pane. So, let’s give it a Boolean that determines how it should behave. On the iPhone, it will be a pop-up. On the iPad, it will not.
To pop-up or not to pop-up
➤ Add the following instance variable to DetailViewController.swift:
var ispop-up = false
➤ In viewDidLoad() replace the four lines dealing with the gesture recognizer set up and the one setting up the background color, with the following:
if ispop-up {
let gestureRecognizer = UITapGestureRecognizer(target: self,
action: #selector(close))
gestureRecognizer.cancelsTouchesInView = false
gestureRecognizer.delegate = self
view.addGestureRecognizer(gestureRecognizer)
view.backgroundColor = UIColor.clear
} else {
view.backgroundColor = UIColor(patternImage:
UIImage(named: "LandscapeBackground")!)
pop-upView.isHidden = true
}
With the gesture recognizer code inside the if ispop-up check, tapping the background has no effect on the iPad. Likewise for the line that sets the background color to clearColor.
The else branch always hides the pop-up view until a SearchResult is selected in the table view. The background gets a pattern image to make things look a little nicer — it’s the same image you used with the landscape view on the iPhone.
Initially, this means the DetailViewController doesn’t show anything except for the patterned background. So, you need SearchViewController to tell the DetailViewController that a new SearchResult has been selected.
Previously, on an iPhone, SearchViewController created a new instance of DetailViewController every time you tapped a row, but now, on an iPad, it will need to use the existing instance from the split view’s detail pane instead. But how does the SearchViewController know what that instance is?
You will have to give it a reference to the DetailViewController. A good place for that is in AppDelegate where you create those instances.
➤ First, add this new property to SearchViewController.swift:
weak var splitViewDetail: DetailViewController?
Notice that you make this property weak. The SearchViewController isn’t responsible for keeping the DetailViewController alive since that’s the job of the split view controller. It would work fine without weak but specifying it makes the relationship clearer.
The variable is an optional because it will be nil when the app runs on an iPhone.
➤ Add the following line to application(_:didFinishLaunchingWithOptions:) in AppDelegate.swift:
searchVC.splitViewDetail = detailVC
➤ To change what happens when the user taps a search result on the iPad, replace tableView(_:didSelectRowAt:) in SearchViewController.swift with:
func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
searchBar.resignFirstResponder()
if view.window!.rootViewController!.traitCollection
.horizontalSizeClass == .compact {
tableView.deselectRow(at: indexPath, animated: true)
performSegue(withIdentifier: "ShowDetail",
sender: indexPath)
} else {
if case .results(let list) = search.state {
splitViewDetail?.searchResult = list[indexPath.row]
}
}
}
On the iPhone, this still does the same as before — pop up a new Detail screen — but on the iPad, it assigns the SearchResult object to the existing DetailViewController that lives in the detail pane.
Note: To determine whether the app is running on an iPhone, you look at the horizontal size class of the window’s root view controller, which is the
UISplitViewController. On iPhone, the horizontal size class is always compact — well, almost always since there are some exceptions, more about that shortly. On the iPad, it is always regular.The reason you’re looking at the size class from the root view controller and not
SearchViewControlleris that the latter’s size class is always horizontally compact. This is true even on iPad because it sits inside the split view’s master pane.
These changes by themselves don’t update the contents of the labels in the DetailViewController. So, let’s make that happen.
The ideal place to update the labels is in a property observer on the searchResult variable. After all, the user interface needs to be updated right after you put a new SearchResult object into this variable.
➤ Change the declaration of searchResult in DetailViewController.swift:
var searchResult: SearchResult! {
didSet {
if isViewLoaded {
updateUI()
}
}
}
You’ve seen this pattern a few times before. You provide a didSet observer to perform certain functionality when the value of a property changes. After searchResult has changed, you call the updateUI() method to set the text on the labels.
Notice that you first check whether the controller’s view is already loaded. searchResult may be given an object when the DetailViewController hasn’t loaded its view yet — which is exactly what happens in the iPhone version of the app. In that case, you don’t want to call updateUI() as there is no user interface yet to update. The isViewLoaded check ensures this property observer only gets used when on an iPad.
➤ Add the following line to the bottom of updateUI():
pop-upView.isHidden = false
This makes the view visible when on the iPad. Recall that in viewDidLoad() you hid the pop-up because there was nothing to show yet.
➤ Run the app. Now the detail pane should show details about the selected search result. Notice that the row in the table stays selected as well.
Fix the Detail pop-up for iPhone
One small problem: The Detail pop-up no longer works properly on the iPhone because ispop-up is always false — try it.
➤ In prepare(for:sender:) in SearchViewController.swift, add the line:
detailViewController.ispop-up = true
➤ Do the same thing in LandscapeViewController.swift. Verify that the Detail screen works properly in all situations.
Display the app name on Detail pane
It would be nice if the app showed its name in the navigation bar above the detail pane. Currently, all that space seems wasted. Ideally, this would use the localized name of the app.
You could use NSLocalizedString() and put the name into the Localizable.strings files, but considering that you already put the localized app name in InfoPlist.strings it would be handy if you could use that. As it happens, you can.
➤ In DetailViewController.swift, add this line to the else clause in viewDidLoad():
if let displayName = Bundle.main.
localizedInfoDictionary?["CFBundleDisplayName"] as? String {
title = displayName
}
The title property is used by the UINavigationController to put the title text in the navigation bar. You set it to the value of the CFBundleDisplayName setting from the localized version of Info.plist, i.e., the translations from InfoPlist.strings.
Because NSBundle’s localizedInfoDictionary can be nil you need to unwrap it. The value stored under the "CFBundleDisplayName" key may also be nil. And finally, the as? cast to turn the value into a String can also potentially fail. If you’re counting along, that is three things that can go wrong in this single line of code.
That’s why it’s called optional chaining: You can check a chain of optionals in a single statement. If any of them is nil, the code inside the if is skipped. That’s a lot shorter than writing three separate if statements!
If you were to run the app right now, no title would show up because you did not actually put a translation for CFBundleDisplayName in the English version of InfoPlist.strings.
➤ Add the following line to InfoPlist.strings (English):
CFBundleDisplayName = "StoreSearch";
That looks good, but there are a few other small improvements to make.
Removing input focus on iPad
On the iPhone, it made sense to give the search bar the input focus, so the keyboard appeared immediately after launching the app. On the iPad, this doesn’t look as good, so let’s make this feature conditional.
➤ In viewDidLoad() in SearchViewController.swift, enclose the call to becomeFirstResponder() in a condition:
if UIDevice.current.userInterfaceIdiom != .pad {
searchBar.becomeFirstResponder()
}
To figure out whether the app is running on an iPhone or on an iPad, you look at the current userInterfaceIdiom. This is either .pad or .phone — an iPod touch counts as a phone in this case.
Hiding the master pane in portrait mode
In portrait mode, after you tap a search result, the master pane stays visible and obscures about half of the detail pane. It would be better to hide the master pane when the user makes a selection.
➤ Add the following method to SearchViewController.swift:
private func hideMasterPane() {
UIView.animate(withDuration: 0.25, animations: {
self.splitViewController!.preferredDisplayMode =
.primaryHidden
}, completion: { _ in
self.splitViewController!.preferredDisplayMode = .automatic
})
}
Every view controller has a built-in splitViewController property that is non-nil if the view controller is currently inside a UISplitViewController.
You can tell the split view to change its display mode to .primaryHidden to hide the master pane. You do this in an animation block, so the master pane disappears with a smooth animation.
The trick is to restore the preferred display mode to .automatic after the animation completes. Otherwise, the master pane stays hidden even in landscape!
➤ Add the following lines to tableView(_:didSelectRowAt:) in the else clause, right after the if case .results block:
if splitViewController!.displayMode != .allVisible {
hideMasterPane()
}
The .allVisible mode only applies in landscape, so this says, “if the split view is not in landscape, hide the master pane when a row gets tapped.”
➤ Try it out. Put the iPad in portrait, do a search and tap a row. Now the master pane will slide away when you tap a row in the table.
Congrats! You have successfully repurposed the Detail pop-up to also work as the detail pane of a split view controller. Whether this is possible in your own apps depends on how different you want the user interfaces of the iPhone and iPad versions to be.
If you’re lucky, you may be able to use the same view controllers for both versions of the app. Often, though, you might find that the iPad user interface for your app is different enough from the iPhone’s that you have to make all new ones.
The Apple Developer Forums
When I first wrote this chapter, how to hide the master pane was not explained anywhere in the official
UISplitViewControllerdocumentation. Needless to say, I had trouble getting it to work properly.Desperate, I turned to the Apple Developer Forums and asked my question there. Within a few hours, I received a reply from a fellow developer who ran into the same problem and who found a solution — thanks, user “timac”!
So, if you’re stuck, don’t forget to look at the Apple Developer Forums for a solution: https://forums.developer.apple.com
Size classes in the storyboard
Even though you’ve placed the existing DetailViewController in the detail pane, the app is not using all that extra space on an iPad effectively. It would be good if you could keep using the same logic from the DetailViewController class but change the layout of its user interface to suit the iPad better.
If you like suffering, you could do if UIDevice.current.userInterfaceIdiom == .pad in viewDidLoad() and move all the labels around programmatically. But there is a better way. This is exactly the sort of thing size classes were invented for!
➤ Open Main.storyboard and take a look at the View as: pane.
Notice how it says iPhone 8 (wC hR)? The wC and hR are the size class for this particular device: The size class for the width is compact (wC), and the size class for the height is regular (hR).
Recall that there are two possible size classes: Compact and regular. You can assign one of these values to the horizontal axis (width) and one to the vertical axis (height).
Here is the diagram again:
➤ Use the View as pane to switch to iPad Pro (9.7″). Not only are the view controllers larger now, but you’ll see the size class has changed to wR hR, or regular in both width and height.
We want to make the Detail pop-up bigger when the app runs on the iPad. However, if you make any edits to the storyboard right now, these edits will also affect the design of the app in iPhone mode. Fortunately, there is a way to make edits that apply to a specific size class only.
You can tell Interface Builder that you only want to change the layout for the regular width size class (wR), but leave compact width alone (wC). Now those edits will only affect the appearance of the app on the iPad.
Uninstalling an item for a specific size class
The Detail pane doesn’t need a close button on the iPad. It is not a pop-up, so there’s no reason to dismiss it. Let’s remove that button from the storyboard.
➤ Select the Close Button. Go to the Attributes inspector and scroll to the bottom, to the Installed option.
This option lets you remove a view from a specific size class while leaving it visible in other size classes.
➤ Click the tiny + button to the left of Installed. This brings up a menu. Choose Width: Regular, Height: Regular and click on Add Variation:
This adds a new line with a second Installed checkbox:
➤ Uncheck Installed for wR hR. Now the Close button disappears from the scene — if the storyboard is in View as: iPad mode, of course.
The Close button still exists, but it is not installed in this size class. You can still see the button in the Document Outline, but it is grayed out:
➤ Use the View as: panel to switch back to iPhone 8.
Notice how the Close button is back in its original position. You’ve only removed it from the storyboard design for the iPad. That’s the power of size classes!
➤ Run the app, and you’ll see that the Close button really is gone on the iPad:
Change the storyboard layout for a given size class
Using the same principle as above, you can change the layout of the Detail screen to be completely different between the iPhone and iPad versions. For example, you can change the Detail pop-up to be bigger on an iPad.
➤ In the storyboard, switch to the iPad Pro layout again.
➤ Select the Pop-up View and go to the Size inspector. The Constraints section shows the constraints for this view:
The Width Equals: 240 constraint has an Edit button. If you click that, a pop-up appears that lets you change the width. However, that will change this constraint for all size classes. You want to change it for the iPad only. So, do the following.
➤ Double-click Width Equals: 240. This brings up the Size inspector for just that constraint:
At this point, if you just type in a new value for Constant, the constraint will become larger for all size classes again.
➤ Click the + button next to Constant. In the pop-up choose Width: Regular, Height: Regular and click Add Variation. This adds a second row. Type 500 into the new wR hR field.
Now the pop-up view is a lot wider. Next up, you’ll rearrange and resize the labels to take advantage of the extra space.
➤ In the same way, change the Width and Height constraints of the Image View to 180.
➤ Select the Vertical Space constraint between the Name label and the Image View and go to its Size inspector. Add a new variation for Constant and type 28 into the wR hR field.
➤ Repeat this procedure for the other Vertical Space constraints. Each time use the + button to add a new rule for Width: Regular, Height: Regular and make the new Constant 20 points taller than the existing value.
Remember, if the constraints are difficult to pinpoint, then select the view they’re attached to instead and use the Size inspector to find the actual constraints.
➤ Make the Vertical Space at the top of the Image View 20 points.
➤ And finally, put the $9.99 button at 20 points from the sides instead of 8.
You should end up with something that looks like this:
To double-check, switch back to iPhone 8 and make sure that the Detail pane is restored to its original dimensions. If not, then you may have changed one of the original constraints instead of making a variation for the iPad’s size class.
In the iPad’s version of the Detail pane, the text is now tiny compared to the pop-up background. So, let’s change the fonts. That works in the same fashion. You add a customization for this size class with the + button, then change the property. You can customize any attribute that has a small + in front of it for different size classes.
➤ Select the Name label. In the Attributes inspector click the + in front of Font to add a new variant. Choose the System Bold font, size 28.
➤ Change the font of the other labels to System, size 20. You can do this in one go by making a multiple-selection.
➤ Change all the “leading” Horizontal Space constraints to 20 for this size class.
The final layout should look like this:
Switch back to iPhone 8 to make sure all the constraints are still correct there.
➤ Run the app, and you should have a much bigger detail view:
Exercise: The first time the detail pane shows its contents they appear quite abruptly because you simply set the
isHiddenproperty ofpop-upViewtofalse, which causes it to appear instantaneously. See if you can make it show up using a cool animation.
➤ This is probably a good time to try the app on the iPhone again. The changes you’ve made should be compatible with the iPhone version, but it’s smart to make sure.
If you’re satisfied everything works as it should, then commit the changes.
Slide over and split-screen on iPad
iOS has a very handy split-screen feature that lets you run two apps side-by-side. It works on pretty much all the 64-bit iPads (with a few caveats). Because you used size classes to build the app’s user interface, split-screen support should work flawlessly.
Try it out: Run the app on the iPad Air 2 or iPad Pro simulator. Swipe up from the bottom of the screen to have your dock appear on screen. Drag an app icon from the dock on to the right (or left) edge of the iPad screen, and it should snap on, giving you two apps running side-by-side. You can drag the divider bar to adjust the size occupied by each app. Thanks to size classes, the layout of StoreSearch will automatically adapt to the allotted space.
The View as: panel has a button Vary for Traits. You can use this to change how a view controller acts when it is part of such a split-screen.
Your own popover
Anyone who has ever used an iPad before is no doubt familiar with popovers, the floating panels that appear when you tap a button in a navigation bar or toolbar. They are a very handy UI element.
A popover is nothing more than a view controller that is presented in a special way. In this section, you’ll create a popover for a simple menu.
Adding the menu items
➤ In the storyboard, first, switch back to iPhone 8 because in iPad mode the view controllers are huge and take up too much space.
➤ Drag a new Table View Controller on to the canvas and place it next to the Detail screen.
➤ Change the table view to Grouped style and give it Static Cells.
➤ Add these rows (change the cell style to Basic):
This just puts three items in the table. You will only do something with the first one in this book. Feel free to implement the functionality of the other two by yourself.
Displaying as popover
To show the new view controller inside a popover, you first have to add a button to the navigation bar to trigger the popover.
➤ From the Objects Library drag a new Bar Button Item into the Detail View Controller’s Navigation Item — you can find it in the Document Outline. Make sure the Bar Button Item is in the Right Bar Button Items group.
➤ Change the bar button’s System Item to Action.
This button won’t show up on the iPhone because there the Detail pop-up doesn’t sit in a navigation controller.
➤ Control-drag from the bar button (in the Document Outline) to the Table View Controller to make segue. Choose the segue type of Action Segue – Present As Popover.
➤ Give the segue the identifier ShowMenu.
If you run the app and press the menu button, the app should look like this:
Setting the popover size
The popover doesn’t really know how big its content view controller is, so it just picks a size and that’s just ugly. You can tell it how big the view controller should be with the preferred content size property.
➤ In the Attributes inspector for the Table View Controller, in the Content Size boxes type Width: 320, Height: 204.
Now the size of the menu popover looks a lot more appropriate:
When a popover is visible, all other controls on the screen become inactive. The user has to tap outside the popover to dismiss it to use the rest of the screen again. You can make exceptions to this by setting the popover’s passthroughViews property.
Sending e-mail from the app
Now, let’s make the “Send Support Email” menu option work. Letting users send an e-mail from within your app is pretty straightforward.
iOS provides the MFMailComposeViewController class that takes care of everything for you. It lets the user type an e-mail and then sends the e-mail using the mail account that is set up on the device.
All you have to do is create an MFMailComposeViewController object and present it on the screen.
The question is: Who will be responsible for this mail compose controller? It can’t be the popover because that view controller will be deallocated once the popover goes away.
Instead, you will let the DetailViewController handle the sending of the e-mail, mainly because this is the screen that brings up the popover — via the segue from its bar button item — in the first place. DetailViewController is the only object that knows anything about the popover.
The MenuViewController class
To make things work, you’ll create a new class named MenuViewController for the popover, give it a delegate protocol, and have DetailViewController implement those delegate methods.
➤ Add a new file to the project using the Cocoa Touch Class template. Name it MenuViewController, subclass of UITableViewController.
➤ Remove all the data source methods from this file; you don’t need those for a table view with static cells. Also, remove all the commented out boilerplate code.
➤ In the storyboard, change the Class of the popover’s table view controller to MenuViewController.
➤ Add a new protocol to MenuViewController.swift, outside the class:
protocol MenuViewControllerDelegate: class {
func menuViewControllerSendEmail(_ controller:
MenuViewController)
}
➤ Also, add a property for this protocol inside the class:
weak var delegate: MenuViewControllerDelegate?
Like all delegate properties, this is weak because you don’t want MenuViewController to “own” the object that implements the delegate methods.
➤ Finally, add tableView(_:didSelectRowAt:) to handle taps on the rows from the table view:
// MARK:- Table View Delegates
override func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.row == 0 {
delegate?.menuViewControllerSendEmail(self)
}
}
Setting the MenuViewController delegate
Now you have to make DetailViewController the delegate for this menu popover.
➤ Switch to DetailViewController.swift and add the following extension to the bottom of the source file to conform to the new protocol:
extension DetailViewController: MenuViewControllerDelegate {
func menuViewControllerSendEmail(_: MenuViewController) {
}
}
Currently, the code is just a stub. You’ll fill in the implementation code in a bit.
➤ Next, add the following navigation code to the class:
// MARK:- Navigation
override func prepare(for segue: UIStoryboardSegue,
sender: Any?) {
if segue.identifier == "ShowMenu" {
let controller = segue.destination as! MenuViewController
controller.delegate = self
}
}
This tells the MenuViewController object who its delegate is.
Run the app and tap Send Support Email. Notice how the popover doesn’t disappear yet. You’ll have to manually dismiss it before you can show the mail compose sheet.
Showing the mail compose view
➤ The MFMailComposeViewController lives in the MessageUI framework — import that in DetailViewController.swift:
import MessageUI
➤ Then, add the following code to menuViewControllerSendEmail() (in the extension at the end):
dismiss(animated: true) {
if MFMailComposeViewController.canSendMail() {
let controller = MFMailComposeViewController()
controller.setSubject(NSLocalizedString("Support Request",
comment: "Email subject"))
controller.setToRecipients(["your@email-address-here.com"])
self.present(controller, animated: true, completion: nil)
}
}
The code first calls dismiss(animated:) to hide the popover. This method takes a completion closure that until now you’ve always left nil. Here you implement the closure — using trailing syntax — to bring up the MFMailComposeViewController after the popover has faded away.
It’s not a good idea to present a new view controller while the previous one is still in the process of being dismissed. This is why you wait to show the mail compose sheet until after the popover is done animating.
To use the MFMailComposeViewController object, you have to give it the subject of the e-mail and the e-mail address of the recipient. You probably should put your own e-mail address there!
➤ Run the app and pick the Send Support Email menu option. The standard e-mail compose sheet should slide up — if you are on a device. This won’t work on the Simulator at all, sorry.
Note: If you run the app on a device and don’t see the e-mail sheet, you may not have set up any e-mail accounts on your device.
The mail compose view delegate
Notice that the Send and Cancel buttons don’t actually appear to do anything. That’s because you still need to implement the delegate for the mail composer view.
➤ Add a new extension to DetailViewController.swift:
extension DetailViewController: MFMailComposeViewControllerDelegate {
func mailComposeController(_ controller:
MFMailComposeViewController, didFinishWith result:
MFMailComposeResult, error: Error?) {
dismiss(animated: true, completion: nil)
}
}
The result parameter says whether the mail was successfully sent or not. This app doesn’t really care about that, but you could show an alert in case of an error if you wanted. Check the documentation for the possible result codes.
➤ In the menuViewControllerSendEmail() method, add the following line, after the controller is created:
controller.mailComposeDelegate = self
➤ Now, if you press Cancel or Send, the mail compose sheet gets dismissed.
Modal sheet presentation styles
Did you notice that the mail sheet did not take up the entire screen area in landscape, but when you rotate to portrait it (almost) does? That is called a page sheet.
On the iPhone, if you presented a modal view controller, it always takes over the entire screen, but on the iPad, you have several options.
The page sheet is probably the nicest option for the MFMailComposeViewController, but let’s experiment with the other ones as well, shall we?
➤ In menuViewControllerSendEmail(), add the following line:
controller.modalPresentationStyle = .formSheet
The modalPresentationStyle property determines how a modal view controller is presented on the iPad. You’ve switched it from the default page sheet to a form sheet, which looks like this:
A form sheet is smaller than a page sheet, so it takes up less room on the screen. There is also a “full screen” presentation style that always covers the entire screen, even in landscape. Try it out!
Landscape on iPhone Plus
The iPhone Plus is a strange beast. It mostly works like any other iPhone, but sometimes it gets ideas and pretends to be an iPad.
➤ Run the app on the iPhone 8 Plus Simulator, do a search, and rotate to landscape.
The app will look something like this:
The app tries to both show the split view controller and the special landscape view at the same time. That’s not going to work.
The iPhone Plus devices are so big that they’re almost small iPads. The designers at Apple decided that in landscape orientation, the Plus should behave like an iPad, and therefore it shows the split view controller.
What’s the trick? Size classes, of course! On a landscape iPhone Plus, the horizontal size class is regular, not compact. But the vertical size class is still compact, just like on the smaller iPhone models.
Showing split view correctly for iPhone Plus
To stop the LandscapeViewController from showing up, you have to make the rotation logic smarter.
➤ In SearchViewController.swift, change willTransition(to:with:) to:
override func willTransition(to newCollection:
UITraitCollection, with coordinator:
UIViewControllerTransitionCoordinator) {
super.willTransition(to: newCollection, with: coordinator)
let rect = UIScreen.main.bounds
if (rect.width == 736 && rect.height == 414) || // portrait
(rect.width == 414 && rect.height == 736) { // landscape
if presentedViewController != nil {
dismiss(animated: true, completion: nil)
}
} else if UIDevice.current.userInterfaceIdiom != .pad {
switch newCollection.verticalSizeClass {
case .compact:
showLandscape(with: coordinator)
case .regular, .unspecified:
hideLandscape(with: coordinator)
}
}
}
The bottom bit of this method is as before; it checks the vertical size class and decides whether to show or hide the LandscapeViewController.
You don’t want to do this for the iPhone Plus, so you need to detect somehow that the app is running on the Plus. There are a couple of ways you can do this:
-
Look at the width and height of the screen. The dimensions of the iPhone Plus are 736 by 414 points.
-
Look at the hardware device name. There are APIs for finding this out, but you have to be careful. Often one type of iPhone can have multiple model names, depending on the cellular chipset used or other factors.
What about the size class? That sounds like it would be the obvious thing to tell the different devices apart. Unfortunately, looking at the size class doesn’t work.
If the device is in portrait, the Plus has the same size classes as the other iPhone models. In other words, in portrait, you can’t tell from the size class alone whether the app is running on a Plus or not. Only in landscape, and even then, if you have Display Zoom on, the Plus will no longer have a different size class. It will act like a regular iPhone.
The approach you’re using in this app is to look at the screen dimensions. You need to check for both orientations because the screen bounds change depending on the orientation of the device.
Once you’ve detected the app runs on an iPhone Plus, you no longer show the landscape view, and you dismiss any Detail pop-up that may still be visible before you rotate to landscape.
➤ Try it out. Now the iPhone Plus shows a proper split view:
Adding size class based UI changes for iPhone Plus
Of course, the Detail pane now uses the iPhone-size design, not the iPad design.
That’s because the size class for DetailViewController is now regular width, compact height. You didn’t make a specific design for that size class, so the app uses the default design.
That’s fine for the size of the Detail view, but it does mean the close button is visible again.
➤ Open the storyboard. Then, open the View as: panel and switch to the iPhone 8 Plus mode. Next, switch to landscape mode. This will help you get the size classes right when you add exceptions.
➤ Select the Close Button in the Detail scene. In the Attributes inspector, add a new row for Installed (for Width: Regular, Height: Compact) and uncheck it:
➤ Select the Center Y Alignment constraint on Pop-up View. Change its Constant to 20, but only for this size class. This moves the Detail panel down a bit.
Dark mode support
iPhone has system-wide support for dark mode, which lets the user define that all supporting apps should show a dark color palette, rather than a light one. All of Apple’s native apps support this mode already. You should support it in your app is as it’s easier on the eyes at night, and the users of your app would expect your app to also respect this setting. And it looks a lot better!
Follow these steps to see how your app looks like in dark mode:
➤ Run the app. In Xcode’s debug bar, click the Environment Overrides button (or choose Debug > View Debugging > Configure Environment Overrides…).
➤ Click on the Interface Style toggle and choose Dark.
Now if you switch to the simulator, you’ll see the app looks like this:
It already looks great, and you didn’t have to write any extra lines of code! How is that possible?
The native controls you’ve been using to design the app, like the search bar, segment control, table view, etc. already support dark mode and know which colors to change. Also, as long as you’re using system colors, like System Background Color or Placeholder Text Color, they will also automatically switch to their dark mode equivalent when switching modes. The only changes you’ll need to make are in custom colors you defined manually.
For example, if you search for something and click on a result, you’ll see that the details pop-up didn’t adopt to dark mode perfectly:
The labels Type and Genre are in black on a dark gray background, so it’s hard to make out the words. If you recall, when you created this view, you specifically set the color of these labels to black. The right thing to do is to choose one of the provided system colors. When you add a new control, the default color values are usually already set to one of the system colors. When you added these two labels, you were asked to choose non-default colors on purpose, so you would see that it breaks dark mode support.
It’s time to fix it:
➤ Open the storyboard and go to the Detail scene.
➤ Click on the Type: label and change the color from black to Default (Label Color). Do the same for the Genre: label.
➤ Run the app again and see how the content adopts its colors correctly when you switch dark mode on and off.
Dark mode in storyboard
It’s annoying to run the app every time you make a change, to verify your view looks good in dark mode. Luckily Apple has incorporated dark mode in Interface Builder as well.
➤ Once again, open the storyboard and go to the Detail scene.
➤ Open the View as panel and under Interface Style choose Dark Style:
Now you know how to easily switch and make sure the UI looks perfect in both modes.
Providing dark mode assets
Most images should look fine in dark mode. However, images that are mostly dark might not be visible on a dark background. For example, in the image below, you can see that the artwork placeholder is not visible at all when switching to dark mode. The iPhone can’t know how to convert your images to dark mode.
For that reason, you have the option to provide a dark mode version of an image.
➤ Open the assets catalog and choose the Placeholder image.
➤ In the right pane choose the Attributes Inspector.
➤ Under Appearances choose Any, Dark.
Additional placeholders will show up and you’ll be able to put images that will only be displayed when the app is in dark mode.
➤ Drag the files PlaceholderDark@2x.png and PlaceholderDark@3x.png from the ImagesDark folder to the 2x and 3x Dark Appearance placeholders respectively.
Now when you switch from light to dark mode, the placeholder changes as well.
Supporting dark mode in code
The app looks great in dark mode by now, but there’s still something that could be improved. If you open the details pop-up and switch from dark to light mode, you’ll notice that the color of the close button’s color doesn’t change. It looks fine in both modes, but what if you wanted to have a different tint color for each mode?
For that, you’ll need to detect whether dark mode is enabled before setting the tint color.
➤ Open DetailViewController.swift
➤ In the method ViewDidLoad() find the line view.tintColor = . . .
➤ Replace it with the following:
if (traitCollection.userInterfaceStyle == .light) {
view.tintColor = UIColor(red: 20/255, green: 160/255, blue: 160/255, alpha: 1)
} else {
view.tintColor = UIColor(red: 140/255, green: 140/255, blue: 240/255, alpha: 1)
}
Now the tint color of the pop-up dialog will update as you switch modes.
And that’s it for the StoreSearch app! Congratulations for making it this far, it has been a long road.
➤ Celebrate by committing the final version of the source code and tagging it v1.0!
You can find the project files for this chapter under 42 – The iPad in the Source Code folder.