42.
The iPad
Written by Fahim Farook
Even though the apps you’ve written so far will work fine on the iPad, they are not optimized for the iPad. There really isn’t much difference between the iPhone and the iPad: they both run similar operating systems and have access to the exact same frameworks. But the iPad has a much bigger screen and that makes all the difference.Given the much bigger screen real estate available, on the iPad you can have different UI elements which 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 comes into play.
In this chapter you will cover the following:
-
Deployment platforms: A brief explanation of how to switch from universal mode to supporting 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 secondary 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 built-in e-mail functionality.
-
Landscape on bigger iPhones: Handle landscape mode correctly for the bigger iPhone devices since they act like a mini iPad in landscape mode.
Deployment platforms
All new iOS projects created with Xcode support both the iPhone and iPad platforms 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, but in case you want to know how to make the change to support only 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 platform. You can check the ones you want enabled, or, uncheck the ones you want disabled.
Note: While you can also enable Mac support (via Mac Catalyst) so that the same code for your iOS app also powers your macOS app, whether this will work for your particular app or not will depend on the features and third-party libraries that you use.
➤ 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 ▸ Fit Screen option from the Simulator menu to make it fit on your screen.
This works fine, but as I said before, simply blowing up the interface to iPad size does not take advantage of all the extra space the bigger screen offers. So instead, you’ll use some of the special features that 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 in the same screen.
A good example of this is the split view controller. It has two panes: a smaller pane on the left — the “primary” pane — usually containing a list of items, and a larger right pane — the “secondary” pane — showing more information about the thing you have selected in the primary 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 because 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 secondary view controller is visible and the app provides a button that will slide the primary 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 the window.
➤ On the Info tab there will be a Supported interface orientations (iPhone) 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, do 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
Adding a split view controller is easy – you 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.
➤ 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 three extra view controllers, leaving just the Split View Controller.
Here’s the final result after I was done:
A split view controller has a relationship segue with two child view controllers, one for the smaller primary pane on the left and one for the bigger secondary pane on the right.
The obvious candidate for the primary pane is the SearchViewController, and the DetailViewController will go into the secondary pane.
➤ Control-drag from the Split View Controller to the Search scene. Choose Relationship Segue – primary view controller.
This puts a new arrow between the split view and the Search screen.
➤ Control-drag from the Split View Controller to the Detail Scene. Choose Relationship Segue – secondary 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 over the course of this chapter to make sure it doesn’t do anything funny on the iPad!
Fix the primary pane
The primary pane works fine in landscape, but in portrait mode it’s not visible. You can make it appear by:
-
Tapping the back button on the navigation bar.
-
Swiping from the left edge of the screen — try it —.
The trouble is, the back button — what’s known as the display mode button — is confusing when it’s named “Back”. You want it to say “Search” instead.
The fix is very simple – you just have to give the view controller from the primary pane a title.
➤ In SearchViewController.swift, add the following line to viewDidLoad():
title = NSLocalizedString("Search", comment: "split view primary 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 the display mode button title showing up correctly as “Search”.
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 class 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.
Improve the secondary pane
The secondary 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 secondary pane, not bring up the pop-up.
You’re using DetailViewController for both purposes — pop-up and secondary 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 isPopUp = false
➤ Replace viewDidLoad() with the following:
override func viewDidLoad() {
super.viewDidLoad()
if isPopUp {
popupView.layer.cornerRadius = 10
let gestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(close))
gestureRecognizer.cancelsTouchesInView = false
gestureRecognizer.delegate = self
view.addGestureRecognizer(gestureRecognizer)
// Gradient view
view.backgroundColor = UIColor.clear
let dimmingView = GradientView(frame: CGRect.zero)
dimmingView.frame = view.bounds
view.insertSubview(dimmingView, at: 0)
} else {
view.backgroundColor = UIColor(patternImage: UIImage(
named: "LandscapeBackground")!)
popupView.isHidden = true
}
if searchResult != nil {
updateUI()
}
}
With the gesture recognizer code inside the if isPopUp 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 secondary 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 would be in SceneDelegate where you can set up access to the Split View Controller and its child views.
➤ Add the following properties to SceneDelegate.swift, inside the class:
// MARK: - Properties
var splitVC: UISplitViewController {
return window!.rootViewController as! UISplitViewController
}
var searchVC: SearchViewController {
let nav = splitVC.viewControllers.first as! UINavigationController
return nav.viewControllers.first as! SearchViewController
}
var detailVC: DetailViewController {
let nav = splitVC.viewControllers.last as! UINavigationController
return nav.viewControllers.first as! DetailViewController
}
These three computed properties refer to the various view controllers in the app:
-
splitVC: The top-level Split View Controller. -
searchVC: The Search screen in the primary pane of the split view. -
detailVC: The Detail screen in the secondary pane of the split view.
Note that both the primary pane and secondary pane of the Split View Controller has a built-in navigation controller which embeds each view controller. So you need to get the actual view controller you are interested in from each navigation controller’s list of views.
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.
➤ Now, add a new property to SearchViewController.swift to hold a reference to the DetailViewController:
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 scene(_:willConnectTo:options:) in SceneDelegate.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 secondary 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 the iPhone, the horizontal size class is always compact — well, almost always since there are some exceptions, but 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, even on iPad, because it sits inside the split view’s primary 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. It’s possible that searchResult is 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():
popupView.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 secondary pane should show details about the selected search result. Notice that the row in the table stays selected as well.
That looks good, but there are a few other small improvements to make.
Remove 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.
Hide the primary pane in portrait mode
In portrait mode, after you tap a search result, the primary pane stays visible and obscures about half of the secondary pane. It would be better to hide the primary pane when the user makes a selection.
➤ Add the following method to SearchViewController.swift:
// MARK: - Private Methods
private func hidePrimaryPane() {
UIView.animate(
withDuration: 0.25,
animations: {
self.splitViewController!.preferredDisplayMode = .secondaryOnly
}, 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 .secondaryOnly to hide the primary pane. You do this in an animation block, so the primary pane disappears with a smooth animation.
The trick is to restore the preferred display mode to .automatic after the animation completes. Otherwise, the primary 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 != .oneBesideSecondary {
hidePrimaryPane()
}
The .oneBesideSecondary mode only applies in landscape, so this says, “if the split view is not in landscape, hide the primary pane when a row gets tapped.”
➤ Try it out. Put the iPad in portrait, do a search, and tap a row. Now the primary 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 secondary 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, but often, 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 view controllers with some duplicated logic.
The Apple Developer Forums
When I first wrote this chapter, how to hide the primary pane was not explained anywhere in the official
UISplitViewControllerdocumentation and 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: devforums.apple.com
This would be a good time to commit your changes since you’ve made quite a few changes.
Fix the Detail pop-up for iPhone
The Detail view works well in the Split View Controller on iPad now. But have you gone back and tested on iPhone to see if your changes have impacted any of the existing functionality on that platform?
This is something that’s always important to test — if you make changes specific for one platform, and your app supports more than one platform, test on the other platforms after the changes.
You’ll see a few small problems if you test the app now on iPhone:
-
You see the secondary pane of the Split View Controller when you start the app instead of the Search scene.
-
The Search view now shows a navigation bar.
-
The Detail pop-up now doesn’t display properly on the iPhone because
isPopUpis always false — try it. -
The landscape view has the wrong layout for the buttons and it also has a navigation bar which takes up screen space.
Let’s fix these issues.
Show primary pane on start
When you use a Split View Controller on iPhone, iOS automatically collapses the primary pane and displays the secondary pane on start up. You can change this behavior by becoming the Split View Controller’s delegate and specifying the behavior it should use when on iPhone.
➤ Add the following extension to SceneDelegate.swift:
extension SceneDelegate: UISplitViewControllerDelegate {
func splitViewController(
_ svc: UISplitViewController,
topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
) -> UISplitViewController.Column {
if UIDevice.current.userInterfaceIdiom == .phone {
return .primary
}
return proposedTopColumn
}
}
You ask the Split View Controller to display the primary pane after the views are collapsed when the current device is an iPhone. For all other devices, you display the pane proposed by the operating system.
➤ Add the following line to the end of scene(_:willConnectTo:options:) in SceneDelegate.swift to to indicate that SceneDelegate will be the Split View Controller’s delegate:
splitVC.delegate = self
That should do it!
Run and test on both iPhone and iPad to make sure that the change works exactly as expected and that it did not break anything else.
Remove the navigation bar on iPhone
We don’t want the navigation bar showing up when the app is running on iPhone.
➤ Add the following code to SearchViewController.swift:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if UIDevice.current.userInterfaceIdiom == .phone {
navigationController?.navigationBar.isHidden = true
}
}
That will hide the navigation bar for SearchViewController and all its child views when running on an iPhone.
Fix the Detail pop-up
➤ In prepare(for:sender:) in SearchViewController.swift, add the line:
detailViewController.isPopUp = true
➤ Do the same thing in LandscapeViewController.swift.
➤ Verify that the Detail screen works properly in all situations.
Fix the landscape screen
The landscape screen on iPhone currently looks like this:
Note that the extra navigation bar that you saw earlier is now gone. This is due to the fix you made on the Search screen to remove the navigation bar there. So all you need to do is fix the button layout.
Exercise: Do you know what caused the button layout to change after adding a Split View Controller?
Answer: The button layout is calculated based on the size of the LandscapeViewController but the size of the Landscape view appears not to be correct at the time the layout calculations are done.
However, since the Landscape view is a fullscreen view, you can simply use the size of the device screen instead of using the Landscape view size.
➤ Replace the following lines in tileButtons(_:) in LandscapeViewController.swift:
let viewWidth = scrollView.bounds.size.width
let viewHeight = scrollView.bounds.size.height
With the following:
let viewWidth = UIScreen.main.bounds.size.width
let viewHeight = UIScreen.main.bounds.size.height
All you’ve done is to change the view width and height to be set based on the screen width and height.
➤ Run the app now and the Landscape view should be laid out correctly now.
Size classes in the storyboard
Even though you’ve placed the existing DetailViewController in the secondary 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!
Recall that there are two possible size classes, compact and regular, and that you can assign one of these values to the horizontal axis (width) and one to the vertical axis (height).
Here is the diagram again:
➤ Open Main.storyboard and use the IB toolbar to switch to iPad (9.7″) – the view controllers are larger now.
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, as you’ve seen previously, 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.
Uninstall 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 on the Detail scene. Go to the Attributes inspector and scroll all the way 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 iPad mode, of course.
The Close Button still exists, but it is not installed for this size class. You can still see the button in the Document Outline, but it is grayed out:
➤ Use the IB toolbar to switch back to iPhone SE.
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
Of course, the Detail pop-up is also way too wide on the iPad :] You fixed this for iPhone devices using variations for size classes previously. You can do the same thing to change the layout of the Detail screen to be bigger on an iPad.
➤ In the storyboard, switch to the iPad (9.7”) layout again.
➤ Select the Pop-up View and add size based variations for wR hR for left, and right Auto Layout pin constraints so that the spacing around the pop-up is 80 points for this size variation.
You’ll notice that you already have variations added previously for these pin values from the iPhone customizations you made previously.
➤ Select the Image View in the Detail pop-up and in the Size inspector, add size based variations for wR hR for the width and height of the image to be 180.
➤ Select the main Stack View and set its Spacing to be 20 for wR hR size class.
➤ Change the pin constraints for the Stack View so that there is 32 points spacing all around.
You should end up with something that looks like this:
Just to double-check, switch back to iPhone SE via the IB toolbar 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.
Unfortunately, this will break Dynamic Type support for iPad but given that the new default fonts are much bigger, it hopefully won’t matter. If it does matter to you, then you would need to set the various font sizes via code based on the device type – iPhone or iPad. But that’s a larger topic that we can’t cover here.
➤ Add a new variant to change the font of the other labels to System, size 20. You can do this in one go by making a multiple-selection.
➤ Add a variant for the Grid Stack View’s Spacing, setting it to 20.
Switch back to iPhone SE 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 secondary pane shows its contents they appear quite abruptly because you simply set the
isHiddenproperty ofpopupViewtofalse, 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 one of the iPad simulators. 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 IB toolbar has a Layout button which becomes available when you select any of the iPad modes. 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.
Add the menu items
➤ In the storyboard, first switch back to iPhone SE 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.
➤ Set the table view’s Storyboard ID to PopoverView in the Identity inspector.
➤ 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.
Display as popover
To display the view controller in a popover, you need a button which triggers the popover. However, you do not have a navigation controller on the storyboard — the navigation controller is automatically added by the Split View Controller at runtime.
So you’ll have to add the button via code.
➤ Add the following code to the end of the else block of the if isPopup condition in viewDidLoad in DetailViewController.swift:
// Popover action button
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(showPopover(_:)))
You set the right bar button item on the Detail screen’s navigation bar to a button where a tap would call a method named showPopover. This button won’t show up on the iPhone because there, the Detail pop-up doesn’t sit in a navigation controller.
➤ Add the new method to DetailViewController.swift:
@objc func showPopover(_ sender: UIBarButtonItem) {
guard let popover = storyboard?.instantiateViewController(
withIdentifier: "PopoverView") else { return }
popover.modalPresentationStyle = .popover
if let ppc = popover.popoverPresentationController {
ppc.barButtonItem = sender
}
present(popover, animated: true, completion: nil)
}
You simply create an instance of the table view controller with the menu using the Storyboard ID you set up earlier, configure its presentation style to be a popover, set the button from which to display the popover and then display it.
If you run the app and press the menu button, the app should look like this:
Set 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 of the popover to dismiss it before they can use the rest of the screen again — you can make exceptions to this by setting the popover’s passthroughViews property.
Send 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 easy.
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 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 because 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: AnyObject {
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)
}
}
Set 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, change showPopover(_:) as follows:
@objc func showPopover(_ sender: UIBarButtonItem) {
guard let popover = storyboard?.instantiateViewController(
withIdentifier: "PopoverView") as? MenuViewController // Change this
else { return }
popover.modalPresentationStyle = .popover
if let ppc = popover.popoverPresentationController {
ppc.barButtonItem = sender
}
popover.delegate = self // Add this
present(popover, animated: true, completion: nil)
}
You make two changes:
- You cast the popover to be of type
MenuViewController - You tell the popover instance, which is of type
MenuController, 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.
Show 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 – so do that first.
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, of course):
controller.mailComposeDelegate = self
➤ Now, if you press Cancel or Send, the mail compose sheet gets dismissed.
Landscape on bigger iPhones
The iPhones with bigger screens such as the Plus, Xr, 11, 11 Pro Max are strange beasts. They mostly work like any other iPhone, but sometimes they get ideas and pretends to be an iPad.
➤ Run the app on the iPhone 8 Plus Simulator, do a search, and rotate to landscape.
You’ll first get an empty secondary pane with the navigation bar. Tap the “Search” button and the app will look something like this:
The app tries to do both: show the split view controller and the special landscape view at the same time. Obviously, that’s not going to work.
These devices are so big that they’re almost small iPads. The designers at Apple decided that in landscape orientation these phone should behave like an iPad, and therefore show the split view controller.
What’s the trick? Size classes, of course! On landscape, for these devices, the horizontal size class is regular, not compact. But the vertical size class is still compact, just like on the smaller iPhone models.
Show split view correctly for bigger iPhones
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)
switch newCollection.verticalSizeClass {
case .compact:
if newCollection.horizontalSizeClass == .compact { // Add this
showLandscape(with: coordinator)
} // Add this
case .regular, .unspecified:
hideLandscape(with: coordinator)
@unknown default:
break
}
}
The method is almost the same as before – you’ve just added an extra if condition to check what the horizontal size class is when the vertical size class is .comapct. This way, you can identify the bigger iPhones which behave differently.
➤ Try it out. Now the iPhone Plus shows a proper split view:
Change split view display mode for iPhone
But … there’s still an issue – when the app starts up, you still only see the secondary pane. You have to tap the “Search” button to see the primary pane. That’s not a very good design, even if this only affects some iPhones …
The fix is easy – you just need to set a different display mode for the split view when it’s being used on an iPhone. The display mode defines how a split view displays its child view controllers. With iOS 14, a split view can have a primary pane, a secondary pane and a supplementary pane. So there could potentially be three panes in play.
How these panes display on a given platform, or under specific conditions, is determined by the split view’s display mode. You can’t change the display mode for a split view directly. Instead, you set the preferredDisplayMode and the split view tries to display the panels as per your preference, but if it is unable to – for example, due to lack of space – it will display what it thinks is the best layout.
So, in order to fix what happens on bigger iPhones, you need to specify that you want the split view to display one additional column besides the secondary pane. We do this in SceneDelegate since then we’ll be initializing split view before it gets shown on screen.
➤ Add the following to the end of scene(_:willConnectTo:options:) in SceneDelegate.swift where you put the other split view related code:
if UIDevice.current.userInterfaceIdiom == .phone {
splitVC.preferredDisplayMode = .oneBesideSecondary
}
If you wonder how this will affect only the bigger iPhones and not all iPhones, good thinking :] But remember that .oneBesideSecondary is your preferred display mode. Doesn’t necessarily mean that that’s what you’ll actually see on the device. If you test it out, you will see that this does not result in two panes on a smaller iPhone screen.
In fact, even on a bigger iPhone screen what you get is not what you asked for. You don’t get the primary pane side-by-side with the secondary pane – instead, you get the primary pane overlaying the secondary pane:
There’s a separate display mode setting if you actually want the primary pane to overlay the secondary pane – .oneOverlaySecondary instead of .oneBesideSecondary – but iOS decided to use the overlay style instead of the one we requested due to the available space.
This at least works for the intended purpose. So we’ll accept it – not that we have much choice :]
Add size class based UI changes for bigger iPhones
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, use the IB toolbar to switch to the iPhone 8 Plus mode and 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:
➤ Build and run the app and test on a variety of devices or simulators — iPad, smaller iPhone, bigger iPhone etc. —, orientations, and appearances to make sure that everything still looks and works right.
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-iPad in the Source Code folder.