How to Build a Sticky Notes App in SwiftUI
A Sticky Notes app lets users jot quick thoughts in colorful, desktop-inspired cards — complete with a home-screen widget so nothing gets buried. It's a perfect first SwiftData project for iOS developers who want a polished, shippable app without a steep learning curve.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical device or simulator running iOS 17+ for WidgetKit live previews
Architecture overview
The app uses a SwiftData ModelContainer as its single source of truth — notes are persisted automatically and shared with a WidgetKit extension via an App Group container. The main view is a LazyVGrid of card cells; tapping a card pushes a focused editor built with TextEditor. A ColorTheme enum drives background tints, and a TimelineProvider in the widget target surfaces the three most-recently modified notes on the home screen.
StickyNotes/
├── App/
│ ├── StickyNotesApp.swift # ModelContainer setup + App Group
│ └── ContentView.swift # Root navigation
├── Models/
│ └── Note.swift # @Model — id, body, colorTheme, isPinned, modifiedAt
├── Views/
│ ├── NoteGridView.swift # LazyVGrid canvas
│ ├── NoteCardView.swift # Single card cell
│ └── NoteEditorView.swift # Full-screen inline editor
└── Widget/
├── StickyWidget.swift # TimelineProvider + Entry
└── StickyWidgetView.swift # Small/medium widget layouts
Step-by-step
1. Data model
Define a SwiftData @Model that stores every note's text, color, pin state, and last-modified date so the widget can sort by recency.
import SwiftData
import Foundation
enum ColorTheme: String, Codable, CaseIterable {
case yellow, pink, mint, sky, lavender
var hex: String {
switch self {
case .yellow: return "#FFF176"
case .pink: return "#F48FB1"
case .mint: return "#A5D6A7"
case .sky: return "#81D4FA"
case .lavender: return "#CE93D8"
}
}
}
@Model
final class Note {
var id: UUID
var body: String
var colorTheme: ColorTheme
var isPinned: Bool
var modifiedAt: Date
init(body: String = "",
colorTheme: ColorTheme = .yellow,
isPinned: Bool = false) {
self.id = UUID()
self.body = body
self.colorTheme = colorTheme
self.isPinned = isPinned
self.modifiedAt = .now
}
}
2. Core UI — note grid
Render all notes in an adaptive two-column grid with a floating "+" button; pinned notes always appear at the top via a compound sort descriptor.
import SwiftUI
import SwiftData
struct NoteGridView: View {
@Environment(\.modelContext) private var ctx
@Query(sort: [
SortDescriptor(\Note.isPinned, order: .reverse),
SortDescriptor(\Note.modifiedAt, order: .reverse)
]) private var notes: [Note]
@State private var selectedNote: Note?
let columns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(notes) { note in
NoteCardView(note: note)
.onTapGesture { selectedNote = note }
}
}
.padding()
}
.navigationTitle("Sticky Notes")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { addNote() } label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
}
}
}
.sheet(item: $selectedNote) { note in
NoteEditorView(note: note)
}
}
}
private func addNote() {
let n = Note()
ctx.insert(n)
selectedNote = n
}
}
3. Desktop-style notes — editor + WidgetKit pin
Give each note an inline TextEditor, a color-theme picker, and a pin toggle that surfaces the note in the home-screen widget via a shared App Group.
import SwiftUI
import WidgetKit
struct NoteEditorView: View {
@Bindable var note: Note
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ZStack {
Color(hex: note.colorTheme.hex).ignoresSafeArea()
TextEditor(text: $note.body)
.scrollContentBackground(.hidden)
.background(.clear)
.font(.body)
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") {
note.modifiedAt = .now
WidgetCenter.shared.reloadAllTimelines()
dismiss()
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
// Color picker
Menu {
ForEach(ColorTheme.allCases, id: \.self) { theme in
Button(theme.rawValue.capitalized) {
note.colorTheme = theme
}
}
} label: {
Image(systemName: "paintpalette")
}
// Pin toggle
Button {
note.isPinned.toggle()
WidgetCenter.shared.reloadAllTimelines()
} label: {
Image(systemName: note.isPinned
? "pin.fill" : "pin")
}
}
}
}
}
}
4. Privacy Manifest setup
App Store review requires a PrivacyInfo.xcprivacy file listing every privacy-sensitive API your app touches — missing it triggers an automatic rejection.
<?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>
<!-- CA92.1: App Group shared defaults for widget data -->
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Forgetting the App Group entitlement. The widget extension runs in a separate process and cannot read your app's default SwiftData store. Add an App Group (
group.com.yourco.stickynotes) to both targets and pass its URL toModelContainer(configurations:). - Widget timeline not refreshing. Calling
WidgetCenter.shared.reloadAllTimelines()only from the editor dismiss is not enough if the user edits quickly — also call it on.onDisappearof the grid view. - TextEditor background bleeding through. On iOS 17+ you must set
.scrollContentBackground(.hidden)before applying a custom background color; without it the system white background overrides yours. - App Store rejection: missing Privacy Manifest. Apple's automated pipeline rejects binaries that call
UserDefaults(used internally by SwiftData and WidgetKit) without a corresponding reason code. Always includePrivacyInfo.xcprivacyin both your app target and widget extension target. - SwiftData crash on first launch. If you rename a
@Modelproperty after shipping, SwiftData can't migrate automatically. Use@Attribute(.unique)and a versionedSchema/SchemaMigrationPlanbefore your first real-user release.
Adding monetization: One-time purchase
The cleanest model for a simple utility like this is a single non-consumable In-App Purchase (e.g. "Unlock Pro — $2.99") implemented with StoreKit 2. Define the product in App Store Connect, then use Product.products(for:) to fetch it at launch and product.purchase() to initiate the transaction. Gate premium color themes and widget sizes behind a @AppStorage("isPro") flag that you set inside a Transaction.updates listener — this listener also handles restores and Family Sharing automatically. Because it's a one-time purchase, you don't need a SubscriptionStatusTask or server-side receipts; StoreKit 2's local verification is sufficient for a Sticky Notes app at this scale.
Shipping this faster with Soarias
Soarias handles the mechanical work that eats weekends: it scaffolds the SwiftData model, App Group entitlements, and WidgetKit extension wiring from a short prompt, generates your PrivacyInfo.xcprivacy with the correct reason codes pre-filled, configures fastlane deliver with App Store metadata and screenshot device targets, and submits the binary to App Store Connect — all without you opening Xcode's Organizer or the ASC web UI.
For a beginner-complexity app like this one, most developers spend 60–70% of their time on setup, signing, and submission friction rather than actual feature code. With Soarias that overhead compresses to under an hour, turning a realistic "two weekends" timeline into a single focused Saturday session from scaffold to TestFlight build.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes — you need the $99/year Apple Developer Program membership to distribute on TestFlight and the App Store. You can build and run on a personal device with a free account, but the WidgetKit extension and App Group entitlements require a paid membership to provision correctly.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then upload via the Organizer or fastlane deliver. In App Store Connect, fill in the app description, keywords, age rating, and privacy nutrition labels, attach your screenshots, set pricing, and click Submit for Review. First submissions typically take 24–48 hours to review.
Can the home-screen widget show the full text of a long note?
WidgetKit widgets are not scrollable, so long notes will be truncated. The best approach is to cap widget body text at around 150 characters using String.prefix(_:) in your TimelineEntry and display a "…" indicator, then deep-link the widget tap directly into the full note editor using a widgetURL modifier.
Last reviewed: 2026-05-12 by the Soarias team.