Explorations in iOS Design (SwiftUI)

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:



  • An HStack groups UI elements horizontally.
  • A VStack groups UI elements vertically.
  • A 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.

Main Game Screen
Main Game Screen

Application Data Model

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.
  • Lastly, two 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"
]
                

Application Logic

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

Application Design

At this point, it now makes sense to look at the code responsible for rendering the application's UI:



  • First, the user's color scheme is checked. In the code shown for this section, the user has not enabled dark appearance.
  • A 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.
  • Next, a VStack is added to the view that displays the current, randomly-chosen English alphabet character.
  • Once a user selects a mode (LAPD or NATO) in the alert dialog that displays at application launch, a VStack will be added to the view that displays a dynamically created list, returned by the method getWords().
  • Each word returned will be displayed as a button, allowing the method wordChoice() to be called each time a word button is tapped.
  • Several design features are used to style the current letter shown, the list of word choices, and each button:


    • The font of the current letter is enlarged to make it appear more prominent.
    • All elements have been styled to display a foreground color appropriate for when dark appearance is not enabled.
    • The list has been styled to have a slightly translucent background by using the .regularMaterial modifier on the .background attribute of the list. This creates the characteristic iOS list effect common to many apps designed by Apple.
    • The list edges are eased off a bit, creating an element with softer edges that is more visually appealing.
    • Both the current letter and list of word choices have .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.
  • The correct answer is then added to 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:



  • The method accepts a parameter corresponding to the word that was tapped.
  • If the correct word was tapped for a given alphabet, the user's score is incremented.
  • 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:



  • Throughout each training session, it is important to account for how many alphabet characters have been used. It is most logical to segment sessions into cycles where each English alphabet character is shown once, and the user has an opportunity to select its correct phonetic representation. Therefore, after all characters have been shown, the game should end.
  • Each time a user selects a word, 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).
  • Each time a character is displayed and the user makes a word selection, the character is removed from the availableLetters array. This ensures the user is not shown the same character more than once during a session.
  • A Swift guard statement is used to ensure that at least one character remains to be shown. If not, the endGame() method is called to prompt the user to play again.
  • If additional characters remain, the current letter is chosen from the array availableLetters.
func nextQuestion() {
    lettersRemaining -= 1

    availableLetters.removeAll(where : {$0 == currLetter})

    guard lettersRemaining > 0 else {
        endGame()

        return
    }
    currLetter = availableLetters[Int.random(in: 0..<lettersRemaining)]
}
                

Alerts and Toolbar

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

Navigation Link

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

View this project's source code on GitHub

Download on the App Store

Top Of Page