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

How to Build a Sticker Maker App in SwiftUI

A Sticker Maker app lets users import photos, automatically remove backgrounds with on-device Vision, and organise the results into iMessage-ready sticker packs. It's ideal for creators, pet owners, and anyone who wants personalised stickers without a subscription.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app is split into two targets that share a SwiftData store via an App Group container. The main target handles photo import (PhotosUI), background removal (Vision), and pack management (SwiftUI + SwiftData). A lightweight Messages Extension target reads the same SwiftData store to surface packs inside iMessage through the MSMessages framework. State flows from a single @Observable StickerStore class injected into the SwiftUI environment; background-removal work runs on a Task so the UI stays responsive. Finished stickers are stored as PNG Data blobs in SwiftData rather than the file system, keeping the App Group footprint tidy and backup-friendly.

StickerMaker/
├── App/
│   ├── StickerMakerApp.swift          # @main, modelContainer setup
│   ├── ContentView.swift              # Tab: Packs list + camera roll
│   ├── PackDetailView.swift           # Grid of stickers in a pack
│   ├── StickerEditorView.swift        # Crop / preview before saving
│   └── StoreView.swift               # One-time purchase paywall
├── Model/
│   ├── StickerPack.swift              # @Model — name, createdAt, stickers
│   └── Sticker.swift                  # @Model — pngData, name, packID
├── Services/
│   ├── StickerStore.swift             # @Observable — CRUD, export
│   └── BackgroundRemover.swift        # Vision pipeline (async)
├── MessagesExtension/
│   ├── MessagesViewController.swift   # MSMessagesAppViewController
│   └── StickerBrowserView.swift       # Reads shared SwiftData store
└── PrivacyInfo.xcprivacy              # Required by App Store
      

Step-by-step

1. Project setup and target configuration

Create a new iOS App project in Xcode, then add a Messages Extension target via File › New › Target. Both targets need the same App Group so they can share a SwiftData store — add the com.yourteam.stickermaker.group capability in Signing & Capabilities for both.

// StickerMakerApp.swift
import SwiftUI
import SwiftData

@main
struct StickerMakerApp: App {
    // Shared container stored in the App Group so the Messages
    // Extension can read the same packs.
    private let container: ModelContainer = {
        let groupURL = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier:
                "group.com.yourteam.stickermaker")!
        let storeURL = groupURL.appending(path: "default.store")
        let config = ModelConfiguration(url: storeURL)
        return try! ModelContainer(
            for: StickerPack.self, Sticker.self,
            configurations: config
        )
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(container)
        }
    }
}

2. Data model with SwiftData

Define two @Model types: StickerPack (a named collection) and Sticker (a PNG blob with metadata). The relationship is one-to-many with cascade delete so removing a pack cleans up its stickers automatically.

// Model/StickerPack.swift
import SwiftData
import Foundation

@Model
final class StickerPack {
    var name: String
    var createdAt: Date
    var isUnlocked: Bool          // for one-time purchase gating
    @Relationship(deleteRule: .cascade)
    var stickers: [Sticker]

    init(name: String) {
        self.name = name
        self.createdAt = .now
        self.isUnlocked = false
        self.stickers = []
    }
}

// Model/Sticker.swift
import SwiftData
import Foundation

@Model
final class Sticker {
    var name: String
    var pngData: Data             // transparent-background PNG
    var createdAt: Date
    var pack: StickerPack?

    init(name: String, pngData: Data) {
        self.name = name
        self.pngData = pngData
        self.createdAt = .now
    }
}

3. Photo picker with PhotosUI

Use SwiftUI's PhotosPicker (iOS 16+) to let users choose one or more images. Load them as Data asynchronously so you can pass the raw bytes straight to the Vision pipeline without touching the file system.

// PackDetailView.swift (excerpt)
import SwiftUI
import PhotosUI
import SwiftData

struct PackDetailView: View {
    @Environment(\.modelContext) private var context
    @Bindable var pack: StickerPack

    @State private var pickerItems: [PhotosPickerItem] = []
    @State private var isRemoving = false
    @State private var errorMessage: String?

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 12) {
                ForEach(pack.stickers) { sticker in
                    StickerCell(sticker: sticker)
                }
            }
            .padding()
        }
        .navigationTitle(pack.name)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                PhotosPicker(
                    selection: $pickerItems,
                    maxSelectionCount: 10,
                    matching: .images
                ) {
                    Label("Add photos", systemImage: "plus")
                }
            }
        }
        .overlay {
            if isRemoving {
                ProgressView("Removing backgrounds…")
                    .padding()
                    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
            }
        }
        .onChange(of: pickerItems) { _, newItems in
            Task { await importItems(newItems) }
        }
    }

    private func importItems(_ items: [PhotosPickerItem]) async {
        isRemoving = true
        defer { isRemoving = false; pickerItems = [] }
        for item in items {
            guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
            if let png = await BackgroundRemover.removingBackground(from: data) {
                let sticker = Sticker(name: "Sticker \(pack.stickers.count + 1)", pngData: png)
                sticker.pack = pack
                context.insert(sticker)
            }
        }
        try? context.save()
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: StickerPack.self, Sticker.self, configurations: config)
    let pack = StickerPack(name: "My Pets")
    container.mainContext.insert(pack)
    return NavigationStack {
        PackDetailView(pack: pack)
    }
    .modelContainer(container)
}

