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

How to Build a Flashcard App in SwiftUI

A flashcard app with spaced repetition shows learners only the cards they're about to forget, making study time dramatically more efficient than random review. This guide is for iOS developers who want to ship a polished, data-driven study tool — from blank Xcode project to the App Store.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses SwiftData for persistence, storing Deck and Card model objects in a local SQLite store. An @Observable StudySession class owns the active review queue and applies the SM-2 spaced repetition algorithm, writing updated scheduling metadata back through the ModelContext. Views are split into three areas — the deck browser, the study session, and the statistics screen — each a separate NavigationStack tab. The Charts framework renders retention history and due-card forecasts from data derived directly from SwiftData queries, with no separate analytics layer needed at this scale.

FlashcardApp/
├── FlashcardApp.swift          # App entry, modelContainer setup
├── Models/
│   ├── Deck.swift              # @Model — name, colorHex, cards relationship
│   └── Card.swift              # @Model — front, back, SM-2 fields
├── Engine/
│   └── SM2.swift               # Pure SM-2 scheduling algorithm
├── Views/
│   ├── ContentView.swift       # TabView root
│   ├── Decks/
│   │   ├── DeckListView.swift
│   │   └── DeckDetailView.swift
│   ├── Study/
│   │   ├── StudySessionView.swift
│   │   └── FlipCardView.swift
│   └── Stats/
│       └── StatsView.swift     # Charts screen
├── PrivacyInfo.xcprivacy       # Required for App Store
└── Assets.xcassets
      

Step-by-step

1. Project setup

Create a new Xcode project: File → New → Project → iOS App. Name it Flashcard, set Interface to SwiftUI, and check Use SwiftData. Delete the boilerplate Item.swift Xcode generates — you'll replace it with your own models next.

// FlashcardApp.swift
import SwiftUI
import SwiftData

@main
struct FlashcardApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Deck.self, Card.self])
    }
}

2. Data model with SwiftData

Define your two model classes. Card stores SM-2 scheduling state: the ease factor (ef), repetition count, and nextReview date. These three fields drive the entire spaced repetition engine — keep them on the model so they persist automatically.

// Models/Deck.swift
import SwiftData
import Foundation

@Model
final class Deck {
    var name: String
    var colorHex: String
    var createdAt: Date
    @Relationship(deleteRule: .cascade) var cards: [Card]

    init(name: String, colorHex: String = "#4F8EF7") {
        self.name = name
        self.colorHex = colorHex
        self.createdAt = .now
        self.cards = []
    }

    var dueCount: Int {
        cards.filter { $0.nextReview <= .now }.count
    }
}

// Models/Card.swift
import SwiftData
import Foundation

@Model
final class Card {
    var front: String
    var back: String
    // SM-2 fields
    var ef: Double          // ease factor, starts at 2.5
    var repetitions: Int    // consecutive correct reviews
    var interval: Int       // days until next review
    var nextReview: Date

    init(front: String, back: String) {
        self.front = front
        self.back = back
        self.ef = 2.5
        self.repetitions = 0
        self.interval = 0
        self.nextReview = .now
    }
}

3. Deck list UI

Use @Query to observe all decks from SwiftData. The list shows each deck's name and how many cards are due today — a small but powerful motivator that drives daily opens.

// Views/Decks/DeckListView.swift
import SwiftUI
import SwiftData

struct DeckListView: View {
    @Query(sort: \Deck.createdAt) private var decks: [Deck]
    @Environment(\.modelContext) private var context
    @State private var showingAdd = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(decks) { deck in
                    NavigationLink(value: deck) {
                        HStack {
                            RoundedRectangle(cornerRadius: 6)
                                .fill(Color(hex: deck.colorHex))
                                .frame(width: 14, height: 40)
                            VStack(alignment: .leading, spacing: 2) {
                                Text(deck.name).font(.headline)
                                Text("\(deck.dueCount) due · \(deck.cards.count) total")
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        }
                    }
                }
                .onDelete(perform: deleteDecks)
            }
            .navigationTitle("My Decks")
            .navigationDestination(for: Deck.self) { DeckDetailView(deck: $0) }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("Add Deck", systemImage: "plus") { showingAdd = true }
                }
            }
            .sheet(isPresented: $showingAdd) { AddDeckSheet() }
        }
    }

    private func deleteDecks(at offsets: IndexSet) {
        for i in offsets { context.delete(decks[i]) }
    }
}

