Skip to content

Lesson 3.2: Swift Generics

Ben Gohlke edited this page May 13, 2021 · 5 revisions

Learning Objectives

  • understand the benefits of generics
  • identify generic types and methods based on the syntax of their definition
  • understand how protocols and generics work together
  • understand the benefits of protocols with associated types

Lab Instructions

In this lab, you will build an app that allows users to play Morse code messages. The app will have a text field, where the user can enter a message, and a play button. There will be three modes of playback: haptic, visual, or both. “Haptic feedback” refers to the small taps and vibrations you feel when interacting with your device.

As you may know, Morse code represents letters and other symbols as combinations of short and long signals called dots and dashes. A Morse code message can be relayed in many forms, such as audible tones or flashing light. A Morse code pattern familiar to many is “SOS,” the international signal for distress, which is represented in Morse code by three dots, three dashes, and three dots: ● ● ● ■ ■ ■ ● ● ●.

Morse code follows several rules regarding the length of dots, dashes, and the spaces between them:

  1. The length of a dot defines the unit of time; it is 1 unit long.
  2. A dash is 3 units long.
  3. The space between dots and dashes in the same character is 1 unit long.
  4. The space between characters is 3 units long.
  5. The space between words is 7 units long.

The following image shows the letters and digits in Morse code:

Diagram of Morse Code alphabet

Step 1

Review Starter Project

  • Starter project

Begin by reviewing the starter project for this lab. It includes a MorseCodeSignal enum defining short and long cases to represent dots and dashes. The MorseCodeCharacter struct has an array of MorseCodeSignals and defines a dictionary that maps letters, numbers, and some punctuation symbols to their Morse code equivalents. You can initialize an instance of MorseCodeCharacter using a Character, so long as it maps to a defined Morse code pattern. MorseCodeSignal and MorseCodeCharacter are the foundational models that make up words and, ultimately, messages.

Look over the two other models that are also provided. MorseCodeWord has a property that is an array of MorseCodeCharacters, and MorseCodeMessage has a property that is an array of MorseCodeWords. Finally, review the methods and outlets provided in ViewController.swift and the app's interface in Main.storyboard. Make sure you understand how everything works together.

Step 2

Set Up Playback Events

Your task for the lab is to add the following protocol and create two concrete types that adopt it, enabling a MorseCodeMessage to be played.

protocol MorseCodePlayer {
  func play(message: MorseCodeMessage) throws
}

Before you proceed, add the MorseCodePlayer protocol above as a new Swift file.

Take a moment to consider how a message will be played back over time, given the rules laid out above. There are two distinct events: on and off. Both events have an associated time duration (a dot is 1 unit of time, as is the space between signals within a letter, etc.). Swift offers a perfect solution to represent this: an enum with associated values.

  • Create a new Swift file named “MorseCodePlaybackEvent” and define the following enum:
enum MorseCodePlaybackEvent {
  case on(TimeInterval)
  case off(TimeInterval)
}
  • To allow for easy access of the associated TimeInterval value, add the following computed property to the enum:
var duration: TimeInterval {
  switch self {
    case .on(let duration):
      return duration
    case .off(let duration):
      return duration
  }
}
  • You can now represent a sequence of these events using an array. For an SOS message, it would look like the following:
let sosEvents: [MorseCodePlaybackEvent] = [
  // S
  .on(1), .off(1), .on(1), .off(1), .on(1), .off(3),
  // O
  .on(3), .off(1), .on(3), .off(1), .on(3), .off(3),
  // S
  .on(1), .off(1), .on(1), .off(1), .on(1), .off(3)
]
  • It would be helpful if MorseCodeSignal, MorseCodeCharacter, MorseCodeWord, and MorseCodeMessage each provided its own representation as an array of playback events—a perfect use case for a protocol! Add the following protocol in a new Swift file named “MorseCodePlaybackEventRepresentable.swift”:
protocol MorseCodePlaybackEventRepresentable {
  var playbackEvents: [MorseCodePlaybackEvent] { get }
}
  • Rather than using literal TimeInterval values, like in the SOS example above, it would be best to define a unit of time for a Morse code signal. This will allow you to adjust it in one place if needed and help reduce errors. Add the following TimeInterval extension within MorseCodePlaybackEventRepresentable.swift:
