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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and the Messages Extension entitlement
- Basic Swift/SwiftUI knowledge — familiarity with async/await is helpful
- A physical iPhone running iOS 17+ for testing the Messages Extension (the Simulator does not support iMessage sticker send)
- Understanding of the Vision framework basics — you don't need ML expertise;
VNGenerateForegroundInstanceMaskRequesthandles everything on-device
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
- Forgetting the App Group on the Messages Extension target. The extension will open a completely separate SwiftData store and show zero stickers. Double-check both targets have the identical App Group identifier, including the
group.prefix. VNGenerateForegroundInstanceMaskRequestrequires iOS 17. If you set a deployment target below iOS 17 and call this API unconditionally, the app crashes on older devices. Wrap it inif #available(iOS 17, *)or just raise the minimum deployment target.- Writing sticker PNG files to the extension's sandbox instead of a shared location.
MSStickerrequires a file URL, not rawData. Write toFileManager.default.temporaryDirectory— accessible to the extension — not to the main app'sDocumentsfolder. - Sending
.heicdata directly to Vision without decoding first.VNImageRequestHandler(data:)can handle HEIC, but if you need to manipulate the image viaCIImagemanually, decode toUIImagefirst to avoid colour-space mismatches that produce black sticker edges. - App Store rejection for missing Privacy Manifest reason codes. Reviewers will reject binary submissions that access
NSPhotoLibrarywithout aPrivacyInfo.xcprivacyfile listing the correct reason codes. Add this file to both targets before you upload your first build to App Store Connect.
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.