#Preview {
    DeckListView()
        .modelContainer(for: [Deck.self, Card.self], inMemory: true)
}

4. Spaced repetition engine (SM-2)

The SM-2 algorithm takes a quality rating (0–5) after each review and updates the ease factor, repetition count, and interval. Ratings below 3 reset the card to tomorrow; ratings 3–5 stretch the interval exponentially. Keeping this logic in a pure function makes it trivially testable.

// Engine/SM2.swift
import Foundation

enum SM2 {
    /// Quality: 0 = complete blackout, 5 = perfect recall
    static func schedule(card: Card, quality: Int) {
        let q = Double(max(0, min(5, quality)))

        if quality < 3 {
            card.repetitions = 0
            card.interval = 1
        } else {
            switch card.repetitions {
            case 0: card.interval = 1
            case 1: card.interval = 6
            default: card.interval = Int((Double(card.interval) * card.ef).rounded())
            }
            card.repetitions += 1
        }

        // Update ease factor (floor at 1.3)
        let newEF = card.ef + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
        card.ef = max(1.3, newEF)

        card.nextReview = Calendar.current.date(
            byAdding: .day, value: card.interval, to: .now
        ) ?? .now
    }
}

5. Study session with flip-card animation

The study session pulls all due cards for a deck, presents them one at a time with a 3D flip animation, and writes the updated SM-2 schedule back to SwiftData after each rating. The rotation3DEffect modifier gives the satisfying card-flip without any third-party libraries.

// Views/Study/FlipCardView.swift
import SwiftUI

struct FlipCardView: View {
    let front: String
    let back: String
    @State private var flipped = false
    @State private var angle: Double = 0

    var body: some View {
        ZStack {
            cardFace(text: front, isFront: true)
                .opacity(flipped ? 0 : 1)
            cardFace(text: back, isFront: false)
                .opacity(flipped ? 1 : 0)
                .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
        }
        .rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0))
        .onTapGesture { flip() }
    }

    private func cardFace(text: String, isFront: Bool) -> some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(isFront ? Color(.systemBackground) : Color.blue.opacity(0.08))
            .shadow(color: .black.opacity(0.08), radius: 12, y: 4)
            .overlay(
                Text(text)
                    .font(.title2)
                    .multilineTextAlignment(.center)
                    .padding(24)
            )
            .frame(maxWidth: .infinity)
            .frame(height: 260)
    }

    private func flip() {
        withAnimation(.spring(duration: 0.5)) {
            angle += 180
            flipped.toggle()
        }
    }
}

// Views/Study/StudySessionView.swift
import SwiftUI
import SwiftData

struct StudySessionView: View {
    let deck: Deck
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss

    @State private var queue: [Card] = []
    @State private var currentIndex = 0
    @State private var showRating = false

    var currentCard: Card? { queue.indices.contains(currentIndex) ? queue[currentIndex] : nil }

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                ProgressView(value: Double(currentIndex), total: Double(max(queue.count, 1)))
                    .tint(.blue)
                    .padding(.horizontal)

