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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.