π Contacts
β Difficulty: ββ
β±οΈ Est. Time: 1h
π App Services
iOS μ°λ½μ² λ°μ΄ν°μ μμ νκ² μ κ·Όνκ³ κ΄λ¦¬νκΈ°
iOS 9+
β¨ Contacts Framework?
The Contacts framework provides APIs for safely accessing the user's contact database. It supports querying, creating, editing, and deleting contacts, managed centrally through CNContactStore. User authorization is required, with privacy as the top priority.
π‘ Key Features: Contact Lookup Β· Create/Edit/Delete Contacts Β· Group Management Β· Contact Formatting Β· Contact Picker UI Β· Change Observation Β· Batch Saving
π 1. κΆν μμ²
User authorization is required for contact access. Both Info.plist settings and runtime permission requests are needed.
Info.plist β Add Permission Descriptions
// Info.plistμ μΆκ° NSContactsUsageDescription "μ°λ½μ²μ μ κ·Όνμ¬ μΉκ΅¬λ₯Ό μ΄λνκ³ μ 보λ₯Ό 곡μ ν©λλ€"
ContactsManager.swift β Permission Management
import Contacts @Observable class ContactsManager { let store = CNContactStore() var authorizationStatus: CNAuthorizationStatus = .notDetermined // νμ¬ κΆν μν νμΈ func checkAuthorizationStatus() { authorizationStatus = CNContactStore.authorizationStatus(for: .contacts) } // κΆν μμ² func requestAccess() async -> Bool { do { let granted = try await store.requestAccess(for: .contacts) await MainActor.run { authorizationStatus = granted ? .authorized : .denied } return granted } catch { print("κΆν μμ² μ€λ₯: \(error)") return false } } // κΆν μνμ λ°λ₯Έ μ²λ¦¬ func ensureAuthorization() async -> Bool { checkAuthorizationStatus() switch authorizationStatus { case .authorized: return true case .notDetermined: return await requestAccess() case .denied, .restricted: return false @unknown default: return false } } }
π 2. μ°λ½μ² μ‘°ν
λ€μν λ°©λ²μΌλ‘ μ°λ½μ²λ₯Ό κ²μνκ³ μ‘°ν.
ContactsFetcher.swift β Contact Query
import Contacts extension ContactsManager { // λͺ¨λ μ°λ½μ² κ°μ Έμ€κΈ° func fetchAllContacts() throws -> [CNContact] { let keysToFetch: [CNKeyDescriptor] = [ CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactImageDataKey as CNKeyDescriptor, CNContactFormatter.descriptorForRequiredKeys(for: .fullName) ] let request = CNContactFetchRequest(keysToFetch: keysToFetch) var contacts: [CNContact] = [] try store.enumerateContacts(with: request) { contact, stop in contacts.append(contact) } return contacts } // μ΄λ¦μΌλ‘ μ°λ½μ² κ²μ func searchContacts(name: String) throws -> [CNContact] { let keysToFetch: [CNKeyDescriptor] = [ CNContactFormatter.descriptorForRequiredKeys(for: .fullName), CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor ] let predicate = CNContact.predicateForContacts(matchingName: name) return try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) } // μλ³μλ‘ μ°λ½μ² κ°μ Έμ€κΈ° func fetchContact(identifier: String) throws -> CNContact { let keysToFetch: [CNKeyDescriptor] = [ CNContactFormatter.descriptorForRequiredKeys(for: .fullName), CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactPostalAddressesKey as CNKeyDescriptor, CNContactBirthdayKey as CNKeyDescriptor, CNContactImageDataKey as CNKeyDescriptor ] let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) guard let contact = contacts.first else { throw ContactsError.contactNotFound } return contact } // μ νλ²νΈλ‘ μ°λ½μ² κ²μ func searchByPhoneNumber(_ phoneNumber: String) throws -> [CNContact] { let keysToFetch: [CNKeyDescriptor] = [ CNContactFormatter.descriptorForRequiredKeys(for: .fullName), CNContactPhoneNumbersKey as CNKeyDescriptor ] let allContacts = try fetchAllContacts() return allContacts.filter { contact in contact.phoneNumbers.contains { labeledValue in labeledValue.value.stringValue.contains(phoneNumber) } } } } enum ContactsError: Error { case contactNotFound case unauthorized case saveFailed }
β 3. μ°λ½μ² μμ±
μλ‘μ΄ μ°λ½μ²λ₯Ό λ§λ€κ³ μ μ₯.
ContactsCreator.swift β Create Contacts
import Contacts extension ContactsManager { // μ μ°λ½μ² μμ± func createContact( givenName: String, familyName: String, phoneNumbers: [String] = [], emails: [String] = [] ) throws { let contact = CNMutableContact() // μ΄λ¦ μ€μ contact.givenName = givenName contact.familyName = familyName // μ νλ²νΈ μΆκ° contact.phoneNumbers = phoneNumbers.map { number in CNLabeledValue( label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: number) ) } // μ΄λ©μΌ μΆκ° contact.emailAddresses = emails.map { email in CNLabeledValue( label: CNLabelHome, value: email as NSString ) } // μ°λ½μ² μ μ₯ let saveRequest = CNSaveRequest() saveRequest.add(contact, toContainerWithIdentifier: nil) try store.execute(saveRequest) } // μμΈ μ 보μ ν¨κ» μ°λ½μ² μμ± func createDetailedContact( givenName: String, familyName: String, organizationName: String? = nil, jobTitle: String? = nil, phoneNumbers: [(String, String)] = [], // (label, number) emails: [(String, String)] = [], // (label, email) postalAddress: CNPostalAddress? = nil, birthday: DateComponents? = nil, note: String? = nil ) throws { let contact = CNMutableContact() // κΈ°λ³Έ μ 보 contact.givenName = givenName contact.familyName = familyName contact.organizationName = organizationName ?? "" contact.jobTitle = jobTitle ?? "" // μ νλ²νΈ contact.phoneNumbers = phoneNumbers.map { label, number in CNLabeledValue( label: label, value: CNPhoneNumber(stringValue: number) ) } // μ΄λ©μΌ contact.emailAddresses = emails.map { label, email in CNLabeledValue( label: label, value: email as NSString ) } // μ£Όμ if let address = postalAddress { contact.postalAddresses = [ CNLabeledValue(label: CNLabelHome, value: address) ] } // μμΌ contact.birthday = birthday // λ©λͺ¨ contact.note = note ?? "" // μ μ₯ let saveRequest = CNSaveRequest() saveRequest.add(contact, toContainerWithIdentifier: nil) try store.execute(saveRequest) } }
βοΈ 4. μ°λ½μ² μμ λ° μμ
κΈ°μ‘΄ μ°λ½μ²λ₯Ό μμ νκ±°λ μμ .
ContactsEditor.swift β Edit/Delete Contacts
import Contacts extension ContactsManager { // μ°λ½μ² μμ func updateContact( identifier: String, givenName: String? = nil, familyName: String? = nil, phoneNumbers: [String]? = nil ) throws { // κΈ°μ‘΄ μ°λ½μ² κ°μ Έμ€κΈ° let keysToFetch: [CNKeyDescriptor] = [ CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor ] let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) guard let existingContact = contacts.first else { throw ContactsError.contactNotFound } // λ³κ²½ κ°λ₯ν 볡μ¬λ³Έ λ§λ€κΈ° let mutableContact = existingContact.mutableCopy() as! CNMutableContact // μμ if let givenName = givenName { mutableContact.givenName = givenName } if let familyName = familyName { mutableContact.familyName = familyName } if let phoneNumbers = phoneNumbers { mutableContact.phoneNumbers = phoneNumbers.map { number in CNLabeledValue( label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: number) ) } } // μ μ₯ let saveRequest = CNSaveRequest() saveRequest.update(mutableContact) try store.execute(saveRequest) } // μ°λ½μ² μμ func deleteContact(identifier: String) throws { let keysToFetch: [CNKeyDescriptor] = [ CNContactIdentifierKey as CNKeyDescriptor ] let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) guard let contact = contacts.first else { throw ContactsError.contactNotFound } let mutableContact = contact.mutableCopy() as! CNMutableContact let saveRequest = CNSaveRequest() saveRequest.delete(mutableContact) try store.execute(saveRequest) } // μ νλ²νΈ μΆκ° func addPhoneNumber(to identifier: String, phoneNumber: String, label: String) throws { let keysToFetch: [CNKeyDescriptor] = [ CNContactPhoneNumbersKey as CNKeyDescriptor ] let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keysToFetch) guard let contact = contacts.first else { throw ContactsError.contactNotFound } let mutableContact = contact.mutableCopy() as! CNMutableContact // μ μ νλ²νΈ μΆκ° let newPhoneNumber = CNLabeledValue( label: label, value: CNPhoneNumber(stringValue: phoneNumber) ) mutableContact.phoneNumbers.append(newPhoneNumber) let saveRequest = CNSaveRequest() saveRequest.update(mutableContact) try store.execute(saveRequest) } }
π± 5. SwiftUI Integration β Contact Picker
Use the system contact picker in SwiftUI.
ContactPickerView.swift β Contact Picker
import SwiftUI import ContactsUI struct ContactPickerView: UIViewControllerRepresentable { @Binding var selectedContact: CNContact? @Environment(\.dismiss) var dismiss func makeUIViewController(context: Context) -> CNContactPickerViewController { let picker = CNContactPickerViewController() picker.delegate = context.coordinator // νμν μμ± μ§μ picker.displayedPropertyKeys = [ CNContactPhoneNumbersKey, CNContactEmailAddressesKey ] return picker } func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(selectedContact: $selectedContact, dismiss: dismiss) } class Coordinator: NSObject, CNContactPickerDelegate { @Binding var selectedContact: CNContact? let dismiss: DismissAction init(selectedContact: Binding<CNContact?>, dismiss: DismissAction) { _selectedContact = selectedContact self.dismiss = dismiss } // μ°λ½μ² μ ν μλ£ func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { selectedContact = contact dismiss() } // μ·¨μ func contactPickerDidCancel(_ picker: CNContactPickerViewController) { dismiss() } } } // μ¬μ© μμ struct ContactSelectorView: View { @State private var selectedContact: CNContact? @State private var showPicker = false var body: some View { VStack(spacing: 20) { if let contact = selectedContact { VStack(alignment: .leading, spacing: 8) { Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "μ μ μμ") .font(.headline) if let phoneNumber = contact.phoneNumbers.first?.value.stringValue { Text(phoneNumber) .font(.subheadline) .foregroundStyle(.secondary) } } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(8) } else { Text("μ°λ½μ²κ° μ νλμ§ μμμ΅λλ€") .foregroundStyle(.secondary) } Button("μ°λ½μ² μ ν") { showPicker = true } .buttonStyle(.borderedProminent) } .padding() .sheet(isPresented: $showPicker) { ContactPickerView(selectedContact: $selectedContact) } } }
π¨ 6. μ°λ½μ² ν¬λ§·ν
Display contact information using CNContactFormatter.
ContactFormatter.swift β Contact Formatting
import Contacts class ContactFormatterHelper { // μ 체 μ΄λ¦ ν¬λ§· func formatFullName(_ contact: CNContact) -> String { return CNContactFormatter.string(from: contact, style: .fullName) ?? "μ μ μμ" } // μ νλ²νΈ ν¬λ§· func formatPhoneNumber(_ phoneNumber: CNPhoneNumber) -> String { let formatter = CNPhoneNumberFormatter() return formatter.string(from: phoneNumber) } // μ£Όμ ν¬λ§· func formatPostalAddress(_ address: CNPostalAddress) -> String { let formatter = CNPostalAddressFormatter() return formatter.string(from: address) } // μ°λ½μ² λ μ΄λΈ νμ§ν func localizedLabel(for label: String) -> String { return CNLabeledValue<NSString>.localizedString(forLabel: label) } }
π± Complete Example β Contact Management App
ContactsAppView.swift β Contact Management App
import SwiftUI import Contacts struct ContactsAppView: View { @State private var manager = ContactsManager() @State private var contacts: [CNContact] = [] @State private var searchText = "" @State private var showingAddContact = false @State private var errorMessage: String? var filteredContacts: [CNContact] { if searchText.isEmpty { return contacts } else { return contacts.filter { contact in let fullName = CNContactFormatter.string(from: contact, style: .fullName) ?? "" return fullName.localizedCaseInsensitiveContains(searchText) } } } var body: some View { NavigationStack { List { ForEach(filteredContacts, id: \.identifier) { contact in NavigationLink { ContactDetailView(contact: contact, manager: manager) } label: { ContactRow(contact: contact) } } .onDelete(perform: deleteContacts) } .navigationTitle("μ°λ½μ²") .searchable(text: $searchText, prompt: "μ°λ½μ² κ²μ") .toolbar { Button { showingAddContact = true } label: { Image(systemName: "plus") } } .sheet(isPresented: $showingAddContact) { AddContactView(manager: manager) { Task { await loadContacts() } } } .alert("μ€λ₯", isPresented: .constant(errorMessage != nil)) { Button("νμΈ") { errorMessage = nil } } message: { Text(errorMessage ?? "") } .task { await loadContacts() } } } func loadContacts() async { let authorized = await manager.ensureAuthorization() guard authorized else { errorMessage = "μ°λ½μ² μ κ·Ό κΆνμ΄ νμν©λλ€" return } do { let fetchedContacts = try manager.fetchAllContacts() await MainActor.run { contacts = fetchedContacts.sorted { c1, c2 in let name1 = CNContactFormatter.string(from: c1, style: .fullName) ?? "" let name2 = CNContactFormatter.string(from: c2, style: .fullName) ?? "" return name1 < name2 } } } catch { errorMessage = "μ°λ½μ²λ₯Ό λΆλ¬μ¬ μ μμ΅λλ€" } } func deleteContacts(at offsets: IndexSet) { for index in offsets { let contact = filteredContacts[index] do { try manager.deleteContact(identifier: contact.identifier) } catch { errorMessage = "μ°λ½μ² μμ μ€ν¨" } } Task { await loadContacts() } } } struct ContactRow: View { let contact: CNContact var body: some View { HStack { if let imageData = contact.imageData, let uiImage = UIImage(data: imageData) { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 40, height: 40) .clipShape(Circle()) } else { Image(systemName: "person.circle.fill") .resizable() .frame(width: 40, height: 40) .foregroundStyle(.gray) } VStack(alignment: .leading) { Text(CNContactFormatter.string(from: contact, style: .fullName) ?? "μ μ μμ") .font(.headline) if let phoneNumber = contact.phoneNumbers.first?.value.stringValue { Text(phoneNumber) .font(.subheadline) .foregroundStyle(.secondary) } } } } } struct ContactDetailView: View { let contact: CNContact let manager: ContactsManager var body: some View { List { Section("μ νλ²νΈ") { ForEach(contact.phoneNumbers, id: \.identifier) { labeledValue in HStack { Text(CNLabeledValue<NSString>.localizedString(forLabel: labeledValue.label ?? "")) .foregroundStyle(.secondary) Spacer() Text(labeledValue.value.stringValue) } } } Section("μ΄λ©μΌ") { ForEach(contact.emailAddresses, id: \.identifier) { labeledValue in HStack { Text(CNLabeledValue<NSString>.localizedString(forLabel: labeledValue.label ?? "")) .foregroundStyle(.secondary) Spacer() Text(labeledValue.value as String) } } } } .navigationTitle(CNContactFormatter.string(from: contact, style: .fullName) ?? "μ°λ½μ²") } } struct AddContactView: View { let manager: ContactsManager let onSave: () -> Void @State private var givenName = "" @State private var familyName = "" @State private var phoneNumber = "" @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { Form { TextField("μ΄λ¦", text: $givenName) TextField("μ±", text: $familyName) TextField("μ νλ²νΈ", text: $phoneNumber) .keyboardType(.phonePad) } .navigationTitle("μ μ°λ½μ²") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("μ·¨μ") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("μ μ₯") { saveContact() } .disabled(givenName.isEmpty || familyName.isEmpty) } } } } func saveContact() { do { try manager.createContact( givenName: givenName, familyName: familyName, phoneNumbers: phoneNumber.isEmpty ? [] : [phoneNumber] ) onSave() dismiss() } catch { print("μ°λ½μ² μ μ₯ μ€ν¨: \(error)") } } }
π‘ HIG Guidelines
- Permission Request: μ°λ½μ² μ κ·Όμ΄ νμν μ΄μ λ₯Ό λͺ νν μ€λͺ
- μ΅μ κΆν: νμν μ΅μνμ μ°λ½μ² μ λ³΄λ§ μμ²
- μ°λ½μ² μ νκΈ° μ¬μ©: Use the system contact picker (CNContactPickerViewController) when possible.
- κ°μΈμ 보 보νΈ: Require user consent before sending contact data to a server
- Error Handling: Provide guidance to navigate to Settings when permission is denied
π― Practical Usage
- μμ μ±: μΉκ΅¬ μ΄λ, μ°λ½μ² λκΈ°ν
- Messaging Apps: λΉ λ₯Έ μ°λ½μ² κ²μ λ° λ©μμ§ λ°μ‘
- μ΄λ©μΌ ν΄λΌμ΄μΈνΈ: λ°λ μ¬λ μλ μμ±
- Event Apps: μ°Έμμ μΆκ°, μ΄λμ₯ λ°μ‘
- λΉμ¦λμ€ μ±: CRM, κ³ κ° κ΄λ¦¬, λͺ ν¨ κ΄λ¦¬
π Learn More
β‘οΈ Performance Tips: μ°λ½μ² μ‘°ν μ νμν ν€λ§
keysToFetch. Fetching all keys may degrade performance. Request image data only when needed.