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.
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 - SafariServices works in the simulator, but verify real-device behavior before submitting for review
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
- Decimal comma locales.
Double("1,99")returnsnilon devices where comma is the decimal separator (most of Europe). Always normalize the string before parsing — replace","with"."or useNumberFormatterwith.locale = .current. - AsyncImage breaking on signed CDN URLs. Many e-commerce product image URLs are signed with an expiry timestamp.
AsyncImagere-fetches on every view appearance, so images silently break after expiry. For key items, serialize the image data locally usingData(contentsOf:)at save time. - Missing Privacy Manifest causing automated rejection. App Store Connect's automated pipeline (not a human reviewer) bounces uploads that trigger required-reason API access without a
PrivacyInfo.xcprivacy. You receive a rejection email within minutes of upload — not after days of review. Ship the manifest with your very first TestFlight build. - SafariServices review flag for undisclosed external links. App Review has flagged apps that silently open product URLs in
SFSafariViewControllerwithout any UI indication that the user is leaving the app context. Add a subtle label ("Opens in Safari") or a long-press context menu with a "Open link" label. - SwiftData migration crash on update. Adding a new non-optional property to
WishlistItemin a future version without aSchemaMigrationPlancrashes on launch for users upgrading from an older build. Make new properties optional or provide a lightweight migration plan from day one.
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.