                if let card = currentCard {
                    FlipCardView(front: card.front, back: card.back)
                        .padding(.horizontal)
                        .onTapGesture { withAnimation { showRating = true } }

                    if showRating {
                        ratingButtons(for: card)
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                    } else {
                        Text("Tap card to reveal answer")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                } else {
                    ContentUnavailableView(
                        "Session complete!",
                        systemImage: "checkmark.seal.fill",
                        description: Text("All \(queue.count) cards reviewed.")
                    )
                    Button("Done") { dismiss() }.buttonStyle(.borderedProminent)
                }
            }
            .navigationTitle("Studying: \(deck.name)")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .cancellationAction) { Button("End") { dismiss() } } }
            .onAppear { queue = deck.cards.filter { $0.nextReview <= .now }.shuffled() }
        }
    }

    @ViewBuilder
    private func ratingButtons(for card: Card) -> some View {
        HStack(spacing: 12) {
            ForEach([(0, "Again", Color.red),
                     (3, "Hard", Color.orange),
                     (4, "Good", Color.green),
                     (5, "Easy", Color.blue)], id: \.0) { q, label, color in
                Button {
                    SM2.schedule(card: card, quality: q)
                    try? context.save()
                    showRating = false
                    currentIndex += 1
                } label: {
                    Text(label)
                        .font(.subheadline.weight(.semibold))
                        .foregroundStyle(.white)
                        .frame(maxWidth: .infinity)
                        .padding(.vertical, 12)
                        .background(color, in: RoundedRectangle(cornerRadius: 12))
                }
            }
        }
        .padding(.horizontal)
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Deck.self, Card.self, configurations: config)
    let deck = Deck(name: "Spanish")
    deck.cards = [Card(front: "Hola", back: "Hello"), Card(front: "Gracias", back: "Thank you")]
    container.mainContext.insert(deck)
    return StudySessionView(deck: deck).modelContainer(container)
}

6. Progress charts with Swift Charts

A statistics screen gives learners a reason to open the app daily. Use the Charts framework to plot cards due over the next 14 days (a bar chart) and the distribution of ease factors (a histogram). Both charts derive from the same SwiftData query — no separate data pipeline required.

// Views/Stats/StatsView.swift
import SwiftUI
import SwiftData
import Charts

struct DueDataPoint: Identifiable {
    let id = UUID()
    let date: Date
    let count: Int
}

struct StatsView: View {
    @Query private var allCards: [Card]

    private var dueForecast: [DueDataPoint] {
        let cal = Calendar.current
        return (0..<14).compactMap { offset -> DueDataPoint? in
            guard let day = cal.date(byAdding: .day, value: offset, to: cal.startOfDay(for: .now))
            else { return nil }
            let nextDay = cal.date(byAdding: .day, value: 1, to: day)!
            let count = allCards.filter { $0.nextReview >= day && $0.nextReview < nextDay }.count
            return DueDataPoint(date: day, count: count)
        }
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 24) {
                    GroupBox("Cards due — next 14 days") {
                        Chart(dueForecast) { point in
                            BarMark(
                                x: .value("Date", point.date, unit: .day),
                                y: .value("Cards", point.count)
                            )
                            .foregroundStyle(.blue.gradient)
                        }
                        .chartXAxis {
                            AxisMarks(values: .stride(by: .day, count: 2)) {
                                AxisValueLabel(format: .dateTime.month(.abbreviated).day())
                            }
                        }
                        .frame(height: 200)
                    }

                    GroupBox("Ease factor distribution") {
                        Chart {
                            ForEach(stride(from: 1.3, through: 3.5, by: 0.2).map { $0 }, id: \.self) { bucket in
                                let count = allCards.filter { $0.ef >= bucket && $0.ef < bucket + 0.2 }.count
                                BarMark(
                                    x: .value("EF", String(format: "%.1f", bucket)),
                                    y: .value("Cards", count)
                                )
                                .foregroundStyle(.green.gradient)
                            }
                        }
                        .frame(height: 180)
                    }

                    GroupBox("Summary") {
                        Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 8) {
                            GridRow {
                                statCell(label: "Total cards", value: "\(allCards.count)")
                                statCell(label: "Due today",
                                         value: "\(allCards.filter { $0.nextReview <= .now }.count)")
                            }
                            GridRow {
                                statCell(label: "Avg ease",
                                         value: allCards.isEmpty ? "—" : String(format: "%.2f", allCards.map(\.ef).reduce(0,+)/Double(allCards.count)))
                                statCell(label: "Mature (≥21d)",
                                         value: "\(allCards.filter { $0.interval >= 21 }.count)")
                            }
                        }
                        .padding(.vertical, 4)
                    }
                }
                .padding()
            }
            .navigationTitle("Statistics")
        }
    }

    private func statCell(label: String, value: String) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(value).font(.title2.bold())
            Text(label).font(.caption).foregroundStyle(.secondary)
        }
    }
}

