Phonetic Trainer is an app designed to teach its users two of the most common phonetic alphabets: NATO and LAPD. It is a quiz-based app that displays an alphabet letter and then presents a list of choices to the user so that they may select the correct phonetic representation of the alphabet character depending upon the phonetic alphabet that they are studying.
This app incorporates many of the exciting design features available in SwiftUI. Most notable is the color gradient used for the application background. This can be dynamically changed depending upon if the app user has enabled the dark appearance setting in the device settings menu.
As we will see below, SwiftUI provides a variety of stacks useful for organizing an app's layout:
HStack
groups UI elements horizontally.
VStack
groups UI elements vertically.
ZStack
groups UI elements on the Z-axis, the axis corresponding to depth
Therefore, by using a ZStack
, it is possible to overlay prominent UI elements
on
top of
the
color gradient used as a background.
SwiftUI provides a convenient way to present dynamically-created lists. In this app, I've
used a
list to
display
possible word choices. SwiftUI also provides the ability to display information at the top
or
bottom
of the screen by using a toolbar
, and adding toolbar items. Here, the user's
score
and
the
alphabet they
are studying are displayed using toolbar items. Lastly, a NavigationLink
is
placed
at
the
bottom of the screen that when tapped will navigate to a list of all the phonetic
representations
belonging
to the alphabet that the user is currently studying.
Before examining the design elements of this app, it is useful to have an idea of how data is stored and manipulated, since these variables and methods will appear extensively in the code responsible for managing the app's state. In SwiftUI, it is said that any view is a representation of the application state. This is one of the guiding principles of SwiftUI, and as a declarative framework, SwiftUI makes it very straightforward to create elegant representations of application state.
On the right, the application data model is shown. Notice that these are not members of a struct or a class; they are simply declared in a file within the project directory. Other files within the project are able to reference the variables without needing to prefix their names with any class or struct name, and there is no need to worry about any instantiation.
alphabet
is an array containing all characters in the English alphabet. It
has
been
declared
using the let
keyword since there is no need to modify it, and doing so
could
introduce
bugs.
allWords
and targetWords
are both initially empty arrays that
will
be
modified
throughout the duration of our app, and, therefore, they have been declared using the
var
keyword.
OrderedDictionaries
are used to store each character in the
English
alphabet as a key, and the appropriate phonetic representation as a value.
It is worth noting that the data type OrderedDictionary
has been used and not a
vanilla
Swift
dictionary. Vanilla Swift dictionaries would not allow for the creation of dynamic lists
displaying
all of the contained values, and this is something that will be useful in this app in order
to
provide users with a reference to the correct phonetic representation of each English
character.
I have also included a text file containing over ten-thousand of the most commonly-searched words on Google in a particular year. By including this file in the project directory, it will be included in the app bundle, and thereby accessible within our app during runtime. This will be explored in detail next. Below is a small sample of the file's contents:
Victorian Validation Veterinary Vocational Vessels Vulnerable Volleyball Vegetable Visibility Volkswagen Vaccine Viewpicture
import Collections
import SwiftUI
let alphabet = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]
var allWords = [String]()
var targetWords = [String]()
let LAPD: OrderedDictionary = ["A" : "Adam",
"B" : "Boy",
"C" : "Charles",
"D" : "David",
"E" : "Edward",
"F" : "Frank",
"G" : "George",
"H" : "Henry",
"I" : "Ida",
"J" : "John",
"K" : "King",
"L" : "Lincoln",
"M" : "Mary",
"N" : "Nora",
"O" : "Ocean",
"P" : "Paul",
"Q" : "Queen",
"R" : "Robert",
"S" : "Sam",
"T" : "Tom",
"U" : "Union",
"V" : "Victor",
"W" : "William",
"X" : "X-ray",
"Y" : "Young",
"Z" : "Zebra"
]
let NATO: OrderedDictionary = ["A" : "Alpha",
"B" : "Bravo",
"C" : "Charlie",
"D" : "Delta",
"E" : "Echo",
"F" : "Foxtrot",
"G" : "Golf",
"H" : "Hotel",
"I" : "India",
"J" : "Juliett",
"K" : "Kilo",
"L" : "Lima",
"M" : "Mike",
"N" : "November",
"O" : "Oscar",
"P" : "Papa",
"Q" : "Quebec",
"R" : "Romeo",
"S" : "Sierra",
"T" : "Tango",
"U" : "Uniform",
"V" : "Victor",
"W" : "Whiskey",
"X" : "X-ray",
"Y" : "Yankee",
"Z" : "Zulu"
]
The first method that will run when the app launches is startGame()
.
chooseMode()
is used to present the user with an alert allowing them to select
either
the
NATO or LAPD phonetic alphabet.
Optional binding is used so that if any of the statements wrapped in an if block fail, the app will crash. This is essential because if the word list isn't able to load, the application has no functionality.
First, the app attempts to access the word list stored in the
application bundle. If it can be accessed, its contents are casted to a String.
allWords
is
then assigned the contents of this string, with each element in allWords
a
single
word
in
the original file. targetWords
is then assigned the value of all the words
beginning
with a randomly chosen English alphabet character (this character was chosen during
application
initialization).
If all has gone well, the method returns.
func startGame() {
chooseMode()
if let wordListURL = Bundle.main.url(forResource: "sorted_words", withExtension: "txt") {
if let sortedWords = try? String(contentsOf: wordListURL) {
allWords = sortedWords.components(separatedBy: "\n")
targetWords = allWords.filter { $0.starts(with: currLetter) }
return
}
}
fatalError("Word list could not be loaded")
}
At this point, it now makes sense to look at the code responsible for rendering the application's UI:
ZStack
is used to place a LinearGradient
as the app's
background.
This
gradient
is declared first, because SwiftUI interprets the first children of the
ZStack
as
appearing
behind subsequent children.
VStack
is added to the view that displays the current,
randomly-chosen
English
alphabet character.
VStack
will be added to the view that displays a dynamically created
list,
returned
by the method getWords()
.
wordChoice()
to
be called each time a word button is tapped.
.regularMaterial
modifier on the .background
attribute
of
the
list.
This creates the characteristic iOS list effect common to many apps designed by
Apple.
.animation
attributes
with the .easeIn
modifier provided. This means that each time a
user
taps
on a
button corresponding to a word choice, the list and current letter shown will
appear
to
slide
into place instead of simply changing.
var body: some View {
NavigationView {
if colorScheme == .light {
ZStack {
LinearGradient(gradient: Gradient(stops: [
.init(color: .red, location: 0.35),
.init(color: .white, location: 0.4),
.init(color: .blue, location: 0.6)
]), startPoint: .top, endPoint: .bottom)
.ignoresSafeArea()
VStack {
Text(currLetter)
.font(.system(size: 140).weight(.bold))
.foregroundColor(.white)
.animation(.easeIn)
Spacer()
if mode != "" {
// display list of words
VStack {
List(getWords(), id: \.self) { word in
Button {
wordChoice(word: word)
} label: {
Text(word)
.foregroundStyle(.secondary)
.font(.title)
}
}
.listRowInsets(EdgeInsets()) // << to zero padding
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
.animation(.easeIn)
}
Spacer()
}
NavigationLink(destination: ReferenceView(mode: mode)) {
Text("View \(mode) Alphabet")
.font(.title)
.foregroundColor(.white)
}
}
}
getWords()
and wordChoice()
The method getWords()
is used to return an array of words to be shown in the
list.
targetWords
is updated to contain all words beginning with the current,
randomly-chosen
letter and then shuffled so that the same words are not shown each time.
shownWords
is assigned the value of the first five words in
targetWords
.
shownWords
.
shownWords
is then shuffled so that the correct answer appears in a random
position
and
then the array is returned to be displayed in the list.
The method wordChoice
is called each time the user taps on a possible word:
nextQuestion()
is called.
func getWords() -> [String] {
targetWords = allWords.filter { $0.starts(with: currLetter) }.shuffled()
var shownWords = targetWords[0...4]
if mode == "NATO" {
shownWords.append(NATO[currLetter]!)
}
if mode == "LAPD" {
shownWords.append(LAPD[currLetter]!)
}
return shownWords.shuffled()
}
func wordChoice(word: String) {
if mode == "NATO" {
if word == NATO[currLetter] {
score += 1
} else {
withAnimation {
}
}
}
if mode == "LAPD" {
if word == LAPD[currLetter] {
score += 1
} else {
withAnimation {
}
}
}
nextQuestion()
}
nextQuestion()
nextQuestion()
is responsible for updating the application state after the user
has
made a word choice:
letterRemaining
is decremented by one.
availableLetters
is assigned the value of alphabet
from our
data
model
at
application
initialization and each time the game resets (after the user attempts all 26 characters,
they
have
the option
of playing again).
availableLetters
array. This ensures the user is not shown the same
character
more
than
once during a session.
endGame()
method is called to prompt the user to play again.
availableLetters
.
func nextQuestion() {
lettersRemaining -= 1
availableLetters.removeAll(where : {$0 == currLetter})
guard lettersRemaining > 0 else {
endGame()
return
}
currLetter = availableLetters[Int.random(in: 0..<lettersRemaining)]
}
This app has used SwiftUI alerts in order to allow the user to select an alphabet, decide to begin another session, and also to display their score. This section examines how alerts work in SwiftUI.
Both alerts and a toolbar can be applied to any view, but for the sake of readability, I
prefer
to
apply them to the outermost view, in this case the ZStack
.
A helper method has been created to set the alert fields that display when all characters
have
been
displayed and the user has the option of playing again:
func endGame() {
endingTitle = "Game Over!"
endingBody = String(format: "You scored %.0f%% accuracy", ((Double(score) / 26) * 100))
showEndingMessage = true
}
When this method is called, the value of showEndingMessage
is set to true, and
the
alert fields will contain the values contained in endingTitle
and
endingBody
.
.alert(messageTitle, isPresented: $showMessage) {
Button("NATO", action: setNATO)
Button("LAPD", action: setLAPD)
} message: {
Text(messageBody)
}
.alert(endingTitle, isPresented: $showEndingMessage) {
Button("Play Again", action: resetGame)
} message: {
Text(endingBody)
}
.toolbar {
ToolbarItem() {
Text(mode)
.font(.largeTitle)
.foregroundColor(.secondary)
}
ToolbarItem(placement: .navigationBarLeading) {
Text("Score: \(score)")
.font(.largeTitle)
.foregroundColor(.secondary)
}
}
The only NavigationLink
shown in the app is responsible for showing the user a
list
of
all
phonetic representations of English alphabet characters. In this case, by using a view
ReferenceView
that accepts as a parameter the current mode
a user
has
chosen
(NATO or LAPD).
ReferenceView
uses a list to display each value contained in the appropriate
OrderedDictionary
.
NavigationLink(destination: ReferenceView(mode: mode)) {
Text("View \(mode) Alphabet")
.font(.title)
.foregroundColor(.white)
}
var body: some View {
if colorScheme == .light {
if mode == "NATO" {
List(NATO.values, id: \.self) { word in
Text(word)
.font(.title)
}
.navigationTitle("NATO Alphabet")
} else {
List(LAPD.values, id: \.self) { word in
Text(word)
.font(.title)
}
.navigationTitle("LAPD Alphabet")
}