```html How to Build a Typing Practice App in SwiftUI (2026)

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.

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

Prerequisites

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

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 @ModelCustomPrompt — 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.

```