How to Build a Contact Manager App in SwiftUI
A Contact Manager app lets users augment their system address book with custom fields — relationship notes, deal context, birthdays with alerts — things Apple's built-in Contacts app doesn't support. It's ideal for freelancers, sales professionals, and anyone who needs a personal CRM on iPhone.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Familiarity with the
Contactsframework (CNContact,CNContactStore) is helpful but not required - Testing on a real device is strongly recommended — the Contacts permission prompt does not behave identically in Simulator
Architecture overview
The app has two data layers: CNContactStore reads the system address book (read-only, with user permission), and SwiftData stores your enriched EnhancedContact records that are keyed to the system contact's identifier. Views are driven by a @Query on SwiftData and a ContactsImporter service that merges the two sources. State flows through @Environment(\.modelContext) and a lightweight @Observable ContactsViewModel.
ContactManagerApp/ ├── Models/ │ └── EnhancedContact.swift # SwiftData @Model ├── Services/ │ └── ContactsImporter.swift # CNContactStore bridge ├── Views/ │ ├── ContactListView.swift │ ├── ContactDetailView.swift │ └── AddEnhancementSheet.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model that mirrors the system contact's stable identifier and layers on custom fields Apple's Contacts app doesn't offer.
import SwiftData
import Foundation
@Model
final class EnhancedContact {
@Attribute(.unique) var systemIdentifier: String
var displayName: String
var relationshipNote: String
var dealContext: String
var lastContactedAt: Date?
var followUpDate: Date?
var customTags: [String]
var createdAt: Date
init(systemIdentifier: String, displayName: String) {
self.systemIdentifier = systemIdentifier
self.displayName = displayName
self.relationshipNote = ""
self.dealContext = ""
self.customTags = []
self.createdAt = .now
}
}
// In your App entry point:
// .modelContainer(for: EnhancedContact.self)
2. Core UI — contacts list
Build a searchable list that queries SwiftData and displays a follow-up badge when a contact is overdue for outreach.
import SwiftUI
import SwiftData
struct ContactListView: View {
@Query(sort: \EnhancedContact.displayName) var contacts: [EnhancedContact]
@State private var searchText = ""
@State private var showingAdd = false
var filtered: [EnhancedContact] {
guard !searchText.isEmpty else { return contacts }
return contacts.filter { $0.displayName.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List(filtered) { contact in
NavigationLink(destination: ContactDetailView(contact: contact)) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(contact.displayName).fontWeight(.medium)
if !contact.customTags.isEmpty {
Text(contact.customTags.joined(separator: " · "))
.font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
if let due = contact.followUpDate, due < .now {
Image(systemName: "bell.badge.fill")
.foregroundStyle(.orange)
}
}
}
}
.searchable(text: $searchText)
.navigationTitle("Contacts")
.toolbar { Button("Import", systemImage: "person.badge.plus") { showingAdd = true } }
.sheet(isPresented: $showingAdd) { ContactImportSheet() }
}
}
}
3. Enhanced contacts — importing from CNContactStore
Request Contacts permission, fetch system contacts, then upsert them into SwiftData so the user can annotate without duplicating data.
import Contacts
import SwiftData
@MainActor
final class ContactsImporter {
private let store = CNContactStore()
private let keysToFetch: [CNKeyDescriptor] = [
CNContactIdentifierKey as CNKeyDescriptor,
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor
]
func importAll(into context: ModelContext) async throws {
let granted = try await store.requestAccess(for: .contacts)
guard granted else { throw ImportError.denied }
let request = CNContactFetchRequest(keysToFetch: keysToFetch)
var fetched: [CNContact] = []
try store.enumerateContacts(with: request) { contact, _ in
fetched.append(contact)
}
for cn in fetched {
let name = [cn.givenName, cn.familyName]
.filter { !$0.isEmpty }.joined(separator: " ")
let descriptor = FetchDescriptor(
predicate: #Predicate { $0.systemIdentifier == cn.identifier }
)
if (try? context.fetch(descriptor))?.first == nil {
context.insert(EnhancedContact(systemIdentifier: cn.identifier, displayName: name))
}
}
try context.save()
}
enum ImportError: Error { case denied }
}
4. Privacy Manifest setup
App Store review requires a PrivacyInfo.xcprivacy file declaring your Contacts access and any required-reason API usage — missing this causes rejection at upload time.
<!-- PrivacyInfo.xcprivacy (add via File › New › File › App Privacy) -->
<?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>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeContacts</string>
<key>NSPrivacyCollectedDataTypeLinked</key><false/>
<key>NSPrivacyCollectedDataTypeTracking</key><false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Missing NSContactsUsageDescription in Info.plist.
CNContactStore.requestAccesswill crash at runtime without this key. Add a clear, user-facing string explaining why you need access — vague strings get flagged in App Store review. - Calling CNContactStore on the main thread.
enumerateContactsis synchronous and can block the UI on large address books. Wrap it in aTask { }or a background actor as shown in step 3. - Stale data after a user edits a system contact. The
CNContactStoreDidChangenotification fires when the system address book changes. Observe it and re-run your import to keep display names in sync. - App Store rejection for missing Privacy Manifest. Any app accessing
UserDefaultsorCNContactStoremust includePrivacyInfo.xcprivacywith correct reason codes. Apple's automated pipeline rejects the build at upload if it's absent. - Duplicate contacts after repeated imports. Always upsert on
systemIdentifier(as shown in step 3), never blindly insert — otherwise every import creates duplicates.
Adding monetization: One-time purchase
Use StoreKit 2's Product.purchase() API to gate the full app behind a single non-consumable in-app purchase. Define the product in App Store Connect as a non-consumable IAP (e.g. com.yourapp.fullaccess), then on launch call Product.products(for:) to fetch it and check Transaction.currentEntitlement(for:) to determine whether the user has already purchased. Free users can see their first 10 imported contacts; purchasing unlocks unlimited contacts and custom tags. Because this is a one-time purchase, there's no subscription to manage — StoreKit 2 handles restoration automatically when users reinstall the app, and you don't need a receipt server.
Shipping this faster with Soarias
Soarias handles the scaffolding (SwiftData model, CNContactStore permission boilerplate, PrivacyInfo.xcprivacy with correct Contacts reason codes), wires up fastlane lanes for TestFlight builds, and generates the required App Store screenshots at every device size — the three areas where intermediate-complexity apps lose the most setup time. The StoreKit non-consumable product ID is pre-configured in the generated StoreKitConfig.storekit testing file, so you can verify purchase flow before submitting.
For an intermediate app like this, most developers spend 2–3 days on project configuration, privacy declarations, and ASC metadata before writing a line of product code. With Soarias that overhead compresses to under an hour, leaving the full week for the actual contact-enrichment features that differentiate your app.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you sideload to your own device, but TestFlight distribution and App Store submission both require the $99/year Apple Developer Program membership. You'll also need it to create the non-consumable IAP product in App Store Connect.
How do I submit this to the App Store?
Archive the app in Xcode (Product › Archive), then use the Xcode Organizer to upload to App Store Connect. From there, complete your app's metadata (description, screenshots, privacy labels), attach the non-consumable IAP, and submit for review. Apple typically reviews within 24–48 hours for new apps.
Can I sync enhanced contact data across a user's devices?
Yes — enable CloudKit in your SwiftData ModelContainer by passing a ModelConfiguration with cloudKitDatabase: .automatic. This syncs your custom fields (notes, tags, follow-up dates) across all devices signed in to the same iCloud account without any additional server infrastructure. Note that system contacts already sync via iCloud Contacts, so only your enrichment data needs to go through CloudKit.
Last reviewed: 2026-05-12 by the Soarias team.