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

How to Build a Wishlist App in SwiftUI

A Wishlist App lets users save products they want to buy — complete with prices, priority levels, and purchase links — stored entirely on-device with no account required. It's an ideal first SwiftData project for iOS developers who want a polished, shippable utility app.

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

Prerequisites

Architecture overview

The app uses SwiftData for zero-config local persistence, storing WishlistItem records with name, price, URL, and priority. The view layer is a NavigationStack with a @Query-driven list that recalculates a running total live. AsyncImage loads product thumbnails from captured image URLs, and SFSafariViewController opens product pages in-app without sending users to Safari — keeping them in your app and preventing a context switch that drives drop-off.

WishlistApp/
├── WishlistApp.swift          # @main, .modelContainer setup
├── Models/
│   └── WishlistItem.swift     # @Model — name, price, url, priority
├── Views/
│   ├── WishlistView.swift     # @Query list + currency total
│   ├── WishlistRowView.swift  # AsyncImage + price + priority badge
│   ├── AddItemView.swift      # sheet — decimal keyboard, locale price
│   └── SafariView.swift       # UIViewControllerRepresentable wrapper
└── PrivacyInfo.xcprivacy      # required for App Store submission

Step-by-step

1. Data model

Mark WishlistItem with @Model so SwiftData handles persistence, versioning, and context propagation automatically — no manual save() calls needed for inserts.

import SwiftData
import Foundation

@Model
final class WishlistItem {
    var name: String
    var price: Double
    var urlString: String
    var imageURLString: String
    var notes: String
    var priority: Int          // 1 = low · 2 = medium · 3 = high
    var isPurchased: Bool
    var dateAdded: Date

    init(name: String, price: Double, urlString: String = "",
         imageURLString: String = "", notes: String = "", priority: Int = 2) {
        self.name = name; self.price = price
        self.urlString = urlString; self.imageURLString = imageURLString
        self.notes = notes; self.priority = priority
        self.isPurchased = false; self.dateAdded = .now
    }
}

2. Core UI — main list view

Use @Query to subscribe to the SwiftData store and recompute the unpurchased total on every change — no manual refresh needed and no view model boilerplate required.

struct WishlistView: View {
    @Environment(\.modelContext) private var modelContext
    @Query(sort: \WishlistItem.priority, order: .reverse) var items: [WishlistItem]
    @State private var showingAdd = false

    var total: Double { items.filter { !$0.isPurchased }.reduce(0) { $0 + $1.price } }

    var body: some View {
        NavigationStack {
            List {
                Section {
                    HStack {
                        Text("Remaining total").foregroundStyle(.secondary)
                        Spacer()
                        Text(total, format: .currency(
                            code: Locale.current.currency?.identifier ?? "USD"
                        )).fontWeight(.semibold)
                    }
                }
                ForEach(items) { WishlistRowView(item: $0) }
                    .onDelete { offsets in offsets.forEach { modelContext.delete(items[$0]) } }
            }
            .navigationTitle("Wishlist")
            .toolbar { Button("Add", systemImage: "plus") { showingAdd = true } }
            .sheet(isPresented: $showingAdd) { AddItemView() }
        }
    }
}

3. Core feature — items with prices

Use a decimal keyboard for price entry and normalize the input before parsing — different locales use commas as decimal separators, and Double("1,99") returns nil without this fix.

struct AddItemView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    @State private var name = ""; @State private var priceText = ""
    @State private var urlString = ""; @State private var priority = 2

    var parsedPrice: Double {
        Double(priceText.replacingOccurrences(of: ",", with: ".")) ?? 0
    }

    var body: some View {
        NavigationStack {
            Form {
                TextField("Item name", text: $name)
                HStack {
                    Text(Locale.current.currencySymbol ?? "$").foregroundStyle(.secondary)
                    TextField("0.00", text: $priceText).keyboardType(.decimalPad)
                }
                TextField("Product URL (optional)", text: $urlString)
                    .keyboardType(.URL).autocorrectionDisabled()
                Picker("Priority", selection: $priority) {
                    Text("Low").tag(1)
                    Text("Medium").tag(2)
                    Text("High").tag(3)
                }.pickerStyle(.segmented)
            }
            .navigationTitle("Add Item").navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        modelContext.insert(WishlistItem(name: name, price: parsedPrice,
                            urlString: urlString, priority: priority))
                        dismiss()
                    }.disabled(name.isEmpty)
                }
            }
        }
    }
}

4. Privacy Manifest

Apple requires PrivacyInfo.xcprivacy for any app that touches required-reason APIs — SwiftData internally accesses UserDefaults and file timestamps, so both must be declared.

<?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: One-time purchase

Implement a non-consumable In-App Purchase using StoreKit 2's async/await API. Gate a premium feature — unlimited items (cap the free tier at 10 in WishlistView), iCloud sync via CloudKit, or custom categories — behind a single unlock. Declare the IAP product in App Store Connect, then call Product.products(for: ["com.yourapp.wishlist.pro"]) at launch and check Transaction.currentEntitlements on each app open to determine unlock status. Never store the unlock only in UserDefaults — always revalidate against StoreKit, as reviewers actively test IAP bypass. StoreKit 2 handles receipt validation server-side, so there's no backend required.

Shipping this faster with Soarias

Soarias scaffolds the full SwiftData + SwiftUI project from a plain-English description — wiring the .modelContainer modifier into your @main entry point, generating the PrivacyInfo.xcprivacy with the correct reason codes for SwiftData's internal API access, configuring fastlane lanes for simulator screenshots across all required device sizes, and pre-filling your App Store Connect listing: app name, subtitle, privacy policy URL, age rating, and category.

For a beginner app like this Wishlist App, most developers finish the core code in a weekend but spend another full day on App Store paperwork — screenshots for iPhone 6.9" and 6.5", metadata fields, IAP setup, and the privacy nutrition label questionnaire. Soarias compresses that overhead to under two hours, so your first TestFlight invite goes out the same day you finish the last line of Swift.

Related guides

FAQ

Do I need a paid Apple Developer account?

You can build and side-load the app on your own device for free using a personal team. But to distribute on TestFlight, publish to the App Store, or configure an In-App Purchase product in App Store Connect, you need an Apple Developer Program membership at $99/year.

How do I submit this to the App Store?

Archive the app in Xcode (Product → Archive), then upload via the Organizer. In App Store Connect, complete your listing: screenshots for iPhone 6.9" and 6.5" at minimum, a privacy policy URL (required even if you collect no data), the age rating questionnaire, and your IAP product details. Once every required field shows a green checkmark, click Submit for Review.

Can users share their wishlist with others?

Not by default — SwiftData persists data locally only. The easiest sharing path is to export a wishlist as a plain-text or CSV string using ShareLink, which requires zero entitlements and works on day one. For real-time sharing between devices, you'd add the iCloud CloudKit capability and pass a .cloudKit configuration to your .modelContainer — a natural premium feature to gate behind the one-time IAP.

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

```