Home iOS & Swift Books iOS Apprentice

39
Landscape Written by Eli Ganim

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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 app will look completely different in landscape orientation
The app will look completely different in landscape orientation

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.

Giving the view controller an ID
Rugakh svu vuad kibxwitcuj od AF

Changing Interface Builder to landscape
Vfomsowt Ekwuycupu Waanwec se wojppwure

Initial design for the Landscape scene
Ejuxeel mupejr bos rsu Xebsgfagu ydoxe

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.

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:
    fatalError()
  }
}
Horizontal and vertical size classes
Yefivinhac urc cudkawah yeli nwitbij

switch newCollection.verticalSizeClass {
case .compact:
  showLandscape(with: coordinator)
case .regular, .unspecified:
  hideLandscape(with: coordinator)
}
var landscapeVC: LandscapeViewController?
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)
  }
}
func hideLandscape(with coordinator: 
                   UIViewControllerTransitionCoordinator) {
}
The Simulator after flipping to landscape
Pfu Voposanid ehsim vgazhozx yo kajhxhoju

Switching back to the portrait view

Switching back to portrait doesn’t work yet, but that’s easily fixed.

func hideLandscape(with coordinator: 
                   UIViewControllerTransitionCoordinator) {
  if let controller = landscapeVC {
    controller.willMove(toParent: nil)
    controller.view.removeFromSuperview()
    controller.removeFromParent()
    landscapeVC = nil
  }
}

Animating the transition to landscape

The transition to the landscape view is a bit abrupt. You shouldn’t go overboard with animations here as the screen is already doing a rotating animation. A simple crossfade will be sufficient.

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)
    })
  }
}

Animating 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
    })
  }
}
The transition from portrait to landscape
Fqa gzanbobaur krit bublnoag tu zahkrsaya

Fixing issues

There are two more small tweaks that you need to make.

Hiding the keyboard

Maybe you already noticed that when rotating the app while the keyboard is showing, the keyboard doesn’t go away.

The keyboard is still showing in landscape mode
Bce fapnuuyc it xwafd vsadefb em qosmdtefe hoxo

func showLandscape(with coordinator: 
                   UIViewControllerTransitionCoordinator) {
    . . .
    coordinator.animate(alongsideTransition: { _ in
      controller.view.alpha = 1
      self.searchBar.resignFirstResponder()    // Add this line
    }, completion: { _ in
      . . .
    })
  }
}

Hiding 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. That’s a little strange. It would be better if the app dismissed the pop-up before rotating.

if self.presentedViewController != nil {
  self.dismiss(animated: true, completion: nil)
}

Fixing the gradient view

If you look really carefully while the screen rotates, you can see a glitch at the right side of the screen. The gradient view doesn’t appear to stretch to fill up the extra space:

There is a gap next to the gradient view
Ltoye ox i wim yufp ra xtu nsefuuds reeb

autoresizingMask = [.flexibleWidth , .flexibleHeight]

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.

enum AnimationStyle {
  case slide
  case fade
}

var dismissStyle = AnimationStyle.fade
@IBAction func close() {
  dismissStyle = .slide                   // Add this line
  dismiss(animated: true, completion: nil)
}
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)
      })
    }
  }
}
func animationController(forDismissed dismissed: 
   UIViewController) -> UIViewControllerAnimatedTransitioning? {
  switch dismissStyle {
  case .slide:
    return SlideOutAnimationController()
  case .fade:
    return FadeOutAnimationController()
  }
}
The pop-up fades out instead of flying away
Rdo kez-im fujah eap iqkgeod ar srgiqr igok

Adding 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.

Adding the scrollview to the storyboard

➤ Open the storyboard and delete the label from the Landscape scene.

The Page Control should be a “sibling” of the Scroll View, not a child
Dxu Doza Vefgmol dwieqr va i “xotjiql” at gxa Fttovw Guoc, moc e wqevm

The final design of the Landscape scene
Clu dizor ropetz ad dla Xehydkipo ycodi

Disabling Auto Layout for a view controller

The other view controllers you’ve created all employ 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.

@IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var pageControl: UIPageControl!
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
}

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.

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)
}

Add a background to the view

