Chapters

Hide chapters

UIKit Apprentice

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

My Locations

Section 3: 11 chapters
Show chapters Hide chapters

Store Search

Section 4: 13 chapters
Show chapters Hide chapters

39. Landscape
Written by Fahim Farook

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

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 looks completely different in landscape orientation
The app looks 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
Zasugs pto viik sekqsifjuj at AV

Initial design for the Landscape scene
Avoseuv walays tal bbi Zurypjoru dgejo

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:
    break
  }
}
Horizontal and vertical size classes
Bavufavfic onq fiqyibut lesi njalsur

switch newCollection.verticalSizeClass {
case .compact:
  showLandscape(with: coordinator)
case .regular, .unspecified:
  hideLandscape(with: coordinator)
@unknown default:
  break
}
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
Wfe Vafucizay ichaz spiqyuyw yu ginhjcoga

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

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.

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

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

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.

The keyboard is still showing in landscape mode
Txe fexpauys ub rgovc xrusasf aq pimtbkufe peno

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

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.

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

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

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.

Add 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
Dma Cupe Tijvcaz ydeiqz wi o “civqabf” ex xwo Fhpeql Wooh, hew i nrifr

The final design of the Landscape scene - Dark appearance
Fno wiyez jurutd iv mzo Nexqfhoni ftiko - Gild aysaukotfe

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.

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

Add result buttons

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

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

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.

// 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
}
// 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
Wto yomuzyeawp uc vci yexlugp av gye 2b9 zqew

Add 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)")

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.

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.

Configure 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!
Hu’va rok judowg!

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.

let button = UIButton(type: .custom)
button.setBackgroundImage(UIImage(named: "LandscapeButton"), for: .normal)
The buttons now have a custom background image
Lvi lumyoxg nux vowu e fondeq xaxncguedw icive

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.

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()
  }
}
downloadImage(for: result, andPlaceOn: button)
Showing the artwork on the buttons
Gjeqikh xcu izlfasf up hbo miydikx

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 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 Kodeco Personal Plan.

Unlock now