-
Notifications
You must be signed in to change notification settings - Fork 10
Lesson 3.2: Swift Generics
- 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
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:
- The length of a dot defines the unit of time; it is 1 unit long.
- A dash is 3 units long.
- The space between dots and dashes in the same character is 1 unit long.
- The space between characters is 3 units long.
- The space between words is 7 units long.
The following image shows the letters and digits in Morse code:
- 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.
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 theenum
:
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
, andMorseCodeMessage
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 followingTimeInterval
extension withinMorseCodePlaybackEventRepresentable.swift
:
extension TimeInterval {
static let morseCodeUnit: TimeInterval = 0.2
}
-
Make
MorseCodeSignal
adopt theMorseCodePlaybackEventRepresentable
protocol by adding an extension (inMorseCodePlaybackEventRepresentable.swift
). A signal will have a single playback event in itsplaybackEvents
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 ofMorseCodeSignal
to satisfy theplaybackEvents
property from the protocol. -
With
MorseCodeSignal
adoptingMorseCodePlaybackEventRepresentable
, you'll next want to makeMorseCodeCharacter
adopt it.MorseCodeCharacter
has asignals
property, which is[MorseCodeSignal]
. Now thatMorseCodeSignal
has aplaybackEvents
property, you can use thesignals
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 makingMorseCodeCharacter
adoptMorseCodePlaybackEventRepresentable
. Come up with a solution for theplaybackEvents
computed property that uses the playback events from eachMorseCodeSignal
in thesignals
array and inserts anoff
event, of a single unit of time, between each signal. -
Now, you'll make
MorseCodeWord
adoptMorseCodePlaybackEventRepresentable
. The solution should be very similar to what you used forMorseCodeCharacter
, except that theoff
event between each character should be 3 units of time. Are you seeing a pattern? -
Finally, make
MorseCodeMessage
adoptMorseCodePlaybackEventRepresentable
. At this point, you are likely seeing a pattern—one that might be reusable for each implementation.
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., forMorseCodeWord
, this would becharacters
, whileMorseCodeSignal
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 ofplaybackEvents
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)]
}
}
}
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 namedVisualMorseCodePlayerView
that adoptsMorseCodePlayer
. -
The protocol requires a single method,
play(message:)
. When called, the view should retrieve theplaybackEvents
from the receivedmessage
and toggle the background color of the view from white to black foron
andoff
events, respectively. The background color should remain the same for the duration specified by the event.Foundation
provides aTimer
class to handle this.Timers
can be initialized with a start time and a closure to execute at that start time using theTimer.scheduleTimer(withTimeInterval:repeats:block)
API. -
Iterate over the
playbackEvents
for themessage
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, openMain.storyboard
and select the View item in the Document Outline for the View Controller. Using the Identity inspector, set the class toVisualMorseCodePlayerView
. 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 toVisualMorseCodePlayerView
:
var visualPlayerView: VisualMorseCodePlayerView {
return view as! VisualMorseCodePlayerView
}
-
In the
playMessage(_:)
method, create aMorseCodeMessage
using the value frommessageTextField.text
and pass it toplay(message:)
onvisualPlayerView
. For the best experience, you should hide the keyboard before playing the message. UsemessageTextField.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!
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 implementVisualMorseCodePlayerView
withMorseCodePlaybackEvent
, implement thehapticEvents(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 BothPlayerMode
selected. If haptics are available and the haptics player successfully initializes, you'll callconfigurePlayers(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 theCHHapticEngine.capabilitiesForHardware().supportsHaptics
API to check whether the current hardware supports haptics (you'll need to import theCoreHaptics
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 theactiveMorseCodePlayers
accordingly. You'll also need to handle the possibility ofmorseCodePlayer
beingnil
. 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'sselectedSegmentIndex
property to initialize the mode by passing it to thePlayerMode(rawValue:)
initializer. -
The final step is to update
playMessage(_:)
so thatplay(message:)
is called on all active players. This is whereactiveMorseCodePlayers
and theMorseCodePlayer
protocol will come in handy. You can simply iterate over the contents of the array, callingplay(message:)
on each element. You can use thepresentErrorAlert(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!
Course curriculum resources from:
-
Develop in Swift Data Collections
- Apple Inc. - Education, 2020. Apple Books.