```html How to Build a Language Learning App in SwiftUI (2026)

How to Build a Language Learning App in SwiftUI

A vocabulary-drill app with spaced-repetition scheduling, audio pronunciation via AVPlayer, and progress charts — built offline-first with SwiftData. Aimed at developers who want to ship a serious study tool to the App Store, not a demo.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

Architecture overview

SwiftData persists decks and vocabulary cards, each card carrying SM-2 spaced-repetition metadata (easeFactor, interval, nextReviewDate) so reviews work fully offline. AVPlayer fetches pronunciation audio from a CDN URL stored on the card, with a local fallback for bundled content. Swift Charts renders daily retention streaks and card maturity without any UIKit bridge. A lightweight SpacedRepetitionEngine service keeps scheduling logic out of views.

LanguageApp/
├── Models/
│   ├── Vocabulary.swift       # @Model — word, SM-2 fields, audioURL
│   ├── Deck.swift             # @Model — cascade-deletes cards
│   └── ReviewSession.swift    # @Model — daily review log for Charts
├── Views/
│   ├── DeckListView.swift
│   ├── DrillView.swift
│   └── ProgressChartView.swift
├── Services/
│   └── SpacedRepetitionEngine.swift
└── LanguageApp.swift

Step-by-step

1. Data model

Store each word's SM-2 scheduling state directly on the Vocabulary model so the entire review algorithm runs offline with zero network calls.

import SwiftData

@Model final class Vocabulary {
    var word: String
    var translation: String
    var audioURL: String?
    var easeFactor: Double = 2.5   // SM-2: quality-weighted difficulty
    var interval: Int      = 1     // days until next review
    var repetitions: Int   = 0
    var nextReviewDate: Date = .now
    var deck: Deck?                // inverse inferred by SwiftData

    init(word: String, translation: String, audioURL: String? = nil) {
        self.word = word; self.translation = translation; self.audioURL = audioURL
    }
}

@Model final class Deck {
    var name: String
    var language: String
    var createdAt: Date = .now
    @Relationship(deleteRule: .cascade) var cards: [Vocabulary] = []

    init(name: String, language: String) {
        self.name = name; self.language = language
    }
}

2. Core UI — Deck list

Use @Query for reactive deck display and type-safe navigationDestination so deep links into specific decks work without routing hacks.

struct DeckListView: View {
    @Query(sort: \Deck.createdAt, order: .reverse) private var decks: [Deck]
    @State private var showingAdd = false

    var body: some View {
        NavigationStack {
            List(decks) { deck in
                NavigationLink(value: deck) {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(deck.name).font(.headline)
                        let due = deck.cards.filter { $0.nextReviewDate <= .now }.count
                        Text("\(deck.language) · \(due) due today")
                            .font(.caption)
                            .foregroundStyle(due > 0 ? Color.orange : Color.secondary)
                    }
                    .padding(.vertical, 4)
                }
            }
            .navigationTitle("My Decks")
            .navigationDestination(for: Deck.self) { DrillView(deck: $0) }
            .toolbar {
                Button("New Deck", systemImage: "plus") { showingAdd = true }
            }
            .sheet(isPresented: $showingAdd) { AddDeckSheet() }
        }
    }
}

3. Vocabulary drills with SM-2 spaced repetition

Load only cards due today, let the user tap to flip, then apply the SM-2 formula on their 0–5 rating to schedule the next review interval.

struct DrillView: View {
    let deck: Deck
    @State private var queue: [Vocabulary] = []
    @State private var flipped = false

    var body: some View {
        VStack(spacing: 20) {
            ProgressView(value: Double(deck.cards.count - queue.count),
                         total: Double(max(deck.cards.count, 1)))
                .padding(.horizontal)
            if let card = queue.first {
                CardFace(card: card, flipped: flipped)
                    .onTapGesture { withAnimation(.spring(response: 0.35)) { flipped = true } }
                if flipped {
                    RatingRow { rate(card, rating: $0) }
                }
            } else {
                ContentUnavailableView("All done for today!",
                    systemImage: "checkmark.seal.fill",
                    description: Text("New cards unlock tomorrow."))
            }
        }
        .padding()
        .navigationTitle("Drill: \(deck.name)")
        .onAppear { queue = deck.cards.filter { $0.nextReviewDate <= .now }.shuffled() }
    }

    private func rate(_ card: Vocabulary, rating: Int) {
        let q = Double(rating)
        card.easeFactor = max(1.3, card.easeFactor + 0.1 - (5-q)*(0.08+(5-q)*0.02))
        if rating < 3 { card.repetitions = 0; card.interval = 1 }
        else {
            card.interval = card.repetitions == 0 ? 1 : card.repetitions == 1 ? 6
                : Int(Double(card.interval) * card.easeFactor)
            card.repetitions += 1
        }
        card.nextReviewDate = Calendar.current.date(
            byAdding: .day, value: card.interval, to: .now) ?? .now
        withAnimation { flipped = false; queue.removeFirst() }
    }
}

4. Privacy Manifest (PrivacyInfo.xcprivacy)

Add PrivacyInfo.xcprivacy to your app target — App Store Connect has rejected submissions without it since 2024, and enforcement remains active in 2026.

<?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>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>C617.1</string></array>
    </dict>
  </array>
</dict></plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to load your subscription SKUs and Transaction.currentEntitlements to gate premium features — unlimited decks, audio pronunciation, and the progress Charts view are natural paywalls. Wrap entitlement checks in a @MainActor @Observable SubscriptionManager so every view reacts automatically to purchase-state changes. Create a Products.storekit configuration file in Xcode to test the complete purchase and restore flow locally without a sandbox account. Declare your subscription group in App Store Connect first, copy the product IDs into that config file, and handle .userCancelled gracefully — a dismissive error sheet on cancellation is a common cause of guideline 3.1.1 rejections.

Shipping this faster with Soarias

Soarias scaffolds the full project structure above in one prompt — SwiftData models with SM-2 fields pre-populated, a correctly configured PrivacyInfo.xcprivacy, a Products.storekit file wired to a StoreKit 2 SubscriptionManager, fastlane lanes for TestFlight and production submission, and the App Store Connect metadata skeleton (screenshots, localised description, keyword field, age rating). That scaffolding alone typically takes 2–4 days of setup work before you write a single drill interaction.

For an advanced project in the 2–4 week range, Soarias compresses the boilerplate phase to an afternoon — you start implementing spaced-repetition logic and AVPlayer integration on day one instead of spending the first week stitching together submission infrastructure.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. A free Apple ID lets you sideload onto your personal device for testing, but the $99/year Apple Developer Program is required to upload builds to TestFlight, submit to the App Store, and configure in-app subscriptions in App Store Connect.

How do I submit this to the App Store?

Archive your app via Product → Archive in Xcode, validate and upload through Organizer, then complete the App Store Connect listing — screenshots for each required device size, privacy nutrition labels, age rating questionnaire, and subscription pricing. First-time submissions typically take 1–3 business days for review.

Can I import Anki decks into my app?

Yes. Anki's .apkg format is a ZIP archive containing a SQLite database and media files. Parse it with SQLite.swift or GRDB, map the card fields to your Vocabulary model, and copy audio files into your app's documents directory. Be aware that many shared Anki decks contain third-party copyrighted content — surface a clear import disclaimer to users and don't bundle pre-made decks without verifying their licences.

Last reviewed: 2026-05-12 by the Soarias team.

```