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

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.

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

Prerequisites

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

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.

```