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

How to Build a Wallpaper App in SwiftUI

A curated wallpaper app lets users browse high-resolution images by category, preview them full-screen, and save favorites directly to their iPhone's Photos library. It's an ideal first shipping project: no custom backend required, straightforward permissions model, and a clear path to subscription monetization.

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

Prerequisites

Architecture overview

The app follows a lightweight MVVM pattern with SwiftData as the local store. A small WallpaperService fetches the curated catalog from a remote JSON feed and inserts records into SwiftData on first launch. Views consume the store directly via @Query and @Observable — no manual objectWillChange plumbing needed. AsyncImage handles thumbnail and full-resolution loading; the actual image bytes are never stored on device, only the metadata. PHPhotoLibrary handles the one write action: saving a full-resolution image to the camera roll.

WallpaperApp/
├── WallpaperAppApp.swift          # @main, ModelContainer
├── ContentView.swift              # NavigationStack root
├── Models/
│   └── Wallpaper.swift            # @Model (SwiftData)
├── Views/
│   ├── WallpaperGridView.swift    # LazyVGrid + AsyncImage
│   ├── WallpaperThumbnail.swift   # Single grid cell
│   └── WallpaperDetailView.swift  # Full-screen + Save action
├── ViewModels/
│   └── WallpaperStore.swift       # @Observable, remote fetch
├── Services/
│   └── WallpaperService.swift     # URLSession JSON fetch
└── Resources/
    └── PrivacyInfo.xcprivacy      # Required for App Store
        

Step-by-step

1. Project setup and app entry point

In Xcode 16, create a new iOS App project (SwiftUI interface, Swift language). Name it WallpaperApp. Enable SwiftData storage when prompted, or add the SwiftData framework target manually. Wire up the ModelContainer in the app struct so the context flows down to every view automatically, then keep ContentView as a thin wrapper around a NavigationStack.

// WallpaperAppApp.swift
import SwiftUI
import SwiftData

@main
struct WallpaperAppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Wallpaper.self)
    }
}

// ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            WallpaperGridView()
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Wallpaper.self, inMemory: true)
}

2. Wallpaper data model with SwiftData

Define a Wallpaper model that stores only metadata — title, remote URLs for the thumbnail and full-resolution image, category, and favorites state. The actual image bytes live on your CDN; SwiftData holds just the lightweight record. This keeps the local database small regardless of how many wallpapers you offer.

// Models/Wallpaper.swift
import SwiftData
import Foundation

@Model
final class Wallpaper {
    var id: UUID
    var title: String
    var imageURL: String
    var thumbnailURL: String
    var category: String
    var isFavorited: Bool
    var addedAt: Date

    init(
        id: UUID = UUID(),
        title: String,
        imageURL: String,
        thumbnailURL: String,
        category: String
    ) {
        self.id = id
        self.title = title
        self.imageURL = imageURL
        self.thumbnailURL = thumbnailURL
        self.category = category
        self.isFavorited = false
        self.addedAt = .now
    }
}

3. Wallpaper grid UI with LazyVGrid and AsyncImage

Use LazyVGrid with an adaptive column spec so the grid naturally reflows from two columns on an iPhone SE to three on an iPhone 16 Pro Max without any manual breakpoints. Use the lower-resolution thumbnailURL in each cell — loading full-res images in a grid is the fastest way to cause memory pressure. @Query keeps the list in sync with SwiftData automatically.

// Views/WallpaperGridView.swift
import SwiftUI
import SwiftData

struct WallpaperGridView: View {
    @Query(sort: \Wallpaper.addedAt, order: .reverse)
    private var wallpapers: [Wallpaper]

    private let columns = [GridItem(.adaptive(minimum: 155), spacing: 10)]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 10) {
                ForEach(wallpapers) { wallpaper in
                    NavigationLink {
                        WallpaperDetailView(wallpaper: wallpaper)
                    } label: {
                        WallpaperThumbnail(wallpaper: wallpaper)
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding(12)
        }
        .navigationTitle("Wallpapers")
        .navigationBarTitleDisplayMode(.large)
    }
}

struct WallpaperThumbnail: View {
    let wallpaper: Wallpaper