extension TimeInterval {
  static let morseCodeUnit: TimeInterval = 0.2
}
  • Make MorseCodeSignal adopt the MorseCodePlaybackEventRepresentable protocol by adding an extension (in MorseCodePlaybackEventRepresentable.swift). A signal will have a single playback event in its playbackEvents array. Recall that a short signal is 1 unit of time and a long signal is 3 units of time. Use a computed property to return an array containing a single “on” playback event for each case of MorseCodeSignal to satisfy the playbackEvents property from the protocol.

  • With MorseCodeSignal adopting MorseCodePlaybackEventRepresentable, you'll next want to make MorseCodeCharacter adopt it. MorseCodeCharacter has a signals property, which is [MorseCodeSignal]. Now that MorseCodeSignal has a playbackEvents property, you can use the signals property to your advantage. The Morse code rules state that 1 unit of time is placed between each signal within the same character. You'll need to insert an .off(morseCodeUnit) between each signal to meet that requirement. Add an extension making MorseCodeCharacter adopt MorseCodePlaybackEventRepresentable. Come up with a solution for the playbackEvents computed property that uses the playback events from each MorseCodeSignal in the signals array and inserts an off event, of a single unit of time, between each signal.

  • Now, you'll make MorseCodeWord adopt MorseCodePlaybackEventRepresentable. The solution should be very similar to what you used for MorseCodeCharacter, except that the off event between each character should be 3 units of time. Are you seeing a pattern?

  • Finally, make MorseCodeMessage adopt MorseCodePlaybackEventRepresentable. At this point, you are likely seeing a pattern—one that might be reusable for each implementation.

Step 3

Create a Reusable Playback Event

Consider the following solution to the playbackEvents property for MorseCodeCharacter:

var playbackEvents: [MorseCodePlaybackEvent] {
  signals.flatMap { signal in
    signal.playbackEvents + [.off(.morseCodeUnit)]
  }
}

An off(.morseCodeUnit) event is appended to each signal's playbackEvents, and the flatMap function is used to return a flattened array containing the concatenated results.

An almost-identical solution can be used for MorseCodeWord:

var playbackEvents: [MorseCodePlaybackEvent] {
  characters.flatMap { character in
    character.playbackEvents + [.off(.morseCodeUnit * 3)]
  }
}

The only differences are that characters replaces signals and the duration for the off event is multiplied by 3. If these two values could be passed in, you could reuse the logic for each component of what ultimately makes up a MorseCodeMessage. Where could you make these values available? How about on the MorseCodePlaybackEventRepresentable protocol? Update the protocol to the following:

protocol MorseCodePlaybackEventRepresentable {
  var playbackEvents: [MorseCodePlaybackEvent] { get }
  var components: [MorseCodePlaybackEventRepresentable] { get }
  var componentSeparationDuration: TimeInterval { get }
}

To conform to this updated version of the protocol, each type needs to return a value for the components and componentSeparationDuration properties.

  • Update each conforming type. components should return an array of the components that make up the type (e.g., for MorseCodeWord, this would be characters, while MorseCodeSignal would return an empty array as it has no components). Ensure you have no compilation errors before moving forward—this will tell you that each type conforms correctly.

  • The next task is to write a protocol extension for MorseCodePlaybackEventRepresentable that provides a default implementation of playbackEvents using the protocol's new properties.

extension MorseCodePlaybackEventRepresentable {
  var playbackEvents: [MorseCodePlaybackEvent] {
    components.flatMap { component in
      component.playbackEvents + [.off(componentSeparationDuration)]
    }
  }
}

With the default implementation in place, you can remove the playbackEvents implementations on every type except MorseCodeSignal, resulting in very concise code. It should look close to the following:

extension MorseCodeSignal: MorseCodePlaybackEventRepresentable {
  var playbackEvents: [MorseCodePlaybackEvent] {
    switch self {
      case .short:
        return [.on(.morseCodeUnit)]
      case .long:
        return [.on(.morseCodeUnit * 3)]
    }
  }
 