4. Background removal with Vision

iOS 17 introduced VNGenerateForegroundInstanceMaskRequest, which isolates subjects entirely on-device with no API key or network call needed. The pipeline converts the result mask into a transparent-background PNG by applying it to the original CIImage.

// Services/BackgroundRemover.swift
import Vision
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit

enum BackgroundRemover {
    /// Returns a transparent-background PNG, or nil on failure.
    static func removingBackground(from imageData: Data) async -> Data? {
        await Task.detached(priority: .userInitiated) {
            guard let uiImage = UIImage(data: imageData),
                  let ciImage = CIImage(image: uiImage) else { return nil }

            let request = VNGenerateForegroundInstanceMaskRequest()
            let handler = VNImageRequestHandler(ciImage: ciImage)
            do {
                try handler.perform([request])
            } catch {
                return nil
            }

            guard let result = request.results?.first else { return nil }

            // Build a mask CIImage from the observation
            guard let maskPixelBuffer = try? result.generateScaledMaskForImage(
                forInstances: result.allInstances,
                from: handler
            ) else { return nil }

            let maskCI = CIImage(cvPixelBuffer: maskPixelBuffer)

            // Blend: keep original RGB, use mask as alpha
            let blendFilter = CIFilter.blendWithMask()
            blendFilter.inputImage = ciImage
            blendFilter.backgroundImage = CIImage.empty()
            blendFilter.maskImage = maskCI

            guard let output = blendFilter.outputImage else { return nil }

            let ctx = CIContext()
            // Render with alpha channel preserved
            guard let cgImage = ctx.createCGImage(
                output,
                from: output.extent,
                format: .RGBA8,
                colorSpace: CGColorSpaceCreateDeviceRGB()
            ) else { return nil }

            return UIImage(cgImage: cgImage).pngData()
        }.value
    }
}

5. Sticker pack management

The main content view lists all packs, lets users create and rename them, and provides a swipe-to-delete gesture. A share sheet wraps the PNG data in a temporary file so users can also export individual stickers to Files, Shortcuts, or AirDrop.

// ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \StickerPack.createdAt, order: .reverse)
    private var packs: [StickerPack]

    @State private var newPackName = ""
    @State private var showingNewPackSheet = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(packs) { pack in
                    NavigationLink(value: pack) {
                        VStack(alignment: .leading) {
                            Text(pack.name).font(.headline)
                            Text("\(pack.stickers.count) stickers")
                                .font(.caption).foregroundStyle(.secondary)
                        }
                    }
                }
                .onDelete(perform: deletePacks)
            }
            .navigationTitle("Sticker Packs")
            .navigationDestination(for: StickerPack.self) { pack in
                PackDetailView(pack: pack)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("New Pack", systemImage: "folder.badge.plus") {
                        showingNewPackSheet = true
                    }
                }
            }
            .sheet(isPresented: $showingNewPackSheet) {
                newPackSheet
            }
        }
    }

    private var newPackSheet: some View {
        NavigationStack {
            Form {
                TextField("Pack name", text: $newPackName)
            }
            .navigationTitle("New Pack")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { showingNewPackSheet = false }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Create") {
                        guard !newPackName.isEmpty else { return }
                        context.insert(StickerPack(name: newPackName))
                        newPackName = ""
                        showingNewPackSheet = false
                    }
                    .disabled(newPackName.isEmpty)
                }
            }
        }
        .presentationDetents([.medium])
    }

    private func deletePacks(at offsets: IndexSet) {
        for index in offsets { context.delete(packs[index]) }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: [StickerPack.self, Sticker.self], inMemory: true)
}

6. Messages Extension integration

The Messages Extension writes PNG files to a shared temporary directory and wraps them in MSSticker objects. MSStickerBrowserViewController handles the iMessage presentation automatically — you only need to supply the sticker array from the shared SwiftData store.

// MessagesExtension/MessagesViewController.swift
import Messages
import SwiftData
import UIKit

final class MessagesViewController: MSMessagesAppViewController {

    private var stickers: [MSSticker] = []
    private lazy var browser = MSStickerBrowserViewController(stickerSize: .regular)

    override func viewDidLoad() {
        super.viewDidLoad()
        loadStickers()
        addChild(browser)
        view.addSubview(browser.view)
        browser.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            browser.view.topAnchor.constraint(equalTo: view.topAnchor),
            browser.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            browser.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            browser.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        browser.didMove(toParent: self)
        browser.stickerBrowserView.dataSource = self
    }

    private func loadStickers() {
        // Open the shared SwiftData store (same App Group container)
        let groupURL = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier:
                "group.com.yourteam.stickermaker")!
        let storeURL = groupURL.appending(path: "default.store")
        let config = ModelConfiguration(url: storeURL, isStoredInMemoryOnly: false)
        guard let container = try? ModelContainer(
            for: StickerPack.self, Sticker.self, configurations: config
        ) else { return }

