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.

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

Prerequisites

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

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.