#Preview {
    StatsView()
        .modelContainer(for: [Deck.self, Card.self], inMemory: true)
}

7. Privacy Manifest (required for App Store)

As of iOS 17, Apple requires a PrivacyInfo.xcprivacy file for all new App Store submissions. This app only accesses the file system through SwiftData (no user-tracking APIs, no ad SDKs), so the manifest is minimal — but it must be present or your submission will be rejected.

<!-- PrivacyInfo.xcprivacy — add this file to your Xcode target -->
<?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>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Freemium

Implement freemium gating with StoreKit 2 using an @Observable EntitlementManager that verifies the current transaction state on launch. A natural paywall for a flashcard app limits free users to two or three decks (or 50 cards total) and puts unlimited decks, CSV import, and iCloud sync behind a one-time purchase or monthly subscription — gate these at the AddDeckSheet level so free users hit the prompt naturally during normal use. Offer a non-consumable product (e.g. "Flashcard Pro — $4.99") for learners who prefer to pay once, plus an optional subscription (e.g. "$1.99/month") for those who want iCloud sync and automatic backup. Use Product.purchase() and listen to Transaction.updates via an AsyncSequence task in your App body so entitlements update in real time without requiring an app restart.

Shipping this faster with Soarias

Soarias automates the scaffolding steps that eat the most wall-clock time on an intermediate project like this: it generates your SwiftData model files from a schema description, wires up the .modelContainer in the app entry point, creates a pre-filled PrivacyInfo.xcprivacy targeting the correct accessed API types, and configures fastlane lanes for TestFlight upload and App Store submission — including the screenshot automation that usually takes an afternoon to set up. It also writes the base StoreKit configuration file and the EntitlementManager skeleton, so you spend your time on the SM-2 engine and UI polish instead of boilerplate.

For an intermediate app like this one, most developers spend two to three days on setup, metadata, screenshots, and submission paperwork before a single user downloads it. Soarias compresses that to a few hours: the $79 one-time cost typically pays for itself on the first TestFlight build alone, and you keep every dollar of revenue with no ongoing platform fee eating into your freemium conversions.

Related guides

FAQ

Does this app work on iOS 16?

No — this guide uses SwiftData (@Model, @Query, modelContainer) and the #Preview macro, both of which require iOS 17 as a minimum deployment target. If you need iOS 16 support, you'd need to replace SwiftData with Core Data and the #Preview macro with PreviewProvider — significant extra work that isn't covered here.

Do I need a paid Apple Developer account to test on my own iPhone?

You can sideload to your personal device using a free Apple ID, but free accounts are limited to three app IDs and apps expire after seven days. For TestFlight distribution (sharing builds with beta testers) and App Store submission, you need the $99/year Apple Developer Program. If you're serious about shipping, sign up before you start — the provisioning profile setup is easier to do once at the beginning than to retrofit.

How do I submit this app to the App Store?

Archive your build in Xcode (Product → Archive), then use the Xcode Organizer to upload to App Store Connect. In ASC, fill in the app name, subtitle, description, keywords, and screenshots for every device size you support (at minimum iPhone 6.9" and iPhone 6.5"). Add your PrivacyInfo.xcprivacy-based nutrition labels under App Privacy, select your build, and submit for review. First-time reviews typically take 24–48 hours; subsequent updates are often faster.

How do I handle iCloud sync so cards are available across iPhone and iPad?

SwiftData supports CloudKit sync with minimal code changes: add the iCloud entitlement to your app target, enable CloudKit in your App Store Connect app record, and change your ModelContainer initializer to use a ModelConfiguration with cloudKitDatabase: .automatic. The main gotcha is that CloudKit requires all optional properties on your @Model classes — SwiftData will warn you at runtime if it encounters a non-optional without a default value. Test sync thoroughly on two physical devices; the Simulator's CloudKit environment is unreliable for replication testing.

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

```