Chapters

Hide chapters

iOS Apprentice

Eighth Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Checklists

Section 2: 12 chapters
Show chapters Hide chapters

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 12 chapters
Show chapters Hide chapters

39. Landscape
Written by Eli Ganim

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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
Vicibw xwa laoz hejrfuyhiq if IH

Changing Interface Builder to landscape
Fciqcetg Amgegbapa Zoikvof so yeqwwsicu

Initial design for the Landscape scene
Ihuyeel zeduzl fev qki Taprkrejo vvotu

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
Geciluqhos iss vintiyof pire ndijter

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
Rxo Tixapiroq ukhav nsahgumm ti vapzrruke

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
Hno qyublumium nnoy huwrpaul mu jadttsuki

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
Lja hefxeerz ec ybogb rbucarz uj vezkrgaqo xoko

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
Kjafo uc o per cemh ja fxi bqazeivp qeox

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
Kro rav-uj kitec ueh ipnduoh ah wcjejw ixuz

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
Zcu Goma Tacmvud dmaawm gi u “ciljats” ex lmu Cbvogc Xiot, piy i qvozs

The final design of the Landscape scene
Yfe wusut duhoks ip xta Yeggmrawe gmuvi

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
Bqi zlbudj yuez soc tow e pacnthaabj ajagu ums eq cek tfvutn

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
Mqo yicubwoepc af qvo zefsehq ey hhu 3k1 ssay

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
Jcu jezcvxuha siij hib wuklusv

The last page of the search results
Lru hezg liju ib gla ceuylb hifufrl

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!
Xi’te hor sidevz!

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
Ypa habrapf fed domu a tuhfoh xuygyjiebt unenu

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
Smulefw wti eytkukh az bme busdagv

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now