        let ctx = ModelContext(container)
        let allStickers = (try? ctx.fetch(FetchDescriptor())) ?? []

        let tmpDir = FileManager.default.temporaryDirectory
        stickers = allStickers.compactMap { sticker in
            let url = tmpDir.appending(path: "\(sticker.persistentModelID).png")
            if !FileManager.default.fileExists(atPath: url.path) {
                try? sticker.pngData.write(to: url)
            }
            return try? MSSticker(contentsOfFileURL: url, localizedDescription: sticker.name)
        }
    }
}

extension MessagesViewController: MSStickerBrowserViewDataSource {
    func numberOfStickers(in stickerBrowserView: MSStickerBrowserView) -> Int {
        stickers.count
    }
    func stickerBrowserView(
        _ stickerBrowserView: MSStickerBrowserView,
        stickerAt index: Int
    ) -> MSSticker {
        stickers[index]
    }
}

7. Privacy Manifest

Apple requires a PrivacyInfo.xcprivacy file for any app that accesses the photo library or uses certain system APIs. Add the file to both targets (or a shared resource) so the App Store build pipeline can validate it before review even starts.

<!-- PrivacyInfo.xcprivacy (Property List source) -->
<?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>
  <!-- Declare the Photo Library usage reason -->
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryPhotoLibrary</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <!-- "App uses photo library to let users create stickers" -->
        <string>1.1</string>
      </array>
    </dict>
  </array>
  <!-- No data sent off-device; all processing is local -->
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyTracking</key>
  <false/>
</dict>
</plist>

// Also add to Info.plist (both targets):
// NSPhotoLibraryUsageDescription =
//   "Used to import photos and turn them into stickers."

Common pitfalls

Adding monetization: One-time purchase

A one-time in-app purchase (non-consumable) is the cleanest monetisation model for a sticker maker — users pay once and unlock all packs and future stickers without a recurring charge. Implement it with StoreKit 2: define a non-consumable product in App Store Connect (e.g. com.yourteam.stickermaker.fullaccess), then use Product.products(for:) to fetch it and product.purchase() to initiate the flow. Listen on Transaction.updates in a Task at app launch so re-installs and family-sharing grants are handled automatically. Store the purchase state in UserDefaults(suiteName:) keyed to the App Group so the Messages Extension can also check entitlement without needing to call StoreKit itself. Gate the ability to create more than one pack (or more than five stickers per pack) behind the purchase — enough value to drive conversion without blocking the user entirely on first launch.

Shipping this faster with Soarias

Soarias scaffolds the entire two-target project (main app + Messages Extension) with the shared App Group wired up, SwiftData models generated, and the PrivacyInfo.xcprivacy file pre-populated for photo library access — the boilerplate that typically eats the first half-day. It also generates your fastlane Matchfile and Appfile for both targets, captures App Store screenshots on a real device via fastlane snapshot, and submits the binary to App Store Connect with metadata filled in, so you skip the manual form entirely.

For an intermediate project like this, the scaffolding, Privacy Manifest, fastlane setup, and first ASC submission together account for roughly two to three days of setup work. Soarias compresses that into under an hour, leaving your full week for the Vision pipeline, the Messages Extension behaviour, and StoreKit integration — the parts that actually differentiate your app.

Related guides

FAQ

Does this work on iOS 16?

VNGenerateForegroundInstanceMaskRequest — the API that does automatic background removal — was introduced in iOS 17. If you need iOS 16 support you can fall back to a manual selection tool (e.g. a lasso or eraser brush) for background removal, but the one-tap automatic path requires iOS 17. The rest of the app — PhotosUI, SwiftData, Messages Extension — works on iOS 16 with minor API adjustments.

Do I need a paid Apple Developer account to test?

You need a free Apple ID to run the main app on your own device via Xcode. However, the Messages Extension requires a provisioning profile with the Messages capability, which is only available to paid Apple Developer Program members ($99/year). You also need a paid account for TestFlight and App Store distribution. Plan for this cost from day one.

How do I add this to the App Store?

Archive the app in Xcode (Product › Archive), upload via the Organiser window or xcrun altool, then complete the listing in App Store Connect: screenshots (required sizes: 6.9" and 6.5" iPhone, 13" and 12.9" iPad if you support iPad), privacy nutrition labels, age rating, and price. Because this app accesses the photo library you'll need to answer the privacy questionnaire carefully — stick to "no data collected" if all processing stays on-device.

How do I handle large photos without blocking the UI?

Wrap the entire BackgroundRemover.removingBackground(from:) call in Task.detached(priority: .userInitiated) as shown in Step 4 — this moves Vision work off the main actor. For very high-resolution images (e.g. 48 MP ProRAW), consider downscaling to 4000 px on the long edge before passing to Vision; the mask quality is identical and processing time drops significantly. Display a ProgressView during processing (shown in Step 3's isRemoving overlay) so users know the app is working.

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

```