  var components: [MorseCodePlaybackEventRepresentable] { [] }
  var componentSeparationDuration: TimeInterval { 0.0 }
}
 
extension MorseCodeCharacter: MorseCodePlaybackEventRepresentable {
  var components: [MorseCodePlaybackEventRepresentable] { signals }
  var componentSeparationDuration: TimeInterval { .morseCodeUnit }
}
 
extension MorseCodeWord: MorseCodePlaybackEventRepresentable {
  var components: [MorseCodePlaybackEventRepresentable] { characters }
  var componentSeparationDuration: TimeInterval { .morseCodeUnit * 3 }
}
 
extension MorseCodeMessage: MorseCodePlaybackEventRepresentable {
  var components: [MorseCodePlaybackEventRepresentable] { words }
  var componentSeparationDuration: TimeInterval { .morseCodeUnit * 7 }
}

extension MorseCodePlaybackEventRepresentable {
  var playbackEvents: [MorseCodePlaybackEvent] {
    components.flatMap { component in
      component.playbackEvents + [.off(componentSeparationDuration)]
    }
  }
}

Step 4

Visual Morse Code Player

Flashing a light is a common way of transmitting Morse code. The visual playback of Morse code messages in the app will be done by changing the background color of ViewController's view property, simulating a light turning on and off. When signaling, the background will be white, and spaces between signals will be black.

Now that you can get a sequence of MorseCodePlaybackEvents from a MorseCodeMessage instance, you're in a good position to write the visual player.

  • Add a new UIView subclass named VisualMorseCodePlayerView that adopts MorseCodePlayer.

  • The protocol requires a single method, play(message:). When called, the view should retrieve the playbackEvents from the received message and toggle the background color of the view from white to black for on and off events, respectively. The background color should remain the same for the duration specified by the event. Foundation provides a Timer class to handle this. Timers can be initialized with a start time and a closure to execute at that start time using the Timer.scheduleTimer(withTimeInterval:repeats:block) API.

  • Iterate over the playbackEvents for the message argument, scheduling a timer with the appropriate behavior in the closure. Hint: Keep in mind that the timer executes at the specified start time. For example, if you schedule your first event to start now for a duration of 3 time units, the next event should start in 3 time units. You'll need a way to keep track of this to schedule subsequent events. Also, don't forget to set the background color back to black after the last event.

  • With the play(message:) method implemented, open Main.storyboard and select the View item in the Document Outline for the View Controller. Using the Identity inspector, set the class to VisualMorseCodePlayerView. This makes the view controller's view the player's view, changing the color of the entire screen as a message is played.

  • Open ViewController.swift and add the following computed property that casts the view to VisualMorseCodePlayerView:

var visualPlayerView: VisualMorseCodePlayerView {
  return view as! VisualMorseCodePlayerView
}
  • In the playMessage(_:) method, create a MorseCodeMessage using the value from messageTextField.text and pass it to play(message:) on visualPlayerView. For the best experience, you should hide the keyboard before playing the message. Use messageTextField.resignFirstResponder() to do so.

  • Build and run the app. By default, the app populates the text field with “SOS” for quick testing of a known pattern. (You'd likely remove this when shipping the app to customers.) Does the view flash the pattern dot-dot-dot-dash-dash-dash-dot-dot-dot?

Great work!

Step 5

Haptic Feedback Morse Code Player

With MorseCodePlaybackEvent in place and MorseCodeMessage capable of providing playback events, you are set for writing the second player type, providing haptic feedback. Starting with iOS 13, you can create custom haptic feedback events for the iPhone 8 and newer.

This step of the lab requires that you run the app on a real device, not Simulator. To do so you'll need to have a free developer account set up and configured within Xcode and the project. See the section called “Select a Real Device” in the Running Your App in the Simulator or on a Device article for more information.

Haptic feedback is achieved using a CHHapticEngine from the CoreHaptics framework. Playing an “SOS” pattern would look like the following:

let events: [CHHapticEvent] = [
  // S
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 0, duration: 0.2),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 0.4, duration: 0.2),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 0.8, duration: 0.2),
 
  // O
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 1.6, duration: 0.6),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 2.4, duration: 0.6),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 3.2, duration: 0.6),
 
  // S
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 4.6, duration: 0.2),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 5.0, duration: 0.2),
  CHHapticEvent(eventType: .hapticContinuous, parameters: [], relativeTime: 5.4, duration: 0.2),
]
 