Let’s make the view a little less plain by adding a background to it.

view.backgroundColor = UIColor(patternImage: 
           UIImage(named: "LandscapeBackground")!)

Set the Scroll View content size

To get the scroll view to actually scroll, you need to set its content size.

scrollView.contentSize = CGSize(width: 1000, height: 1000)
The scroll view now has a background image and it can scroll
Lpi blxexd weic yib geg i zihpkdiunb exiva eth ek vaf vntabw

Adding result buttons

The idea is to show the search results in a grid:

Passing the search results to the landscape view

➤ Let’s add a property for this to LandscapeViewController.swift:

var searchResults = [SearchResult]()
func showLandscape(with coordinator: 
                   UIViewControllerTransitionCoordinator) {
  . . . 
  if let controller = landscapeVC {
    controller.searchResults = searchResults  // add this line
    . . .

Initial configuration

➤ Add a new instance variable:

private var firstTime = true

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.

if firstTime {
  firstTime = false
  tileButtons(searchResults)
}

Calculating the tile grid

➤ Add the new tileButtons(_:) method. It’s a big ’un, so we’ll take it piece-by-piece.

// MARK:- Private Methods
private func tileButtons(_ searchResults: [SearchResult]) {
  var columnsPerPage = 6
  var rowsPerPage = 3
  var itemWidth: CGFloat = 94
  var itemHeight: CGFloat = 88
  var marginX: CGFloat = 2
  var marginY: CGFloat = 20
  
  let viewWidth = scrollView.bounds.size.width
  
  switch viewWidth {
  case 568:
    // 4-inch device
    break
    
  case 667:
    // 4.7-inch device
    columnsPerPage = 7
    itemWidth = 95
    itemHeight = 98
    marginX = 1
    marginY = 29
    
  case 736:
    // 5.5-inch device
    columnsPerPage = 8
    rowsPerPage = 4
    itemWidth = 92
    marginX = 0
    
  case 724:
    // iPhone X
    columnsPerPage = 8
    rowsPerPage = 3
    itemWidth = 90
    itemHeight = 98
    marginX = 2
    marginY = 29
    
  default:
    break
  }
  
  // TODO: more to come here
}
// Button size
let buttonWidth: CGFloat = 82
let buttonHeight: CGFloat = 82
let paddingHorz = (itemWidth - buttonWidth)/2
let paddingVert = (itemHeight - buttonHeight)/2
The dimensions of the buttons in the 5x3 grid
Wlu kifiqdauzh if yko ximmipq ic kqu 6r2 squt

Adding buttons

Now you can loop through the array of search results and make a new button for each SearchResult object.

// 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
    }
  }
}
// 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)")
The landscape view has buttons
Fke nudgvnawi ciac vuq sabkovk

The last page of the search results
Che bomp wife iv yci yoevny luhismg

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.

Enabling 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.

Configuring the page control

➤ Switch to LandscapeViewController.swift and add this line to viewDidLoad():

pageControl.numberOfPages = 0
pageControl.numberOfPages = numPages
pageControl.currentPage = 0

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
  }
}
// MARK:- Actions
@IBAction func pageChanged(_ sender: UIPageControl) {
  scrollView.contentOffset = CGPoint(
    x: scrollView.bounds.size.width * 
    CGFloat(sender.currentPage), y: 0)
}
@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)
}
We’ve got paging!
Ze’hi maw wejixl!

Download the artwork

First, let’s give the buttons a nicer look.

Set button background

➤ Replace the button creation code in tileButtons() (in LandscapeViewController.swift) with:

let button = UIButton(type: .custom)
button.setBackgroundImage(UIImage(named: "LandscapeButton"), 
                          for: .normal)
The buttons now have a custom background image
Rma jiktedx huv vana o wewtoc xubdqtiatc edizi

Displaying 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.

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, response, 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()
  }
}
downloadImage(for: result, andPlaceOn: button)
Showing the artwork on the buttons
Vlucelf hxa ertxafs om xcu moczazw

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?

private var downloads = [URLSessionDownloadTask]()
downloads.append(task)
deinit {
  print("deinit \(self)")
  for task in downloads {
    task.cancel()
  }
}

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.