How to Build a Spelling Practice App in SwiftUI
A Spelling Practice app speaks a word aloud and grades the learner's typed answer — ideal for students drilling weekly vocabulary or parents running grade-level spelling tests at home. This guide walks you through a production-ready iOS version using AVSpeechSynthesizer, SwiftData, and ad-based monetization.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A real iOS device for audio testing — AVSpeechSynthesizer can fail silently on some Simulator configurations
- Google AdMob account (or an alternative ad network) for the ad-supported monetization layer
Architecture overview
The app uses SwiftData to persist word lists with per-word accuracy stats, an @Observable SpellingDrillEngine that wraps AVSpeechSynthesizer for on-device audio, and a tab-based SwiftUI shell with a drill screen and a stats screen. No network calls are required — everything runs fully offline.
SpellingApp/ ├── Models/ │ └── SpellingWord.swift // SwiftData @Model ├── Engine/ │ └── SpellingDrillEngine.swift // AVSpeechSynthesizer logic ├── Views/ │ ├── ContentView.swift // Tab navigation │ ├── DrillView.swift // Main quiz screen │ └── StatsView.swift // Progress & history └── PrivacyInfo.xcprivacy // Required for App Store
Step-by-step
1. Data model
Persist each word with its category and accuracy metrics so the drill engine can surface the words the learner misses most.
import SwiftData
import Foundation
@Model
final class SpellingWord {
var text: String
var category: String
var timesAttempted: Int
var timesCorrect: Int
var addedAt: Date
init(text: String, category: String = "General") {
self.text = text
self.category = category
self.timesAttempted = 0
self.timesCorrect = 0
self.addedAt = .now
}
var accuracy: Double {
guard timesAttempted > 0 else { return 0 }
return Double(timesCorrect) / Double(timesAttempted)
}
var needsPractice: Bool {
timesAttempted == 0 || accuracy < 0.8
}
}
2. Core UI
Build the drill screen with a large tap-to-hear button, a text field auto-focused on appear, and a progress bar that advances through the sorted word list.
struct DrillView: View {
@Environment(\.modelContext) private var context
@Query(sort: \SpellingWord.accuracy) private var words: [SpellingWord]
@State private var engine = SpellingDrillEngine()
@State private var index = 0
@State private var input = ""
@State private var lastCorrect: Bool? = nil
@FocusState private var fieldFocused: Bool
var current: SpellingWord? { words.isEmpty ? nil : words[index % words.count] }
var body: some View {
VStack(spacing: 24) {
ProgressView(value: Double(index), total: Double(max(words.count, 1)))
.padding(.horizontal)
Spacer()
Button { current.map { engine.speak($0.text) } } label: {
Image(systemName: "speaker.wave.3.fill")
.font(.system(size: 72))
.foregroundStyle(lastCorrect == false ? .red : .blue)
}
.buttonStyle(.plain)
Text("Tap to hear the word")
.font(.caption).foregroundStyle(.secondary)
TextField("Type spelling here…", text: $input)
.textFieldStyle(.roundedBorder)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($fieldFocused)
.padding(.horizontal, 32)
Button("Check") { checkAnswer() }
.buttonStyle(.borderedProminent)
.disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
Spacer()
}
.onAppear { fieldFocused = true; current.map { engine.speak($0.text) } }
}
}
3. Word spelling drills with AVSpeechSynthesizer
Wrap AVSpeechSynthesizer in an @Observable engine that handles speech rate, letter-by-letter spelling hints, and case-insensitive answer evaluation with streak bonuses.
import AVFoundation
import Observation
@Observable
final class SpellingDrillEngine {
private let synth = AVSpeechSynthesizer()
var score = 0
var streak = 0
func speak(_ word: String, slow: Bool = false) {
synth.stopSpeaking(at: .immediate)
let utt = AVSpeechUtterance(string: word)
utt.voice = AVSpeechSynthesisVoice(language: "en-US")
utt.rate = slow ? 0.3 : AVSpeechUtteranceDefaultSpeechRate * 0.8
utt.postUtteranceDelay = 0.1
synth.speak(utt)
}
func spellAloud(_ word: String) {
let letters = word.map(String.init).joined(separator: " ")
speak(letters, slow: true)
}
@discardableResult
func evaluate(_ input: String, against word: SpellingWord) -> Bool {
let correct = input.trimmingCharacters(in: .whitespaces)
.lowercased() == word.text.lowercased()
word.timesAttempted += 1
if correct {
word.timesCorrect += 1
score += streak >= 3 ? 20 : 10
streak += 1
} else {
streak = 0
}
return correct
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your Xcode target — App Store Connect rejects any app that accesses UserDefaults or system file APIs without a declared reason.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- AVAudioSession inactive on device: Call
try AVAudioSession.sharedInstance().setActive(true)in yourApp.init; otherwise the synthesizer is silenced by Silent Mode and produces no output without any error thrown. - Simulator audio inconsistency: AVSpeechSynthesizer works on most Simulator configurations but can fail silently on some Apple Silicon Macs. Validate all audio paths on a physical device before submitting.
- Missing Privacy Manifest causes instant rejection: SwiftData writes to both
UserDefaultsandFileManager. Since May 2024, omittingPrivacyInfo.xcprivacytriggers an automated rejection email within hours of upload to App Store Connect — not during human review. - AdMob without ATT prompt rejected on first submission: The Google Mobile Ads SDK accesses tracking APIs. Without
NSUserTrackingUsageDescriptionin Info.plist and a visible ATT request, App Store Review will reject with guideline 5.1.2. - Word list not matching target age rating: If your metadata targets children (age 4+), word content and ads must comply with COPPA and Apple's Kids Category rules — standard AdMob banners are not permitted.
Adding monetization: Ad-supported
Integrate Google AdMob by adding the GoogleMobileAds Swift package, initialising the SDK in App.init via GADMobileAds.sharedInstance().start(completionHandler: nil), and wrapping a GADBannerView in a UIViewRepresentable pinned to the bottom of DrillView. On first launch, show Apple's ATTrackingManager.requestTrackingAuthorization — personalized ad CPMs drop sharply without consent, so placement before the first drill maximises opt-in rate. A banner shown between word sets (every five drills) is a cadence App Store reviewers consistently accept for educational apps.
Shipping this faster with Soarias
Soarias scaffolds the complete Xcode project from your app concept — SwiftData model, AVSpeechSynthesizer engine, tab navigation shell, and PrivacyInfo.xcprivacy — in under a minute. It also configures fastlane deliver with your App Store Connect API key and pre-populates required metadata fields (description, keywords, age rating, privacy nutrition labels) so you can push directly to TestFlight without opening the App Store Connect web UI.
For a beginner-complexity project like this one, most developers spend the bulk of their time navigating App Store bureaucracy rather than writing Swift. Soarias collapses scaffolding, Privacy Manifest generation, and the first TestFlight submission into a single guided flow — turning the typical 1–2 weekend timeline into a single focused afternoon for everything except the word-list content itself.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free Apple ID lets you side-load the app onto your own device via Xcode, but distributing via TestFlight or publishing on the App Store requires the $99/year Apple Developer Program membership.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then upload via the Xcode Organiser or fastlane deliver. In App Store Connect, complete the required metadata — screenshots, description, privacy nutrition labels, and age rating — then submit for review. Apple typically reviews beginner-category educational apps within 24–48 hours.
Can I let users import their own word lists?
Yes — support CSV import via fileImporter with a .commaSeparatedText content type, parse each row, and insert new SpellingWord records into the SwiftData model context. If you add iCloud sync via ModelConfiguration(cloudKitDatabase: .automatic), word lists roam across the user's devices automatically.
Last reviewed: 2026-05-12 by the Soarias team.