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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge — you should be comfortable with
@State,NavigationStack, andList - Familiarity with SwiftData is helpful but not required; the guide explains each annotation
- No external dependencies — this app uses only Apple frameworks (SwiftData, Charts, StoreKit)
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
- Forgetting to save the ModelContext after SM-2 updates. If you don't call
try? context.save()after each rating, SwiftData may coalesce saves awkwardly and cards can reappear in the queue after the app is backgrounded. Save immediately after each scheduling update — it's cheap. - Using
Date.nowfor "due today" comparisons without normalizing to midnight. If you comparenextReview <= Date.nowat 9 AM, a card due at 9:01 AM that morning won't appear until the user reopens the app 2 minutes later. Decide on one comparison strategy and stick to it — rawDate.nowis fine for SM-2 since precision matters less than consistency. - App Store rejection for missing or malformed PrivacyInfo.xcprivacy. Forgetting to add the file to the app target (not just the project) is the most common mistake — Xcode will include it in the build only if it's in the correct target membership. Double-check in File Inspector before submitting.
- SwiftData relationship not cascading on deck delete. Without
deleteRule: .cascadeon theDeck.cardsrelationship, deleting a deck orphans all its cards in the store. This bloats the database silently and can cause@Queryto return stale objects. - Animating the card flip and the rating buttons simultaneously. Triggering both state changes in the same
withAnimationblock can cause the layout to stutter on older iPhones. Useanimation(.spring, value:)scoped to each view rather than a global block.
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.