do {
  self.engine = try CHHapticEngine()
  try engine.start()
  let pattern = CHHapticPattern(events: events, parameters: [])
  let player = try engine.makePlayer(with: pattern)
  try player.start(atTime: 0)
} catch {
  print(error)
}

The haptic events are defined with a start time and a duration, then used to create a CHHapticPattern. The pattern is used to create a CHHapticPlayer, and its start(atTime:) is called to initiate playback at the designated start time.

  • Add a new Swift file named “HapticsMorseCodePlayer.swift” to your project with the following template:
import Foundation
import CoreHaptics
 
class HapticsMorseCodePlayer: MorseCodePlayer {
  let hapticsEngine: CHHapticEngine
 
  init() throws {
    hapticsEngine = try CHHapticEngine()
    try hapticsEngine.start()
  }
 
  func play(message: MorseCodeMessage) throws {
    let events = hapticEvents(for: message)
    let pattern = try CHHapticPattern(events: events, parameters: [])
    let player = try hapticsEngine.makePlayer(with: pattern)
    try player.start(atTime: 0)
  }
 
  func hapticEvents(for message: MorseCodeMessage) -> [CHHapticEvent] {
    // TODO
    return []
  }
}
  • Using what you've just learned about CoreHaptics and the strategy used to implement VisualMorseCodePlayerView with MorseCodePlaybackEvent, implement the hapticEvents(for:) method.

  • In ViewController, add the following instance variables:

var activeMorseCodePlayers: [MorseCodePlayer] = []
var hapticsPlayer: HapticsMorseCodePlayer?​

  • Also, add the following placeholder method, which you'll implement later:
func configurePlayers(mode: PlayerMode) {
}
  • In viewDidAppear(_:), check that haptics is supported by the device and attempt to initialize the player. The default UI state for the app is to have the Both PlayerMode selected. If haptics are available and the haptics player successfully initializes, you'll call configurePlayers(mode: .both); otherwise, you'll use .visual and hide the segmented control so the user is not confused by the unavailable choice. You can use the CHHapticEngine.capabilitiesForHardware().supportsHaptics API to check whether the current hardware supports haptics (you'll need to import the CoreHaptics framework to do so). Before viewing the suggested solution, try implementing this logic on your own.
override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
 
  if CHHapticEngine.capabilitiesForHardware().supportsHaptics == true {
    do {
      hapticsPlayer = try HapticsMorseCodePlayer()
      configurePlayers(mode: .both)
    } catch {
      presentErrorAlert(title: "Haptics Error", message: "Failed to start haptics engine.")
      configurePlayers(mode: .visual)
    }
  } else {
    playerModeSegmentedControl.isHidden = false
    configurePlayers(mode: .visual)
  }
}
  • Next, you need to implement configurePlayers(mode:). This method should switch over the mode argument and set the activeMorseCodePlayers accordingly. You'll also need to handle the possibility of morseCodePlayer being nil. It can get a bit confusing covering all the possible cases; consider switching on a tuple of (mode, hapticsPlayer).

  • You'll also need to call configurePlayers(mode:) when the segmented control's value changes. You can use the segmented control's selectedSegmentIndex property to initialize the mode by passing it to the PlayerMode(rawValue:) initializer.

  • The final step is to update playMessage(_:) so that play(message:) is called on all active players. This is where activeMorseCodePlayers and the MorseCodePlayer protocol will come in handy. You can simply iterate over the contents of the array, calling play(message:) on each element. You can use the presentErrorAlert(title:message:) method to let the user know if anything went wrong.

  • Build and run the app on an actual device. When Both mode is selected, you should feel the device vibrate in sync with the screen flashing. Nice work!