How to Build a Shopping List App in SwiftUI
A Shopping List app lets users track groceries organized by store section — produce, dairy, meat, frozen — so they never miss an aisle. It's a perfect first iOS project: local-first persistence with SwiftData, a category picker sheet, and swipe-to-delete all in under 200 lines of SwiftUI.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for testing SwipeActions — Simulator renders them but the feel differs, and you'll want to verify delete gestures before submission
Architecture overview
The app is fully local-first. SwiftData stores GroceryItem records on-device — no network calls, no account required. A single @Query feeds the main view, which groups items by category using a computed Dictionary. State flows down via @Environment(\.modelContext), keeping all mutations explicit and testable. For monetization, a Google AdMob banner view is the only external dependency, and it ships with its own privacy manifest since SDK 10.
ShoppingListApp/ ├── Models/ │ └── GroceryItem.swift # @Model + GroceryCategory enum ├── Views/ │ ├── ShoppingListView.swift # grouped list, swipe actions │ ├── GroceryRowView.swift # checkbox + name row │ └── AddItemView.swift # add-item sheet └── PrivacyInfo.xcprivacy # required for App Store
Step-by-step
1. Data model
Define a GroceryCategory enum and a SwiftData @Model class — the framework handles persistence, migrations, and @Query change tracking automatically.
import SwiftData
enum GroceryCategory: String, CaseIterable, Codable {
case produce = "Produce"
case dairy = "Dairy"
case meat = "Meat"
case bakery = "Bakery"
case frozen = "Frozen"
case beverages = "Beverages"
case other = "Other"
}
@Model
final class GroceryItem {
var name: String
var category: GroceryCategory
var quantity: String
var isChecked: Bool
var dateAdded: Date
init(name: String, category: GroceryCategory = .other, quantity: String = "1") {
self.name = name; self.category = category
self.quantity = quantity; self.isChecked = false
self.dateAdded = .now
}
}
2. Main list view
Build the root view with category sections and .swipeActions — the two SwiftUI components that make the app feel native without any custom gesture code.
struct ShoppingListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \GroceryItem.dateAdded) private var items: [GroceryItem]
@State private var showAddItem = false
var body: some View {
NavigationStack {
List {
ForEach(GroceryCategory.allCases, id: \.self) { cat in
let rows = items.filter { $0.category == cat }
if !rows.isEmpty {
Section(cat.rawValue) {
ForEach(rows) { item in
GroceryRowView(item: item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { modelContext.delete(item) }
label: { Label("Delete", systemImage: "trash") }
}
}
}
}
}
}
.navigationTitle("Shopping List")
.toolbar { Button("Add", systemImage: "plus") { showAddItem = true } }
.sheet(isPresented: $showAddItem) { AddItemView() }
}
}
}
3. Grocery list with categories
The add-item sheet is where category assignment happens — a Picker lets users sort into the right aisle before saving, keeping the main list clean from the start.
struct AddItemView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var quantity = "1"
@State private var category: GroceryCategory = .other
var isValid: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty }
var body: some View {
NavigationStack {
Form {
TextField("Item name", text: $name)
TextField("Quantity", text: $quantity).keyboardType(.numbersAndPunctuation)
Picker("Category", selection: $category) {
ForEach(GroceryCategory.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
}
.navigationTitle("Add Item").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
modelContext.insert(GroceryItem(name: name, category: category, quantity: quantity))
dismiss()
}.disabled(!isValid)
}
}
}
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your Xcode target (File → New → File → App Privacy) — App Store Connect rejects uploads missing this file for apps targeting iOS 17+.
<?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>
</array>
</dict>
</plist>
Common pitfalls
- Missing
.modelContainermodifier. Forgetting.modelContainer(for: GroceryItem.self)on yourWindowGroupscene causes a crash on launch. There's no graceful fallback — SwiftData requires the container before any view runs. - Sorting on a non-optional in
@Query. If you later add an optional property and try to sort on it, SwiftData throws at runtime. Keep all@Modelproperties non-optional or supply explicit defaults ininit. - Missing AdMob privacy manifest. If you integrate Google AdMob SDK older than version 10, it ships without a privacy manifest. App Store Review will reject the build. Use AdMob SDK 10+ or bundle the manifest manually — Apple's review email won't clearly name which SDK is the culprit.
- Screenshot device mismatch. App Store Connect requires screenshots for every selected device family (6.9", 6.5", iPad if you support it). Uploading an archive without them blocks submission entirely — prepare these before your first TestFlight upload, not after.
- Calling
modelContext.save()in a loop. SwiftData autosaves on the run loop. Manually callingsave()insideForEachor after every toggle causes redundant writes and can produce race conditions; remove it unless you have a specific reason to force a synchronous save.
Adding monetization: Ad-supported
Add the GoogleMobileAds Swift package (https://github.com/googleads/swift-package-manager-google-mobile-ads), drop your GADApplicationIdentifier key into Info.plist, and call MobileAds.initialize(completionHandler: nil) in your @main App's init(). Wrap GADBannerView in a UIViewRepresentable and pin it to the bottom of ShoppingListView using an adaptive banner size. For an optional "Remove Ads" upgrade, pair AdMob with a StoreKit 2 Product.purchase() flow and gate the banner behind an @AppStorage("adsRemoved") boolean — no server required.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model and GroceryCategory enum from a prompt, generates a correctly populated PrivacyInfo.xcprivacy with the right API reason codes, and configures fastlane lanes for screenshot capture and App Store Connect submission in one pass. It also surfaces the AdMob Info.plist keys you need and flags whether your SDK version ships its own privacy manifest — catching the most common beginner rejection before it costs you a review cycle.
For a beginner-complexity app like this one, the non-feature overhead — provisioning profiles, signing, metadata, screenshot framing, fastlane config — typically burns 3–5 hours on a first build. Soarias compresses that to under 30 minutes, so both weekends stay free for actual product decisions: which categories to ship, how the checkbox interaction should feel, whether to add a share sheet for the list.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A $99/year Apple Developer Program membership is required to distribute on TestFlight or the App Store. A free Apple ID lets you sideload onto your personal device for testing, but you cannot submit to App Store Connect or share builds via TestFlight without a paid membership.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then upload via the Organizer window or the xcodebuild CLI. In App Store Connect, complete the listing — name, subtitle, description, screenshots, privacy nutrition labels, and pricing. Fastlane's deliver lane automates the metadata and screenshot upload steps once configured.
Can I sync the list across iPhone and iPad?
Yes. Enable the iCloud capability in Xcode's Signing & Capabilities tab, add CloudKit, then swap your SwiftData container to use ModelConfiguration(cloudKitDatabase: .automatic). SwiftData handles syncing and conflict resolution automatically across all devices signed into the same Apple ID — no additional server code required.
Last reviewed: 2026-05-12 by the Soarias team.