How to Build a Reading Tracker App in SwiftUI
A reading tracker lets users log every book they've read or are currently reading, record their current page, and visualise their reading habit over time with a clean chart. It's ideal for avid readers who want a lightweight, private alternative to Goodreads — with their data stored entirely on-device.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge — familiarity with
@StateandNavigationStackis enough - No third-party services required; all data lives on-device with SwiftData
Architecture overview
The app uses a single-layer SwiftData store as its data layer — no network calls, no server. A Book model holds metadata and an array of daily ReadingSession records. SwiftUI views observe the model container directly via @Query, so there is no separate view-model class needed. Book cover art is fetched lazily with AsyncImage from an Open Library URL stored on the model. Swift Charts renders a bar chart of pages read per day, powered by the session array already in the store.
ReadingTrackerApp/ ├── ReadingTrackerApp.swift # @main, ModelContainer setup ├── Models/ │ ├── Book.swift # @Model: title, author, coverURL, pageCount, currentPage │ └── ReadingSession.swift # @Model: date, pagesRead, relationship to Book ├── Views/ │ ├── BookListView.swift # @Query list, add-book sheet trigger │ ├── BookDetailView.swift # progress bar, Charts bar chart, session log │ ├── AddBookView.swift # form: title, author, ISBN, page count │ └── Components/ │ ├── BookRowView.swift # AsyncImage + metadata row │ └── ProgressRingView.swift ├── PrivacyInfo.xcprivacy # required for App Store └── Assets.xcassets
Step-by-step
1. Project setup
Create a new Xcode project using the App template, set the interface to SwiftUI, and tick Use SwiftData in the project wizard. Then replace the default app entry point with a clean ModelContainer configuration that includes both models.
// ReadingTrackerApp.swift
import SwiftUI
import SwiftData
@main
struct ReadingTrackerApp: App {
var body: some Scene {
WindowGroup {
BookListView()
}
.modelContainer(for: [Book.self, ReadingSession.self])
}
}
2. Book data model
Define two @Model classes. Book stores the core metadata plus a computed progress percentage. ReadingSession records how many pages were read on a given day so Charts has per-day data to plot.
// Models/Book.swift
import SwiftData
import Foundation
@Model
final class Book {
var title: String
var author: String
var pageCount: Int
var currentPage: Int
var coverURL: String // Open Library: https://covers.openlibrary.org/b/isbn/{isbn}-M.jpg
var dateAdded: Date
var isFinished: Bool
@Relationship(deleteRule: .cascade)
var sessions: [ReadingSession] = []
var progress: Double {
guard pageCount > 0 else { return 0 }
return Double(currentPage) / Double(pageCount)
}
init(title: String, author: String, pageCount: Int, coverURL: String = "") {
self.title = title
self.author = author
self.pageCount = pageCount
self.currentPage = 0
self.coverURL = coverURL
self.dateAdded = .now
self.isFinished = false
}
}
// Models/ReadingSession.swift
@Model
final class ReadingSession {
var date: Date
var pagesRead: Int
var book: Book?
init(date: Date = .now, pagesRead: Int, book: Book? = nil) {
self.date = date
self.pagesRead = pagesRead
self.book = book
}
}
3. Book list UI with AsyncImage
Use @Query to fetch all books directly in the view — no view model needed for a beginner project. AsyncImage handles cover loading with a placeholder so the list never blocks on network.
// Views/BookListView.swift
import SwiftUI
import SwiftData
struct BookListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Book.dateAdded, order: .reverse) private var books: [Book]
@State private var showingAddBook = false
var body: some View {
NavigationStack {
List {
ForEach(books) { book in
NavigationLink(value: book) {
BookRowView(book: book)
}
}
.onDelete(perform: deleteBooks)
}
.navigationTitle("My Library")
.navigationDestination(for: Book.self) { book in
BookDetailView(book: book)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add Book", systemImage: "plus") {
showingAddBook = true
}
}
}
.sheet(isPresented: $showingAddBook) {
AddBookView()
}
.overlay {
if books.isEmpty {
ContentUnavailableView(
"No Books Yet",
systemImage: "books.vertical",
description: Text("Tap + to add your first book.")
)
}
}
}
}
private func deleteBooks(at offsets: IndexSet) {
for index in offsets {
context.delete(books[index])
}
}
}
// Views/Components/BookRowView.swift
struct BookRowView: View {
let book: Book
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: URL(string: book.coverURL)) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
case .failure, .empty:
Image(systemName: "book.closed.fill")
.foregroundStyle(.secondary)
.font(.title2)
@unknown default:
ProgressView()
}
}
.frame(width: 44, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 6))
.background(Color(.systemGroupedBackground), in: RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 4) {
Text(book.title)
.font(.headline)
.lineLimit(1)
Text(book.author)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
ProgressView(value: book.progress)
.tint(book.isFinished ? .green : .blue)
}
}
.padding(.vertical, 4)
}
}
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Book.self, configurations: config)
let sample = Book(title: "The Pragmatic Programmer", author: "Hunt & Thomas",
pageCount: 352, coverURL: "")
sample.currentPage = 180
container.mainContext.insert(sample)
return BookListView().modelContainer(container)
}
4. Reading progress tracking with Charts
The detail view lets users log today's page, updates currentPage on the model, and creates a new ReadingSession — which immediately appears in the bar chart below. Swift Charts' BarMark needs a date-keyed value; group sessions by calendar day to avoid duplicate bars.
// Views/BookDetailView.swift
import SwiftUI
import SwiftData
import Charts
struct BookDetailView: View {
@Bindable var book: Book
@Environment(\.modelContext) private var context
@State private var newPage: String = ""
@State private var showingPageEntry = false
private var chartData: [(day: Date, pages: Int)] {
let calendar = Calendar.current
let grouped = Dictionary(grouping: book.sessions) { session in
calendar.startOfDay(for: session.date)
}
return grouped
.map { (day: $0.key, pages: $0.value.reduce(0) { $0 + $1.pagesRead }) }
.sorted { $0.day < $1.day }
.suffix(14) // last 14 days
.map { $0 }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Cover + meta
HStack(spacing: 16) {
AsyncImage(url: URL(string: book.coverURL)) { phase in
if case .success(let img) = phase {
img.resizable().scaledToFill()
} else {
Image(systemName: "book.closed.fill")
.font(.largeTitle)
.foregroundStyle(.secondary)
}
}
.frame(width: 80, height: 110)
.clipShape(RoundedRectangle(cornerRadius: 8))
.background(Color(.systemGroupedBackground),
in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 6) {
Text(book.title).font(.title3).bold()
Text(book.author).foregroundStyle(.secondary)
Text("\(book.currentPage) / \(book.pageCount) pages")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
// Progress
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Progress")
.font(.headline)
Spacer()
Text("\(Int(book.progress * 100))%")
.foregroundStyle(.secondary)
}
ProgressView(value: book.progress)
.tint(book.isFinished ? .green : .blue)
.scaleEffect(x: 1, y: 2, anchor: .center)
}
.padding(.horizontal)
// Log pages button
Button {
newPage = "\(book.currentPage)"
showingPageEntry = true
} label: {
Label("Update Page", systemImage: "pencil.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
// Chart
if !book.sessions.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Pages per day (last 14 days)")
.font(.headline)
.padding(.horizontal)
Chart(chartData, id: \.day) { item in
BarMark(
x: .value("Day", item.day, unit: .day),
y: .value("Pages", item.pages)
)
.foregroundStyle(.blue.gradient)
.cornerRadius(4)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 3)) { _ in
AxisValueLabel(format: .dateTime.month().day())
AxisGridLine()
}
}
.frame(height: 180)
.padding(.horizontal)
}
}
}
.padding(.vertical)
}
.navigationTitle(book.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Toggle("Finished", isOn: $book.isFinished)
.toggleStyle(.button)
.tint(.green)
}
}
.alert("Update current page", isPresented: $showingPageEntry) {
TextField("Page number", text: $newPage)
.keyboardType(.numberPad)
Button("Save") { logProgress() }
Button("Cancel", role: .cancel) { }
}
}
private func logProgress() {
guard let page = Int(newPage), page > book.currentPage, page <= book.pageCount else { return }
let pagesRead = page - book.currentPage
book.currentPage = page
if page == book.pageCount { book.isFinished = true }
let session = ReadingSession(pagesRead: pagesRead, book: book)
context.insert(session)
book.sessions.append(session)
}
}
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Book.self, ReadingSession.self, configurations: config)
let book = Book(title: "Atomic Habits", author: "James Clear", pageCount: 320)
book.currentPage = 128
container.mainContext.insert(book)
let session = ReadingSession(
date: Calendar.current.date(byAdding: .day, value: -1, to: .now)!,
pagesRead: 40, book: book)
container.mainContext.insert(session)
book.sessions.append(session)
return NavigationStack { BookDetailView(book: book) }
.modelContainer(container)
}
5. Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy file in every new app submission. Because this app fetches book cover images from Open Library (a network call) and uses UserDefaults indirectly via SwiftData, you must declare both. Without this file your binary will be rejected at upload time.
<!-- PrivacyInfo.xcprivacy (add via File ▸ New ▸ App Privacy File) -->
<?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>
<!-- No data sent to Soarias or any first-party server -->
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<!-- Required-reason APIs used by SwiftData / Foundation -->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string> <!-- Read/write only from same app -->
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string> <!-- Display to user -->
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Common pitfalls
-
SwiftData migration crashes on update. If you change a
@Modelproperty type or add a non-optional property without a default value after shipping, existing stores crash on open. Use lightweight migration or provide a default:var genre: String = ""from day one to leave room. -
@Queryin a child view loses context.@Queryreads from the container injected into the environment. If you present a sheet with a brand-newModelContainerin a#Preview, the sheet view's@Querysees a different store and returns nothing. Always pass the same container. -
AsyncImage caching is per-session.
AsyncImageusesURLSession.shared's in-memory cache only. Cover images will re-download every cold launch. For a smoother UX, save the resolvedDatainto the model after first fetch, or use a light caching library — App Review won't flag this, but reviewers do notice slow image loads. - App Store rejection for missing book cover attribution. Open Library covers are openly licensed, but Apple reviewers occasionally flag apps that display third-party images without any attribution or without a way to remove a user's data. Add an "About" screen crediting Open Library to head this off.
-
Charts import missing.
import Chartsis a separate framework from SwiftUI. Forgetting it gives a cryptic "cannot find type 'Chart' in scope" error — not a missing package, just a missing import.
Adding monetization: One-time purchase
A reading tracker is a natural fit for a one-time unlock — users pay once to remove a book-count cap (for example, free tier allows 10 books, paid unlocks unlimited). Implement this with StoreKit 2's Product.purchase() API: define a non-consumable in-app purchase in App Store Connect, fetch it with Product.products(for: ["com.yourapp.unlimited"]) at launch, and gate the "Add Book" button behind a check on Transaction.currentEntitlement(for:). Because StoreKit 2 is async/await-native and handles receipt validation server-side, you do not need a backend. Store the entitlement state in an @Observable StoreService class injected into the environment so any view can gate features without duplicating purchase logic.
Shipping this faster with Soarias
Soarias automates the scaffolding from Step 1 — it generates the full Xcode project with SwiftData models, a pre-wired ModelContainer, and the PrivacyInfo.xcprivacy file already filled in for common required-reason APIs. It also sets up a Fastlane Matchfile for code-signing, writes your Deliverfile with the App Store metadata fields, and submits the binary to App Store Connect via the ASC API — no manual Xcode Organizer upload needed.
For a beginner-complexity app like this reading tracker, the manual path typically costs one full weekend just on provisioning, signing, and first-submission plumbing. With Soarias that overhead shrinks to under an hour, so you spend both weekends on the actual product — the progress chart, the cover art UX, the StoreKit paywall — rather than wrestling with certificates.
Related guides
FAQ
Does this work on iOS 16?
No. SwiftData requires iOS 17 or later, and the #Preview macro and ContentUnavailableView also require iOS 17+. If you need iOS 16 support you would have to replace SwiftData with Core Data and @FetchRequest, and swap ContentUnavailableView for a custom empty-state view — a significant rewrite. Given that iOS 17+ market share is now well above 90%, targeting iOS 17 as the minimum is the practical choice for a new app.
Do I need a paid Apple Developer account to test?
Not for running on your own device via Xcode. A free Apple ID lets you sideload to a personal device for 7 days before needing a re-sign. However, you do need the paid $99/year Apple Developer Program membership to distribute via TestFlight or submit to the App Store, and to use push notifications or certain entitlements like HealthKit.
How do I add this to the App Store?
Archive the app in Xcode (Product ▸ Archive), then use the Xcode Organizer or xcrun altool / Fastlane's deliver to upload the binary to App Store Connect. From there, fill in the metadata (screenshots, description, privacy labels), set your pricing, and submit for review. First-time reviews typically take 24–48 hours. Make sure your PrivacyInfo.xcprivacy is included — missing it is one of the most common reasons for automated rejection before a human reviewer even sees the app.
I'm a beginner — do I need to understand Core Data to use SwiftData?
No. SwiftData is a clean Swift-native API built on top of Core Data, but you never interact with Core Data types directly. If you know how to use a Swift class and the @State property wrapper, SwiftData's @Model and @Query will feel immediately familiar. The main gotcha for beginners is remembering that @Model classes must be final — the compiler will remind you if you forget.
Last reviewed: 2026-05-11 by the Soarias team.