39.
Landscape
Written by Fahim Farook
So far, the apps you’ve made were either portrait or landscape, but not both. Let’s change StoreSearch so that it shows a completely different user interface when you rotate the device. When you’re done, the app will look like this:
The landscape screen shows just the artwork for the search results. Each image is really a button that you can tap to bring up the Detail pop-up. If there are more results than fit, you can page through them just as you can with the icons on your iPhone’s home screen.
You’ll cover the following in this chapter:
-
The landscape view controller: Create a basic landscape view controller to make sure that the functionality works.
-
Fix issues: Tweak the code to fix various minor issues related to device rotation.
-
Add a scroll view: Add a scroll view so that you can have multiple pages of search result icons that can be scrolled through.
-
Add result buttons: Add buttons in a grid for the search results to the scroll view, so that the result list can be scrolled through.
-
Paging: Configure scrolling through results page-by-page rather than as a single scrolling list.
-
Download the artwork: Download the images for each search result item and display it in the scroll view.
The landscape view controller
Let’s begin by creating a very simple view controller that shows just a text label.
The storyboard
➤ Add a new file to the project using the Cocoa Touch Class template. Name it LandscapeViewController and make it a subclass of UIViewController.
➤ In Interface Builder, with Main storyboard open, drag a new View Controller on to the canvas.
➤ In the Document Outline, click on the yellow circle for the view controller and change it’s name to Landscape.
➤ In the Identity inspector, change the Class to LandscapeViewController. Also type this into the Storyboard ID field.
There will be no segue to this view controller. Instead, you’ll instantiate this view controller programmatically when you detect a device rotation. For that, it needs to have an ID so you can uniquely identify this particular view controller in the storyboard.
➤ Use the Orientation button on the toolbar at the bottom of Interface Builder to switch to landscape mode.
This flips all the scenes in the storyboard to landscape, but that is OK — it doesn’t change what happens when you run the app. Putting Interface Builder in landscape mode is just a design aid that makes it easier to lay out your UI. What actually happens when you run the app depends on the orientation the user holds the device in. The trick is to use Auto Layout constraints to make sure that the view controllers properly resize to landscape or portrait at runtime.
➤ Drag a new Label into the scene and give it some text. You’re just using this label to verify that the new view controller shows up in the correct orientation.
➤ Use the Align Auto Layout menu to center the label horizontally and vertically.
Your design should look something like this:
Show the landscape view on device rotation
As you know by now, view controllers have a bunch of methods such as viewDidLoad(), viewWillAppear() and so on that are invoked by UIKit at given times. There is also a method that is invoked when the device is rotated. You can override this method to show (and hide) the new LandscapeViewController.
➤ Add the following method to SearchViewController.swift:
override func willTransition(
to newCollection: UITraitCollection,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.willTransition(to: newCollection, with: coordinator)
switch newCollection.verticalSizeClass {
case .compact:
showLandscape(with: coordinator)
case .regular, .unspecified:
hideLandscape(with: coordinator)
@unknown default:
break
}
}
This method isn’t just invoked on device rotations, but any time the trait collection for the view controller changes. You’ve seen trait collections used before in the previous chapter to detect the current appearance. But let’s learn a bit more about it.
So what is a trait collection? It is, um, a collection of traits, where a trait can be:
- The horizontal size class
- The vertical size class
- The display scale — is this a Retina screen or not?
- The user interface idiom — is this an iPhone or iPad?
- The preferred Dynamic Type font size
- The appearance — is it Light or Dark?
- And a few other things
Whenever one or more of these traits change, for whatever reason, UIKit calls willTransition(to:with:) to give the view controller a chance to adapt to the new traits.
What we are interested in here are the size classes. This feature allows you to design a user interface that is independent of the device’s actual dimensions or orientation. With size classes, you can create a single storyboard that works across all devices, from iPhone to iPad — a “universal storyboard”.
So how exactly do these size classes work? Well, there’s two of them, a horizontal one and a vertical one, and each can have two values: compact or regular.
The combination of these four things creates the following possibilities:
When an iPhone app is in portrait orientation, the horizontal size class is compact and the vertical size class is regular.
Upon a rotation to landscape, the vertical size class changes to compact.
What you may not have expected is that the horizontal size class doesn’t change and stays compact in both portrait and landscape orientations — except on a growing list of iPhone models such as the Plus, Xr, Max etc. that is.
In landscape, the horizontal size class on these non-consistent iPhones is regular. That’s because the larger dimensions of these devices can fit a split screen in landscape mode, like the iPad — something you’ll see later on.
What this boils down to is, to detect an iPhone rotation you just have to look at how the vertical size class changed. That’s exactly what the switch statement does:
switch newCollection.verticalSizeClass {
case .compact:
showLandscape(with: coordinator)
case .regular, .unspecified:
hideLandscape(with: coordinator)
@unknown default:
break
}
If the new vertical size class is .compact the device got flipped to landscape and you show the LandscapeViewController. But if the new size class is .regular, the app is back in portrait and you hide the landscape view again.
The reason the second case statement also checks .unspecified is because switch statements must always be exhaustive and have cases for all possible values. .unspecified shouldn’t happen, but just in case it does, you also hide the landscape view. This is another example of defensive programming.
And the third case for @unknown default is another example of defensive programming. While the current case statements cover all the possible values, it’s possible that in future there might be additional values for vertical sizes. So you guard for that. Try commenting out the code and you’ll see that Xcode prompts you to add this particular safeguard.
Just to keep things readable, the actual showing and hiding happens in methods of their own. You will add these next.
In the early years of iOS, it was tricky to put more than one view controller on the same screen. The motto used to be: one screen, one view controller. However, when devices with larger screens became available, that became inconvenient — you often want one area of the screen to be controlled by one view controller and a second area by a separate view controller. So now, view controllers are allowed to be part of other view controllers if you follow a few rules.
This is called view controller containment. These APIs are not limited to just the iPad; you can take advantage of them on the iPhone as well. These days a view controller is no longer expected to manage a screenful of content, but manages a “self-contained presentation unit”, whatever that may be for your app.
You’re going to use view controller containment for the LandscapeViewController.
It would be eminently possible to make a modal segue to this scene and present it with your own custom animations. But you’ve already done that and it’s more fun to play with something new. Besides, it’s useful to learn about containment and child view controllers.
➤ Add an instance variable to SearchViewController.swift:
var landscapeVC: LandscapeViewController?
This is an optional because there will only be an active LandscapeViewController instance if the phone is in landscape orientation. In portrait orientation this will be nil.
➤ Add the following helper method:
func showLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
// 1
guard landscapeVC == nil else { return }
// 2
landscapeVC = storyboard!.instantiateViewController(
withIdentifier: "LandscapeViewController") as? LandscapeViewController
if let controller = landscapeVC {
// 3
controller.view.frame = view.bounds
// 4
view.addSubview(controller.view)
addChild(controller)
controller.didMove(toParent: self)
}
}
In previous apps you called present(animated:completion:) or made a segue to show a new modal screen. Here, however, you add the new LandscapeViewController as a child view controller of SearchViewController.
Here’s how it works, step-by-step:
-
It should never happen that the app instantiates a second landscape view when you’re already looking at one. The
guardstatement codifies this requirement. If it should happen thatlandscapeVCis notnil, then you’re already showing the landscape view and you simplyreturnright away. -
Find the scene with the ID “LandscapeViewController” in the storyboard and instantiate it. Because you don’t have a segue, you need to instantiate the view controller manually. This is why you set the Storyboard ID in the Identity inspector.
The
landscapeVCinstance variable is an optional, so you need to unwrap it before you can continue. -
Set the size and position of the new view controller. This makes the landscape view just as big as the
SearchViewController, covering the entire screen.The
frameis the rectangle that describes the view’s position and size in terms of its superview. To move a view to its final position and size you usually set itsframe. Theboundsis also a rectangle but seen from inside the view.Because
SearchViewController’s view is the superview here, theframeof the landscape view must be made equal to theSearchViewController’sbounds. -
These are the minimum required steps to add the contents of one view controller to another, in this order:
a. Add the landscape controller’s view as a subview. This places it on top of the table view, search bar and segmented control.
b. Tell the
SearchViewControllerthat theLandscapeViewControlleris now managing that part of the screen, usingaddChild(). If you forget this step, then the new view controller may not always work correctly.c. Tell the new view controller that it now has a parent view controller with
didMove(toParent:).In this new arrangement,
SearchViewControlleris the “parent” view controller, andLandscapeViewControlleris the “child”. In other words, the Landscape screen is embedded inside theSearchViewController.
Note: Even though it will appear on top of everything else, the Landscape screen is not presented modally. It is “contained” in its parent view controller, and therefore owned and managed by the parent — it isn’t independent like a modal screen. This is an important distinction.
View controller containment is also used for navigation and tab bar controllers where the
UINavigationControllerandUITabBarController“wrap around” their child view controllers.Usually, when you want to show a view controller that takes over the whole screen, you’d use a modal segue. But when you want just a portion of the screen to be managed by its own view controller, you’d make it a child view controller.
One of the reasons you’re not using a modal segue for the Landscape screen in this app, even though it is a full-screen view controller, is that the Detail pop-up already is modally presented and this could potentially cause conflicts. Besides, I wanted to show you a fun alternative to modal segues.
➤ To get the app to compile, add an empty implementation of the “hide” method:
func hideLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
}
By the way, the transition coordinator parameter is needed for doing animations, which you’ll add soon.
➤ Try it out! Run the app, do a search and rotate your iPhone or the Simulator to landscape — don’t forget to test out for both Light and Dark appearances.
Remember: to rotate the Simulator, press ⌘ and the left (or right) arrow keys. Or, you can use the rotate simulator button on the toolbar above the simulator. It’s possible that the Simulator won’t flip over right away — it can be buggy like that. If that happens, press ⌘+arrow key a few more times.
This is not doing any animation just yet. As always, first get it to work right, and then make it look pretty.
If you don’t do a search first before rotating to landscape, the keyboard may remain visible. You’ll fix that shortly. In the mean time you can press ⌘+K (on the Simulator only) to hide the keyboard manually.
Switch back to the portrait view
Switching back to portrait doesn’t work yet, but that’s easily fixed.
➤ Replace the method stub, which is basically a method name with no implementation code, that you added earlier with the following implementation to hide the landscape view controller:
func hideLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
if let controller = landscapeVC {
controller.willMove(toParent: nil)
controller.view.removeFromSuperview()
controller.removeFromParent()
landscapeVC = nil
}
}
This is essentially the reverse of what you did to embed the landscape view controller.
First, you call willMove(toParent:) to tell the view controller that it is leaving the view controller hierarchy and it no longer has a parent. Then, you remove its view from the screen, and finally, removeFromParent() truly disposes of the view controller.
You also set the instance variable to nil in order to remove the last strong reference to the LandscapeViewController object now that you’re done with it.
➤ Run the app. Switching back to portrait should remove the black landscape view.
Note: If you press ⌘-right (or ⌘-left) twice, the Simulator first rotates to landscape and then to portrait, but the
LandscapeViewControllerdoes not disappear. Why is that?It’s might not be immediately evident, but what you’re looking at now is not portrait but portrait upside down. This orientation is not recognized by the app — see the Device Orientation setting under Deployment Info in the project settings — and therefore the app keeps thinking it’s in landscape.
Press ⌘-right (or ⌘-left) twice again and you’re back in regular portrait.
Animate the transition to landscape
The transition to the landscape view is a bit abrupt. I don’t want to go overboard with animations here as the screen is already doing a rotating animation. A simple crossfade will be sufficient.
➤ Change the showLandscape(with:) method in SearchViewController.swift as follows:
func showLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
. . .
if let controller = landscapeVC {
controller.view.frame = view.bounds
controller.view.alpha = 0 // New line
view.addSubview(controller.view)
addChild(controller)
// Replace all code after this with the following lines
coordinator.animate(
alongsideTransition: { _ in
controller.view.alpha = 1
}, completion: { _ in
controller.didMove(toParent: self)
})
}
}
You’re still doing the same things as before, except now, the landscape view starts out completely transparent — alpha = 0 — and slowly fades in while the rotation takes place until it’s fully visible — alpha = 1.
Now you see why the UIViewControllerTransitionCoordinator object is needed — so your animation can be performed alongside the rest of the transition from the old traits to the new. This ensures the animations run as smoothly as possible.
The call to animate(alongsideTransition:completion:) takes two closures: the first is for the animation itself, the second is a “completion handler” that gets called after the animation finishes. The completion handler gives you a chance to delay the call to didMove(toParent:) until the animation is over.
Both closures are given a “transition coordinator context” parameter (the same context that animation controllers get) but you don’t use it here and so, you use the _ wildcard to ignore it.
Animate the transition from landscape
➤ Make similar changes to hideLandscape(with:):
func hideLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
if let controller = landscapeVC {
controller.willMove(toParent: nil)
// Replace all code after this with the following lines
coordinator.animate(
alongsideTransition: { _ in
controller.view.alpha = 0
}, completion: { _ in
controller.view.removeFromSuperview()
controller.removeFromParent()
self.landscapeVC = nil
})
}
}
This time you fade out the view. You don’t remove the view and the controller until the animation is completely done.
➤ Try it out. The transition between the portrait and landscape views should be a lot smoother now.
Tip: To see the transition animation in slow motion, select Debug ▸ Slow Animations from the Simulator menu.
Note: The order of operations for removing a child view controller is exactly the reverse of adding a child view controller, except for the calls to
willMoveanddidMove(toParent:).The rules for view controller containment say that when adding a child view controller, the last step is to call
didMove(toParent:). UIKit does not know when to call this method, as that needs to happen after any of your animations. You are responsible for sending the “did move to parent” message to the child view controller once the animation completes.There is also a
willMove(toParent:)but that gets called on your behalf byaddChild()already, so you’re not supposed to do that yourself.The rules are opposite when removing the child controller. First you should call
willMove(toParent: nil)to let the child view controller know that it’s about to be removed from its parent. The child view controller shouldn’t actually be removed until the animation completes, at which point you callremoveFromParent(). That method will then take care of sending the “did move to parent” message.You can find these rules in the API documentation for
UIViewController.
Fix issues
There are two more small tweaks that you need to make.
Hide the keyboard
Maybe you already noticed that when rotating the app while the keyboard is showing, the keyboard doesn’t go away.
Exercise: See if you can fix this yourself.
Answer: You’ve done something similar already after the user taps the Search button. The code is exactly the same here.
➤ Add the following line to showLandscape(with:):
func showLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
. . .
coordinator.animate(alongsideTransition: { _ in
controller.view.alpha = 1
self.searchBar.resignFirstResponder() // Add this line
}, completion: { _ in
. . .
})
}
}
Now the keyboard disappears as soon as you rotate the device. I found it looks best if you call resignFirstResponder() inside the animate-alongside-transition closure. After all, hiding the keyboard also happens with an animation.
Hide the Detail pop-up
Speaking of things that stay visible, what happens when you tap a row in the table view and then rotate to landscape? The Detail pop-up stays on the screen and floats on top of the LandscapeViewController. I find that a little strange. It would be better if the app dismissed the pop-up before rotating.
Exercise: See if you can fix that one.
The Detail pop-up is presented modally via a segue, so you can call dismiss(animated:completion:) to dismiss it, just like you do in the close() action method.
There’s a complication though: you should only dismiss the Detail screen when it is actually visible. For that, you can look at the presentedViewController property. This returns a reference to the current modal view controller, if any. If presentedViewController is nil there isn’t anything to dismiss.
➤ Add the following code to the end of the animate(alongsideTransition:) closure in showLandscape(with:):
if self.presentedViewController != nil {
self.dismiss(animated: true, completion: nil)
}
➤ Run the app and tap on a search result, then rotate to landscape. The pop-up should now fly off the screen. When you return to portrait, the pop-up is nowhere to be seen.
Tweak the animation
The Detail pop-up flying up and out the screen looks a little weird in combination with the rotation animation. There’s too much happening on the screen at once for my taste. Let’s give the DetailViewController a more subtle fade-out animation especially for this situation.
When you tap the X button to dismiss the pop-up, you’ll still make it fly out of the screen. But when it is automatically dismissed upon rotation, the pop-up will fade out with the rest of the table view instead.
You’ll give DetailViewController a property that that specifies how it will animate the pop-up’s dismissal. You can use an enum for this.
➤ Add the following to DetailViewController.swift, inside the class:
enum AnimationStyle {
case slide
case fade
}
var dismissStyle = AnimationStyle.fade
This defines a new enum named AnimationStyle. An enum, or enumeration, is simply a list of possible values. The AnimationStyle enum has two values, slide and fade. Those are the animations the Detail pop-up can perform when dismissed.
The dismissStyle variable determines which animation is chosen. This variable is of type AnimationStyle, so it can only contain one of the values from that enum. By default it is .fade, the animation that will be used when rotating to landscape.
Note: The full name of the enum is
DetailViewController.AnimationStylebecause it sits inside theDetailViewControllerclass.It’s a good idea to keep the things that are closely related to a particular class, such as this enum, inside the definition for that class. That puts them inside the class’s namespace.
Doing this allows you to also add a completely different
AnimationStyleenum to one of the other view controllers, without running into naming conflicts.
➤ In the close() method, set the animation style to .slide, so that it keeps using the animation you’re already familiar with:
@IBAction func close() {
dismissStyle = .slide // Add this line
dismiss(animated: true, completion: nil)
}
➤ Add a new Swift File to the project, named FadeOutAnimationController. This will handle the animation for the .fade style.
➤ Replace the source code of this new file with:
import UIKit
class FadeOutAnimationController: NSObject,
UIViewControllerAnimatedTransitioning {
func transitionDuration(
using transitionContext: UIViewControllerContextTransitioning?
) -> TimeInterval {
return 0.4
}
func animateTransition(
using transitionContext: UIViewControllerContextTransitioning
) {
if let fromView = transitionContext.view(
forKey: UITransitionContextViewKey.from) {
let time = transitionDuration(using: transitionContext)
UIView.animate(
withDuration: time,
animations: {
fromView.alpha = 0
}, completion: { finished in
transitionContext.completeTransition(finished)
}
)
}
}
}
This is mostly the same as the other animation controllers. The actual animation simply sets the view’s alpha value to 0 in order to fade it out.
➤ Switch to DetailViewController.swift and in the extension for the transitioning delegate, change the method that returns the animation controller for dismissing the pop-up to the following:
func animationController(
forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
switch dismissStyle {
case .slide:
return SlideOutAnimationController()
case .fade:
return FadeOutAnimationController()
}
}
Instead of always returning a new SlideOutAnimationController instance, it now looks at the value from dismissStyle. If it is .fade, then it returns an instance of the new FadeOutAnimationController object.
➤ Run the app, bring up the Detail pop-up and rotate to landscape. The pop-up should now fade out while the landscape view fades in — enable slow animations to clearly see what is going on.
And that does it. If you want to create more animations that can be used on dismissal, you only have to add a new value to the AnimationStyle enum and check for it in the animationController(forDismissed:) method. And build a new animation controller, of course.
That concludes the first version of the landscape screen. It doesn’t do much yet, but it’s already well integrated with the rest of the app. That’s worthy of a commit, methinks.
Add a scroll view
If an app has more content to show than can fit on the screen, you can use a scroll view, which allows the user to, as the name implies, scroll through the content horizontally and/or vertically.
You’ve already been working with scroll views all this time without knowing it: the UITableView object extends from UIScrollView.
In this section, you’ll use a scroll view of your own, in combination with a paging control, to show the artwork for all the search results, even if there are more images than can fit on the screen at once.
Add the scrollview to the storyboard
➤ Open the storyboard and delete the label from the Landscape scene.
➤ Now, drag a Scroll View into the scene and set it to completely cover the screen —667 x 375 if you’re using the iPhone SE (2nd generation) layout.
➤ Drag a new Page Control object into the scene — make sure you pick Page Control and not Page View Controller.
This gives you a small view with three white dots. Place it bottom center. The exact location doesn’t matter because you’ll move it to the right position later.
Important: Do not place the Page Control inside the Scroll View. They should be at the same level in the view hierarchy:
If you did drop your Page Control inside the Scroll View instead of on top, then you can rearrange it in the Document Outline.
That’s it for the design of the Landscape scene. The rest you will do in code.
Disable Auto Layout for a view controller
The other view controllers you’ve created all employed Auto Layout to resize them to the dimensions of the user’s screen, but here, you’re going to take a different approach. Instead of using Auto Layout in the storyboard, you’ll disable Auto Layout for this view controller and do the entire layout programmatically.
You do need to hook up the controls to outlets, of course.
➤ Add these outlets to LandscapeViewController.swift, and connect them in Interface Builder:
@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var pageControl: UIPageControl!
Next up you’ll disable Auto Layout for this view controller. The storyboard has a “Use Auto Layout” checkbox but you cannot use that. It would turn off Auto Layout for all the view controllers, not just this one.
➤ Replace LandscapeViewController.swift’s viewDidLoad() with:
override func viewDidLoad() {
super.viewDidLoad()
// Remove constraints from main view
view.removeConstraints(view.constraints)
view.translatesAutoresizingMaskIntoConstraints = true
// Remove constraints for page control
pageControl.removeConstraints(pageControl.constraints)
pageControl.translatesAutoresizingMaskIntoConstraints = true
// Remove constraints for scroll view
scrollView.removeConstraints(scrollView.constraints)
scrollView.translatesAutoresizingMaskIntoConstraints = true
}
Remember how, if you don’t add constraints of your own, Interface Builder will give the views automatic constraints? Well, those automatic constraints get in the way if you’re going to do your own layout. That’s why you need to remove these unwanted constraints from all the visible views in the view controller first.
You also do translatesAutoresizingMaskIntoConstraints = true. This allows you to position and size your views manually by changing their frame property.
When Auto Layout is enabled, you’re not really supposed to change the frame yourself — you can only indirectly move views into position by creating constraints. Modifying the frame by hand can cause conflicts with the existing constraints and bring all sorts of trouble — you don’t want to make Auto Layout angry, you wouldn’t like it when it’s angry.
For this view controller, it’s much more convenient to manipulate the frame property directly than it is making constraints — especially when you’re placing the buttons for the search results — which is why you’re disabling Auto Layout.
Note: Auto Layout doesn’t really get disabled, but with the “translates autoresizing mask” option set to true, UIKit will convert your manual layout code into the proper constraints behind the scenes. That’s also why you removed the automatic constraints because they will conflict with the new ones, possibly causing your app to crash.
Custom scroll view layout
Now that Auto Layout is out of the way, you can do your own layout. That happens in the viewWillLayoutSubviews() method.
➤ Add this new method:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let safeFrame = view.safeAreaLayoutGuide.layoutFrame
scrollView.frame = safeFrame
pageControl.frame = CGRect(
x: safeFrame.origin.x,
y: safeFrame.size.height - pageControl.frame.size.height,
width: safeFrame.size.width,
height: pageControl.frame.size.height)
}
The viewWillLayoutSubviews() method is called by UIKit as part of the layout phase of your view controller when it first appears on screen. It’s the ideal place for changing the frames of your views by hand.
The scroll view should always be as large as the entire screen, so you would think that you should make its frame equal to the main view’s bounds. This used to be the case till Apple introduced the iPhone X. But things change …
With the iPhone X, you had to make sure that your content did not appear where the iPhone X’s notch was, or where the scroll bar appeared at the bottom of the screen. So, Apple introduced the safe area concept — iOS would tell you what parts of a view were safe to have content on and each view would have several properties which defined the safe area for that view.
We make use of the safeAreaLayoutGuide property of the main view to get its layoutFrame — the safe area for the view in its own coordinate system — and then use that to set up the scroll view and the page control.
The page control is located at the bottom of the screen, and spans the entire width of the safe area. If this calculation doesn’t make any sense to you, then try to sketch what happens on a piece of paper. It’s what I usually do when writing my own layout code.
Note: If you’re confused about how the layout looks/works, easy way to get a better understanding is to set the background color of the scroll view and the page control to two distinctive colors like yellow and red and then run the app.
You will now see each control’s actual content area in different colors against the black background and show you how each view is laid out.
You will find that this is a good technique to use in debugging any view positioning/sizing related issue.
➤ Run the app and flip to landscape. Nothing much happens yet: the screen has the page control at the bottom (the dots) but it’s otherwise blank.
Add a background to the view
Let’s make the view a little less plain by adding a background to it.
➤ Open the Asset Catalog and add the LandscapeBackground@2x.png and LandscapeBackground@3x.png images from the Images folder from this app’s resources.
There are also LandscapeBackground-dark@2x.png and LandscapeBackground-dark@3x.png images in that folder – be sure that you don’t add those. You’ll be using them soon enough.
➤ Select the new LandscapeBackground image and in the Attributes inspector, change Appearances to Any, Dark.
This allows you to have a dark variant of the same image for when the app is showing the Dark appearance. Now you see where the other two images go, right?
Note: If you had not changed your Xcode settings since slicing the images previously, you might not see any chances after changing the Appearance above and still see the slicing interface. If so, just select Editor ▸ Show Overview from the Xcode menu to go back to the normal image view.
➤ Drag the LandscapeBackground-dark@2x.png and LandscapeBackground-dark@3x.png images into the 2x and 3x slots for Dark Appearance.
Now you have an image which works in either appearance. Cool!
➤ Add the following line to viewDidLoad() in LandscapeViewController.swift:
view.backgroundColor = UIColor(patternImage: UIImage(named: "LandscapeBackground")!)
This puts an image as the main view’s background. An image? But you’re setting the backgroundColor property, which is a UIColor, not a UIImage! Yup, that’s true, but UIColor has a cool trick that lets you use a tile-able image as a color.
If you look at the LandscapeBackground image in the asset catalog, you’ll see that it is a small square. When you set this image as a pattern image for the background, the image repeats to cover the entire area. Tile-able images can be used anywhere where you can use a UIColor.
You might be tempted to set the background for the scroll view instead of the main view and for most iOS devices, that would work just as well. In fact, it would work better in the case of the scroll view because when you scroll the view, the background would animate.
However, on an iPhone X, if you set the image as the background for the scroll view, you’ll notice that it doesn’t cover the whole screen. This is again due to that pesky safe area.
Try it for yourself and see the difference.
And if you have the bright idea of setting the background of the main view and the background of the scroll view to the same image in the hopes of having a seamless background that scrolls, all I can say is to try that too and see what happens. :]
Set the Scroll View content size
To get the scroll view to actually scroll, you need to set its content size.
➤ Add the following line to viewDidLoad():
scrollView.contentSize = CGSize(width: 1000, height: 1000)
It is very important to set the contentSize property when dealing with scroll views. This tells the scroll view how big the content area for the scroll view is — a scroll view’s inside (the content area), can be bigger than its actual bounds. If the content area is bigger than the scroll view’s bounds, that’s when the scroll view allows you to scroll.
People often forget this step and then they wonder why their scroll view doesn’t scroll. Unfortunately, you cannot set contentSize from Interface Builder, so it must be done from code.
➤ Run the app and try some scrolling — also, don’t forget to test both Light and Dark appearances.
You might not notice too much of a difference since the background is static, but if you pay close attention, you’ll notice that the horizontal and vertical scroll bars do move as you scroll around.
If the dots at the bottom also move while scrolling, then you’ve placed the page control inside the scroll view. Open the storyboard and in the Document Outline drag the Page Control below the Scroll View.
The page control itself doesn’t do anything yet. Before you can make that work, you first have to add some content to the scroll view.
Add result buttons
The idea is to show the search results in a grid:
Each of these results is really a button. Before you can place these buttons on the screen, you need to calculate how many will fit on the screen at once. Easier said than done, because different iPhone models have different screen sizes.
Time for some math! Let’s assume the app runs on a 4-inch device. In that case, the scroll view is 568 points wide by 320 points tall. It can fit 3 rows of 6 columns if you put each search result in a rectangle of 94 by 88 points. That comes to 3×6 = 18 search results on the screen at once. A search may return up to 200 results. Obviously, there is not enough room for everything and you will have to spread out the results over several pages.
One page contains 18 buttons. For the maximum number of results you will need 200 / 18 = 11.1111 pages, which rounds up to 12 pages. That last page will only be filled partially.
The 4.7-inch iPhone models have room for 7 columns plus some leftover vertical space, and the 5.5-inch iPhone Plus models can fit an extra row. Also there’s the iPhone X, Xs, and 11 Pro which can handle 4 rows and 9 columns. Not to mention the iPhone 11, Xr, Xs Max, and 11 Pro Max which can fit 4 rows by 8 columns.
That’s a lot of different possibilities!
You need to add the logic to LandscapeViewController so it can calculate how big the scroll view’s contentSize has to be. It will also need to add a UIButton object for each search result.
Once you have that working, you can display the artwork via that UIButton.
Of course, this means the app first needs to pass the array of search results to LandscapeViewController so it can use them for its calculations.
Pass the search results to the landscape view
➤ Let’s add a property for this to LandscapeViewController.swift:
var searchResults = [SearchResult]()
Initially, this will be an empty array. SearchViewController replaces it with the real array upon rotation to landscape.
➤ Assign the array to the new property in SearchViewController.swift:
func showLandscape(with coordinator: UIViewControllerTransitionCoordinator) {
. . .
if let controller = landscapeVC {
controller.searchResults = searchResults // add this line
. . .
You have to be sure to set searchResults before you access the view property from the LandscapeViewController, because that will trigger the view to be loaded and call viewDidLoad().
The view controller will read from the searchResults array in viewDidLoad() to build up the contents of its scroll view. But if you access controller.view before setting searchResults, this property will still be nil and no buttons will be created. The order in which you do things matters here!
➤ Switch back to LandscapeViewController.swift. Remove the line that sets scrollView.contentSize from viewDidLoad(). That was just for testing.
Now let’s go make those buttons.
Initial configuration
➤ Add a new instance variable:
private var firstTime = true
The purpose for this variable will become clear in a moment.
Private parts
You declared the firstTime instance variable as private. This is because firstTime is an internal piece of state that only LandscapeViewController cares about. It should not be visible to other objects.
You don’t want the other objects in your app to know about the existence of firstTime, or worse, actually try to use this variable. Strange things are bound to happen if some other view controller changes the value of firstTime when LandscapeViewController isn’t expecting the change.
We haven’t talked much about the distinction between interface and implementation yet, but what an object shows to the outside is different from what it has on the inside. That’s done on purpose because its internals — the implementation details — should not be of interest to anyone else, and are often even dangerous to expose since messing around with internal settings can crash the app.
It is considered good programming practice to hide as much as possible inside the object and only show a few things on the outside. To make certain variables and methods invisible from outside of your own class, you declare them to be private. That removes them from the object’s public interface.
Exercise: Find other variables and methods in the app that can be made
private.
➤ Add the following lines to the end of viewWillLayoutSubviews():
if firstTime {
firstTime = false
tileButtons(searchResults)
}
This calls a new method, tileButtons(_:), that performs the necessary math and places the buttons on the screen in neat rows and columns. This needs to happen just once, when the LandscapeViewController is added to the screen.
You may think that viewDidLoad() would be a good place for this, but at the point in the view controller’s lifecycle when viewDidLoad() is called, the view is not on the screen yet and has not been added into the view hierarchy. At this time, it doesn’t know how large the view should be. Only after viewDidLoad() is done does the view get resized to fit the actual screen.
So you can’t use viewDidLoad() for this. The only safe place to perform calculations based on the final size of the view — that is, any calculations that use the view’s frame or bounds — is in viewWillLayoutSubviews().
A warning: viewWillLayoutSubviews() may be invoked more than once! For example, it’s also called when the landscape view gets removed from the screen. You use the firstTime variable to make sure you only place the buttons once.
Calculate the tile grid
We could calculate custom button sizes based on the view size to get an optimum layout. And that’s exactly what we used to do previously.
However, given the number of different iPhone devices and the fact that more are being added, that approach is probably going to end up in a lot of additional code with each new iOS iteration.
To make things simpler, we are going to calculate a standard grid based on the view size.
➤ Add the new tileButtons(_:) method. It’s a bit long, so we’ll take it piece-by-piece.
// MARK: - Private Methods
private func tileButtons(_ searchResults: [SearchResult]) {
let itemWidth: CGFloat = 94
let itemHeight: CGFloat = 88
var columnsPerPage = 0
var rowsPerPage = 0
var marginX: CGFloat = 0
var marginY: CGFloat = 0
let viewWidth = scrollView.bounds.size.width
let viewHeight = scrollView.bounds.size.height
// 1
columnsPerPage = Int(viewWidth / itemWidth)
rowsPerPage = Int(viewHeight / itemHeight)
// 2
marginX = (viewWidth - (CGFloat(columnsPerPage) * itemWidth)) * 0.5
marginY = (viewHeight - (CGFloat(rowsPerPage) * itemHeight)) * 0.5
// TODO: more to come here
}
The method must decide how many rows and columns of 94 x 88 button can be placed on the view based on the view width and height. So here’s the important parts:
-
You calculate the number of columns needed by dividing the view width by the button width and the number of rows needed by dividing the view height by the button height.
Note that
viewWidthanditemWidthareCGFloatvalues and the result of the division would be aCGFloatvalue as well. ButcolumnsPerPageis anIntvalue. So you have to cast the result of the division to anIntin order to assign the value tocolumnsPerPage. -
You calculate how much space is left over horizontally by finding the difference between the view width and the width of all the columns, and then divide the result by 2 — which is the same as multiplying by 0.5 — to get the padding on the left and right. Similarly, you calculate the padding on the top and bottom as well.
From now on, you’ll keep adding more code to the end of tileButtons() (where the TODO comment is) till the method is complete.
➤ Add the following lines to tileButtons():
// Button size
let buttonWidth: CGFloat = 82
let buttonHeight: CGFloat = 82
let paddingHorz = (itemWidth - buttonWidth) / 2
let paddingVert = (itemHeight - buttonHeight) / 2
You’ve already specified that each search result gets a grid square of 94 by 88 points, but that doesn’t mean you need to make the buttons that big as well.
The image you’ll put on the buttons is 60×60 pixels, so that leaves quite a gap around the image. After playing with the design a bit, I decided that the buttons will be 82×82 points (buttonWidth and buttonHeight), leaving a small amount of padding between each button and its neighbors (paddingHorz and paddingVert).
Add buttons
Now you can loop through the array of search results and make a new button for each SearchResult object.
➤ Add the following lines to tileButtons():
// Add the buttons
var row = 0
var column = 0
var x = marginX
for (index, result) in searchResults.enumerated() {
// 1
let button = UIButton(type: .system)
button.backgroundColor = UIColor.white
button.setTitle("\(index)", for: .normal)
// 2
button.frame = CGRect(
x: x + paddingHorz,
y: marginY + CGFloat(row) * itemHeight + paddingVert,
width: buttonWidth,
height: buttonHeight)
// 3
scrollView.addSubview(button)
// 4
row += 1
if row == rowsPerPage {
row = 0; x += itemWidth; column += 1
if column == columnsPerPage {
column = 0; x += marginX * 2
}
}
}
Here is how this works:
-
Create the
UIButtonobject. For debugging purposes, you give each button a title with the array index. If there are 200 results in the search, you also should end up with 200 buttons. Setting the index on the button will help to verify this. -
When you make a button by hand, you always have to set its
frame. Using the measurements you figured out earlier, you determine the position and size of the button. Notice thatCGRect’s properties are allCGFloatbutrowis anInt. You need to convertrowto aCGFloatbefore you can use it in the calculation. -
You add the new button object to the
UIScrollViewas a subview. After the first 18 or so buttons (depending on the screen size), this places any subsequent buttons out of the visible range of the scroll view, but that’s the whole point. As long as you set the scroll view’scontentSizeaccordingly, the user can scroll to view these other buttons. -
You use the
xandrowvariables to position the buttons, going from top to bottom (by increasingrow). When you’ve reached the bottom (rowequalsrowsPerPage), you go up again to row 0 and skip to the next column (by increasing thecolumnvariable).When the
columnreaches the end of the screen (equalscolumnsPerPage), you reset it to 0 and add any leftover space tox(twice the X-margin).Note that in Swift you can put multiple statements on a single line by separating them with a semicolon. I did that to save some space, you can have those statements on separate lines, if you so prefer.
If this sounds like gobbledygook to you, I suggest you play around a bit with these calculations to gain insight into how they work. It’s not rocket science, but it does require some mental gymnastics. Tip: Sketching the process on paper can help!
Note: By the way, did you notice what happened in the
for inloop?
for (index, result) in searchResults.enumerated() {
This
for...inloop steps through theSearchResultobjects from the array, but with a twist. By calling the array’senumerated()method, you get a tuple containing not only the nextSearchResultobject but also its index in the array.A tuple is nothing more than a temporary list with two or more items in it. Here, the tuple is
(index, result). This is a neat trick to loop through an array and get both the objects and their indices.
➤ Finally, add the last part of this very long method:
// Set scroll view content size
let buttonsPerPage = columnsPerPage * rowsPerPage
let numPages = 1 + (searchResults.count - 1) / buttonsPerPage
scrollView.contentSize = CGSize(
width: CGFloat(numPages) * viewWidth,
height: scrollView.bounds.size.height)
print("Number of pages: \(numPages)")
At the end of the method you calculate the contentSize for the scroll view based on how many buttons fit on a page and the number of SearchResult objects.
You want the user to be able to “page” through these results — you’ll enable this feature shortly — rather than simply scroll. So, you should always make the content width a multiple of the scroll width (568, 667, 736, or 812 points). You can then determine how many pages you need with a simple formula.
Note: Dividing an integer value by an integer always results in an integer. If
buttonsPerPageis 18 (3 rows × 6 columns) and there are fewer than 18 search results,searchResults.count / buttonsPerPageis 0.It’s important to realize that
numPageswill never have a fractional value because all the variables involved in the calculation areInts, which makesnumPagesanInttoo.That’s why the formula is
1 + (searchResults.count – 1) / buttonsPerPage.If there are 18 results, exactly enough to fill a single page,
numPages = 1 + 17/18 = 1 + 0 = 1. But if there are 19 results, the 19th result needs to go on the second page, andnumPages = 1 + 18/18 = 1 + 1 = 2. Plug in some other values to verify this formula is correct.
I also threw in a print() for good measure, so you can verify that you really end up with the right number of pages.
Note: Xcode currently gives a warning “Immutable value ‘result’ was never used; consider replacing with’ _’ or removing it”. That warning will go away once you use the
resultvariable in the next section.
➤ Run the app, do a search, and rotate to landscape. You should now see a whole bunch of buttons neatly laid out in a grid.
Scroll all the way to the right and you’ll notice that the last button is titled 199. That is 200 buttons indeed — you started counting at 0, remember?.
Just to make sure that this logic works properly, you should test a few different scenarios. What happens when there are fewer results than 18 — the amount that fit on a single page on an iPhone SE? What happens when there are exactly 18 search results? How about 19, one more than can go on a single page?
The easiest way to test these situations is to change the &limit parameter in the search URL.
Exercise: Try these situations for yourself and see what happens.
➤ Also test when there are no search results. The landscape view should now be empty. You’ll add a “Nothing Found” label to this screen too, in a bit.
Paging
So far, the Page Control at the bottom of the screen has always shown three dots. And there wasn’t much paging to be done on the scroll view either.
In case you’re wondering what paging means: if the user has moved the scroll view a certain amount, it should snap to a new page.
With paging enabled, you can quickly flick through the contents of a scroll view, without having to drag it all the way. You’re no doubt familiar with this effect because it is what the iPhone uses in its springboard. Many other apps use the effect too, for example, the Weather app uses paging to flip between the cards for different cities.
Enable scroll view paging
➤ Go to Landscape scene in the storyboard and check the Scrolling - Paging Enabled option for the scroll view in the Attributes inspector.
There, that was easy! Now run the app and the scroll view will let you page rather than scroll. That’s cool, but you also need to do something with the page control at the bottom of the screen.
Configure the page control
➤ Switch to LandscapeViewController.swift and add this line to viewDidLoad():
pageControl.numberOfPages = 0
This effectively hides the page control, which is what you want to do when there are no search results.
➤ Add the following lines to the end of tileButtons():
pageControl.numberOfPages = numPages
pageControl.currentPage = 0
This sets the number of dots that the page control displays to the number of pages that you calculated. The active dot — the white one — needs to be synchronized with the active page in the scroll view. Currently, it never changes unless you tap in the page control and even then it has no effect on the scroll view.
To get this to work, you’ll have to make the page control talk to the scroll view, and vice versa. The view controller must become the delegate of the scroll view so it will be notified when the user is flicking through the pages.
Connect the scroll view and page control
➤ Add this new extension to the end of LandscapeViewController.swift:
extension LandscapeViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let width = scrollView.bounds.size.width
let page = Int((scrollView.contentOffset.x + width / 2) / width)
pageControl.currentPage = page
}
}
This is a UIScrollViewDelegate method. You figure out what the index of the current page is by looking at the contentOffset property of the scroll view. This property determines how far the scroll view has been scrolled and is updated while you’re dragging the scroll view.
Unfortunately, the scroll view doesn’t simply tell us, “The user has flipped to page X”. So, you have to calculate this yourself. If the content offset gets beyond halfway on the page (width/2), the scroll view will move to the next page. In that case, you update the pageControl’s active page number.
You also need to know when the user taps on the Page Control so you can update the scroll view. There is no delegate for this, but you can use a regular @IBAction method for it.
➤ Add the action method:
// MARK: - Actions
@IBAction func pageChanged(_ sender: UIPageControl) {
scrollView.contentOffset = CGPoint(
x: scrollView.bounds.size.width * CGFloat(sender.currentPage),
y: 0)
}
This works the other way around: when the user taps in the Page Control, its currentPage property gets updated. You use that to calculate a new contentOffset for the scroll view.
➤ In the storyboard, for the Landscape scene, Control-drag from the Scroll View to the view controller and select delegate.
➤ Also Control-drag from the Page Control to the view controller and select pageChanged: under Sent Events.
➤ Try it out, the page control and the scroll view should now be in sync.
The transition from one page to another after tapping in the page control is still a little abrupt, though. An animation would help here.
Exercise: See if you can animate what happens in
pageChanged(_:).
You can simply wrap the code from the action method in an animation block:
@IBAction func pageChanged(_ sender: UIPageControl) {
UIView.animate(
withDuration: 0.3,
delay: 0,
options: [.curveEaseInOut],
animations: {
self.scrollView.contentOffset = CGPoint(
x: self.scrollView.bounds.size.width * CGFloat(sender.currentPage),
y: 0)
},
completion: nil)
}
You’re using a version of the UIView animation method that allows you to specify options because the “Ease In, Ease Out” timing (.curveEaseInOut) looks good here.
➤ This is a good time to commit.
Download the artwork
First, let’s give the buttons a nicer look.
Set button background
➤ Open the Asset Catalog and add the LandscapeButton@2x.png and LandscapeButton@3x.png images from the Images folder from this app’s resources. As before, do not add the -dark variant of the image yet.
➤ Select the new LandscapeButton image and in the Attributes inspector, change Appearances to Any, Dark.
➤ Drag the LandscapeButton-dark@2x.png and LandscapeButton-dark@3x.png images into the 2x and 3x slots for Dark Appearance.
That sets up the images we’ll use. Now, you can use them via code.
➤ Replace the button creation code in tileButtons() (in LandscapeViewController.swift) with:
let button = UIButton(type: .custom)
button.setBackgroundImage(UIImage(named: "LandscapeButton"), for: .normal)
Instead of a regular button, you now make a .custom one, and you give it a background image instead of a white background and a title.
If you run the app, it will look like this for each Appearance:
Display button images
Now you have to download the artwork images, if they haven’t already been downloaded and cached by the table view, and put them on the buttons.
Problem: You’re dealing with UIButtons here, not UIImageViews, so you cannot simply use that handy extension from earlier. Fortunately, the code is very similar!
➤ Add a new method to LandscapeViewController.swift:
private func downloadImage(
for searchResult: SearchResult,
andPlaceOn button: UIButton
) {
if let url = URL(string: searchResult.imageSmall) {
let task = URLSession.shared.downloadTask(with: url) {
[weak button] url, _, error in
if error == nil, let url = url,
let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
DispatchQueue.main.async {
if let button = button {
button.setImage(image, for: .normal)
}
}
}
}
task.resume()
}
}
This looks very much like what you did in the UIImageView extension.
First you get a URL instance with the link to the 60×60-pixel artwork, and then you create a download task. Inside the completion handler you put the downloaded file into a UIImage, and if all that succeeds, use DispatchQueue.main.async to place the image on the button.
➤ Add the following line to tileButtons() to call this new method, right after where you create the button:
downloadImage(for: result, andPlaceOn: button)
And that should do it. Run the app and you’ll get some cool-looking buttons:
Note: The Xcode warning about
resultis gone, but now it gives the same message for theindexvariable. Xcode doesn’t like it if you declare variables but don’t use them. You’ll useindexagain later in this app but in the mean time, you can replace it by the_wildcard symbol to stop Xcode from complaining.
Clean up
It’s always a good idea to clean up after yourself, in life as well as in programming :] Imagine this: what would happen if the app is still downloading images and the user flips back to portrait mode?
At that point, the LandscapeViewController is deallocated but the image downloads keep going. That is exactly the sort of situation that can crash your app if not handled properly.
To avoid ownership cycles, you capture the button with a weak reference. When LandscapeViewController is deallocated, so are the buttons. So, the completion handler’s captured button reference automatically becomes nil. The if let inside the DispatchQueue.main.async block will now safely skip button.setImage(for). No harm done. That’s why you wrote [weak button].
However, to conserve resources, the app should really stop downloading these images because they are not needed. Otherwise, it’s just wasting bandwidth and battery life, and users don’t take too kindly to apps that do this.
➤ Add a new property to LandscapeViewController.swift:
private var downloads = [URLSessionDownloadTask]()
This array will keep track of all the active URLSessionDownloadTask objects.
➤ Add the following line to the end of downloadImage(for:andPlaceOn:), right after where you resume the download task:
downloads.append(task)
➤ And finally, add a deinit method to cancel any operations that are still on the way:
deinit {
print("deinit \(self)")
for task in downloads {
task.cancel()
}
}
This will stop the download for any button whose image was still pending or in transit. Good job, partner!
➤ Commit your changes.
Exercise: Despite what the iTunes web service promises, not all of the artwork is truly 60×60 pixels. Some of it is bigger, some are not even square, and so, it might not always fit nicely in the button. Your challenge is to use the image sizing code from MyLocations to always resize the image to 60×60 points before you put it on the button. Note that we’re talking points here, not pixels — on Retina devices, the image should actually end up being 120×120 or even 180×180 pixels in size.
Note: In this section you learned how to create a grid-like view using a
UIScrollView. iOS comes with a versatile class,UICollectionView, that lets you do the same thing — and much more! — without having to resort to the sort of math you did intileButtons(). To learn more aboutUICollectionView, check out the websitehttps://www.raywenderlich.com/library?q=collectionview&sort_order=relevance
You can find the project files for this chapter under 39-Landscape in the Source Code folder.