    var body: some View {
        AsyncImage(url: URL(string: wallpaper.thumbnailURL)) { phase in
            switch phase {
            case .empty:
                Rectangle()
                    .fill(.secondary.opacity(0.15))
                    .overlay(ProgressView())
            case .success(let image):
                image
                    .resizable()
                    .scaledToFill()
            case .failure:
                Rectangle()
                    .fill(.secondary.opacity(0.15))
                    .overlay(
                        Image(systemName: "photo")
                            .foregroundStyle(.secondary)
                    )
            @unknown default:
                EmptyView()
            }
        }
        .aspectRatio(9 / 16, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 14))
        .overlay(alignment: .bottomTrailing) {
            if wallpaper.isFavorited {
                Image(systemName: "heart.fill")
                    .foregroundStyle(.red)
                    .padding(8)
                    .shadow(radius: 2)
            }
        }
    }
}

#Preview {
    NavigationStack {
        WallpaperGridView()
    }
    .modelContainer(for: Wallpaper.self, inMemory: true)
}

4. Detail view and Save to Photos (core feature)

The detail view displays the full-resolution wallpaper edge-to-edge and offers two actions: save to Photos (the primary CTA) and toggle favorite. iOS has no public API for setting a Home Screen or Lock Screen wallpaper programmatically, so saving to the camera roll and prompting users to set it themselves is the correct approach — be upfront in your UI copy so users don't leave confused reviews.

// Views/WallpaperDetailView.swift
import SwiftUI
import Photos

struct WallpaperDetailView: View {
    let wallpaper: Wallpaper
    @State private var saveState: SaveState = .idle
    @State private var showPermissionAlert = false

    enum SaveState { case idle, saving, saved, failed }

    var body: some View {
        GeometryReader { geo in
            AsyncImage(url: URL(string: wallpaper.imageURL)) { phase in
                switch phase {
                case .success(let image):
                    image
                        .resizable()
                        .scaledToFill()
                        .frame(width: geo.size.width, height: geo.size.height)
                        .clipped()
                case .empty:
                    Color.black
                        .overlay(ProgressView().tint(.white))
                default:
                    Color.black
                        .overlay(
                            Image(systemName: "photo")
                                .foregroundStyle(.white)
                        )
                }
            }
        }
        .ignoresSafeArea()
        .overlay(alignment: .bottom) {
            HStack(spacing: 16) {
                saveButton
                favoriteButton
            }
            .padding(.bottom, 48)
        }
        .navigationBarTitleDisplayMode(.inline)
        .alert("Photos Access Required", isPresented: $showPermissionAlert) {
            Button("Open Settings") {
                if let url = URL(string: UIApplication.openSettingsURLString) {
                    UIApplication.shared.open(url)
                }
            }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("Allow Wallpaper App to save photos in Settings.")
        }
    }

    private var saveButton: some View {
        Button {
            Task { await saveToPhotos() }
        } label: {
            Label(
                saveState == .saved ? "Saved!" : "Save to Photos",
                systemImage: saveState == .saved
                    ? "checkmark.circle.fill"
                    : "square.and.arrow.down"
            )
            .font(.headline)
            .padding(.horizontal, 22)
            .padding(.vertical, 13)
            .background(.ultraThinMaterial)
            .clipShape(Capsule())
        }
        .disabled(saveState == .saving || saveState == .saved)
    }

    private var favoriteButton: some View {
        Button {
            wallpaper.isFavorited.toggle()
        } label: {
            Image(systemName: wallpaper.isFavorited ? "heart.fill" : "heart")
                .font(.title2)
                .foregroundStyle(wallpaper.isFavorited ? .red : .white)
                .padding(14)
                .background(.ultraThinMaterial)
                .clipShape(Circle())
        }
    }

    private func saveToPhotos() async {
        saveState = .saving
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else {
            showPermissionAlert = true
            saveState = .idle
            return
        }
        guard
            let url = URL(string: wallpaper.imageURL),
            let (data, _) = try? await URLSession.shared.data(from: url),
            let image = UIImage(data: data)
        else {
            saveState = .failed
            return
        }
        do {
            try await PHPhotoLibrary.shared().performChanges {
                PHAssetChangeRequest.creationRequestForAsset(from: image)
            }
            saveState = .saved
        } catch {
            saveState = .failed
        }
    }
}

