How to Build a Typing Practice App in SwiftUI
A Typing Practice app presents users with a text prompt and measures how quickly and accurately they reproduce it, displaying a live WPM counter and a score history chart. It's ideal for students, writers, and anyone wanting to build keyboard speed on iPhone or iPad.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- No SwiftData experience needed — the model here is a single, flat class
Architecture overview
The app keeps its data layer in a single SwiftData @Model that persists finished sessions (WPM, accuracy, date). Live session state lives in an @Observable SessionManager that recomputes WPM on every keystroke. Three SwiftUI views cover the full flow: a home screen to pick a prompt and start, a typing screen that colour-highlights correct and incorrect characters, and a results screen that plots the last ten sessions with Swift Charts. No network calls or external services are required.
TypingPractice/ ├── Models/ │ └── TypingSession.swift # @Model — persisted results ├── Managers/ │ └── SessionManager.swift # @Observable — live WPM math ├── Views/ │ ├── HomeView.swift │ ├── TypingView.swift │ └── ResultsView.swift # Swift Charts WPM history └── PrivacyInfo.xcprivacy
Step-by-step
1. Define the data model
A SwiftData @Model stores each finished session so the Charts history view survives app restarts without any extra persistence code.
import SwiftData
import Foundation
@Model
final class TypingSession {
var id: UUID
var prompt: String
var wpm: Int
var accuracy: Double // 0.0 – 1.0
var duration: TimeInterval
var date: Date
init(prompt: String, wpm: Int, accuracy: Double, duration: TimeInterval) {
self.id = UUID()
self.prompt = prompt
self.wpm = wpm
self.accuracy = accuracy
self.duration = duration
self.date = .now
}
}
// App entry point — wire up the container once:
// WindowGroup { ContentView() }
// .modelContainer(for: TypingSession.self)
2. Build the core typing UI
Render the prompt above a monospaced text field; auto-focus the field on appear and strip autocorrect so it doesn't corrupt accuracy tracking.
struct TypingView: View {
@Bindable var manager: SessionManager
@FocusState private var isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(manager.coloredPrompt)
.font(.system(.body, design: .monospaced))
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.secondary.opacity(0.1),
in: RoundedRectangle(cornerRadius: 12))
TextField("Start typing…", text: $manager.userInput)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isFocused)
.padding()
.background(.background,
in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12)
.stroke(.blue, lineWidth: 1))
.onChange(of: manager.userInput) { manager.tick() }
}
.onAppear { isFocused = true }
}
}
3. Implement WPM tracking with Swift Charts
The @Observable manager recomputes WPM on every keystroke; a Charts view in the results screen graphs the last ten sessions over time.
import Observation
import Charts
import SwiftData
@Observable
final class SessionManager {
var prompt: String = ""
var userInput: String = ""
private var startTime: Date?
var liveWPM: Int {
guard let t = startTime, !userInput.isEmpty else { return 0 }
let minutes = max(Date().timeIntervalSince(t) / 60, 0.001)
return Int(Double(userInput.split(separator: " ").count) / minutes)
}
var accuracy: Double {
guard !userInput.isEmpty else { return 1 }
let correct = zip(prompt, userInput).filter { $0 == $1 }.count
return Double(correct) / Double(userInput.count)
}
func tick() { if startTime == nil { startTime = .now } }
// Swift Charts history — embed in ResultsView
struct HistoryChart: View {
@Query(sort: \TypingSession.date) var sessions: [TypingSession]
var body: some View {
Chart(sessions.suffix(10)) { s in
LineMark(x: .value("Date", s.date), y: .value("WPM", s.wpm))
PointMark(x: .value("Date", s.date), y: .value("WPM", s.wpm))
}
.chartYAxisLabel("Words per minute")
.frame(height: 200)
}
}
}
4. Add the Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy in every new submission — omitting it causes a silent processing rejection before your app even reaches a reviewer.
<?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
- Autocorrect silently edits input. Without
.autocorrectionDisabled()and.textInputAutocapitalization(.never), iOS fixes typos before your WPM and accuracy math ever sees them, producing falsely high scores. - WPM shows zero until the first space. Splitting on spaces yields zero words mid-first-word — guard against this explicitly and show a neutral placeholder ("—") rather than "0 WPM" to avoid confusing users.
- Missing Privacy Manifest causes a processing rejection. This failure happens after you upload the binary, not during human review — you see it as an email from Apple. Add the file before your first TestFlight build.
- SwiftData container crashes Xcode previews.
@Queryrequires a model container at preview time. Add.modelContainer(for: TypingSession.self, inMemory: true)to your preview or it will crash every time. - TextField doesn't focus on iPad with a hardware keyboard.
@FocusState+.onAppearis reliable on iPhone but intermittent on iPad when a Magic Keyboard is attached. Test on a real iPadOS device before submitting.
Adding monetization: One-time purchase
Use StoreKit 2's Product.products(for:) to fetch a single non-consumable IAP (e.g. com.yourapp.pro) you create in App Store Connect under the Monetisation tab. Gate premium features — extra prompt packs, full session history, or detailed accuracy breakdowns — behind this purchase and call product.purchase() when the user taps your paywall CTA. On every cold launch, call Transaction.currentEntitlement(for:) to silently restore access; Apple also requires a visible "Restore Purchases" button that calls AppStore.sync(), or your submission will be rejected during review.
Shipping this faster with Soarias
Soarias generates the SwiftData model, the @Observable session manager, and the Swift Charts history view from a plain-English description of your app. It also auto-creates the PrivacyInfo.xcprivacy file, sets up a fastlane lane for screenshots and binary upload, and submits directly to App Store Connect — the four manual steps above collapse into a single prompt in the Soarias desktop app.
For a beginner-complexity project like this one, the bulk of the time usually goes to Xcode project configuration and filling out App Store Connect metadata. Soarias handles both end-to-end. A build that realistically takes 1–2 weekends typically lands in an afternoon: describe the app, review the generated code, test on device, and submit.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free Apple ID lets you run the app on your own device via Xcode, but distributing via TestFlight or submitting to the App Store requires the Apple Developer Program at $99/year.
How do I submit this to the App Store?
Archive the build in Xcode (Product → Archive), then upload it through Organizer to App Store Connect. Complete the metadata form (description, keywords, screenshots at 6.9-inch and 6.5-inch sizes), configure pricing as a paid app or add your IAP, and click Submit for Review. First submissions typically receive a decision within 24–48 hours.
How do I add custom prompt libraries beyond the built-in ones?
Bundle a prompts.json file in your app target and decode it at launch into an in-memory array of strings. For user-created content, add a second @Model — CustomPrompt — with a title and body field, and present a simple form to add entries. SwiftData persists it automatically with no extra configuration.
Last reviewed: 2026-05-12 by the Soarias team.