#Preview {
    NavigationStack {
        WallpaperDetailView(
            wallpaper: Wallpaper(
                title: "Mountain Sunrise",
                imageURL: "https://picsum.photos/1080/1920",
                thumbnailURL: "https://picsum.photos/540/960",
                category: "Nature"
            )
        )
    }
    .modelContainer(for: Wallpaper.self, inMemory: true)
}

5. Privacy Manifest — required for App Store

Apple requires a PrivacyInfo.xcprivacy file in every app submitted since spring 2024. In Xcode: File → New → File from Template → App Privacy. Edit the generated plist to declare that your app does not track users and collects no data. You also need NSPhotoLibraryAddUsageDescription in Info.plist — without it, the process will crash at runtime before the system permission dialog can appear.

<!-- Resources/PrivacyInfo.xcprivacy -->
<?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>
</plist>

<!-- Add to Info.plist -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Used to save wallpapers to your camera roll.</string>

If your app adds analytics (Amplitude, Mixpanel, Firebase Analytics), revisit NSPrivacyCollectedDataTypes to declare any usage and device identifiers those SDKs access — third-party SDK privacy manifests are merged at archive time, but you are still responsible for the top-level declaration.

Common pitfalls

Adding monetization: Subscription

Gate premium wallpaper categories — 4K landscapes, abstract art, seasonal packs — behind a weekly or monthly auto-renewing subscription using StoreKit 2. On app launch, call Product.products(for: ["com.yourapp.premium.monthly"]) to fetch your subscription product, and check Transaction.currentEntitlements to determine active status without a custom server. Wrap both in an @Observable class (e.g. SubscriptionStore) that any view can observe. Keep a permanent free tier of at least 20 wallpapers so users experience real value before hitting the paywall — App Store reviewers will reject apps that show a paywall immediately on first launch. Include a clearly labeled "Restore Purchases" button in your settings screen; omitting it is one of the most common subscription-app rejection reasons.

Shipping this faster with Soarias

Soarias automates the friction points that stall beginner projects before they ever reach the App Store. For a wallpaper app specifically: it generates the Xcode project scaffold with SwiftData already wired to a ModelContainer, creates the PrivacyInfo.xcprivacy with correct keys pre-filled, configures fastlane match for code signing against your Apple Developer account, and populates the required App Store Connect metadata — screenshots at every required device size (6.9", 6.5", 5.5"), privacy nutrition labels, and description copy — so you're not hunting for dimension specs at midnight before a submit.

At beginner complexity, the typical first-ship experience is one to two weekends building the app and then another weekend fighting Xcode signing, screenshot uploads, and the ASC submission checklist. With Soarias, the scaffolding and signing take minutes, TestFlight upload is a single command, and the ASC required-fields form comes back pre-filled. That's a realistic six to eight hour saving on a project where your total build time might be twelve hours — meaning Soarias halves your time-to-ship.

Related guides

FAQ

Does this work on iOS 16?

The guide uses @Query and @Model from SwiftData, which require iOS 17. The async PHPhotoLibrary.requestAuthorization(for:) API and try await PHPhotoLibrary.shared().performChanges require iOS 15+. If you need iOS 16 support, replace SwiftData with a UserDefaults-backed favorites list and a local JSON file for the catalog — everything else in the guide compiles cleanly against iOS 16.

Do I need a paid Apple Developer account to test?

Not for the Simulator. To install on a real device you need a free Apple ID — Xcode will sign the build for 7-day local development. You need the $99/year Apple Developer Program membership only when you're ready to distribute via TestFlight or submit to the App Store. Since Photos testing requires a real device, plan on the free account sideload workflow during development.

How do I add this to the App Store?

Archive the build in Xcode (Product → Archive), then distribute via Xcode Organizer (Upload to App Store). In App Store Connect, complete the required fields: screenshots for 6.9" and 6.5" devices (mandatory), a privacy nutrition label declaring no data collection, subscription pricing and duration, and a support URL. First-time apps typically go through review in one to three business days. Use TestFlight for internal testing before the public submission.

AsyncImage keeps re-fetching images when I scroll — is that normal for a beginner project?

Yes, and it's expected. AsyncImage relies on the system URL cache, which is small and in-memory only. For a curated catalog of under 100 wallpapers at thumbnail size, users will rarely notice. Once you're past launch and profiling shows real scroll jank, swap in Kingfisher or build a small NSCache<NSURL, UIImage> wrapper around URLSession. Don't add the complexity before you've validated the app with real users.

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

```