๐ Contacts
iOS ์ฐ๋ฝ์ฒ ๋ฐ์ดํฐ์ ์์ ํ๊ฒ ์ ๊ทผํ๊ณ ๊ด๋ฆฌํ๊ธฐ
โจ Contacts Framework๋?
Contacts ํ๋ ์์ํฌ๋ ์ฌ์ฉ์์ ์ฐ๋ฝ์ฒ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์์ ํ๊ฒ ์ ๊ทผํ ์ ์๋ API๋ฅผ ์ ๊ณตํฉ๋๋ค. ์ฐ๋ฝ์ฒ ์กฐํ, ์์ฑ, ์์ , ์ญ์ ๋ฅผ ์ง์ํ๋ฉฐ, CNContactStore๋ฅผ ํตํด ์ค์ ์ง์ค์์ผ๋ก ๊ด๋ฆฌ๋ฉ๋๋ค. ์ฌ์ฉ์ ๊ถํ์ด ํ์์ด๋ฉฐ, ๊ฐ์ธ์ ๋ณด ๋ณดํธ๋ฅผ ์ต์ฐ์ ์ผ๋ก ํฉ๋๋ค.
๐ 1. ๊ถํ ์์ฒญ
์ฐ๋ฝ์ฒ ์ ๊ทผ์ ์ํด์๋ ์ฌ์ฉ์ ๊ถํ์ด ํ์์ ๋๋ค. Info.plist ์ค์ ๊ณผ ๋ฐํ์ ๊ถํ ์์ฒญ์ด ํ์ํฉ๋๋ค.
// Info.plist์ ์ถ๊ฐ NSContactsUsageDescription "์ฐ๋ฝ์ฒ์ ์ ๊ทผํ์ฌ ์น๊ตฌ๋ฅผ ์ด๋ํ๊ณ ์ ๋ณด๋ฅผ ๊ณต์ ํฉ๋๋ค"
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. ์ฐ๋ฝ์ฒ ์กฐํ
๋ค์ํ ๋ฐฉ๋ฒ์ผ๋ก ์ฐ๋ฝ์ฒ๋ฅผ ๊ฒ์ํ๊ณ ์กฐํํฉ๋๋ค.
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. ์ฐ๋ฝ์ฒ ์์ฑ
์๋ก์ด ์ฐ๋ฝ์ฒ๋ฅผ ๋ง๋ค๊ณ ์ ์ฅํฉ๋๋ค.
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. ์ฐ๋ฝ์ฒ ์์ ๋ฐ ์ญ์
๊ธฐ์กด ์ฐ๋ฝ์ฒ๋ฅผ ์์ ํ๊ฑฐ๋ ์ญ์ ํฉ๋๋ค.
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 ํตํฉ - ์ฐ๋ฝ์ฒ ์ ํ๊ธฐ
์์คํ ์ฐ๋ฝ์ฒ ์ ํ๊ธฐ๋ฅผ SwiftUI์์ ์ฌ์ฉํฉ๋๋ค.
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. ์ฐ๋ฝ์ฒ ํฌ๋งทํ
CNContactFormatter๋ฅผ ์ฌ์ฉํ์ฌ ์ฐ๋ฝ์ฒ ์ ๋ณด๋ฅผ ํ์ํฉ๋๋ค.
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) } }
๐ฑ ์ข ํฉ ์์ - ์ฐ๋ฝ์ฒ ๊ด๋ฆฌ ์ฑ
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 ๊ฐ์ด๋๋ผ์ธ
- ๊ถํ ์์ฒญ: ์ฐ๋ฝ์ฒ ์ ๊ทผ์ด ํ์ํ ์ด์ ๋ฅผ ๋ช ํํ ์ค๋ช
- ์ต์ ๊ถํ: ํ์ํ ์ต์ํ์ ์ฐ๋ฝ์ฒ ์ ๋ณด๋ง ์์ฒญ
- ์ฐ๋ฝ์ฒ ์ ํ๊ธฐ ์ฌ์ฉ: ๊ฐ๋ฅํ๋ฉด ์์คํ ์ฐ๋ฝ์ฒ ์ ํ๊ธฐ(CNContactPickerViewController) ์ฌ์ฉ
- ๊ฐ์ธ์ ๋ณด ๋ณดํธ: ์ฐ๋ฝ์ฒ ๋ฐ์ดํฐ๋ฅผ ์๋ฒ๋ก ์ ์กํ๊ธฐ ์ ์ฌ์ฉ์ ๋์ ํ์
- ์ค๋ฅ ์ฒ๋ฆฌ: ๊ถํ ๊ฑฐ๋ถ ์ ์ค์ ์ฑ์ผ๋ก ์ด๋ํ ์ ์๋ ์๋ด ์ ๊ณต
๐ฏ ์ค์ ํ์ฉ
- ์์ ์ฑ: ์น๊ตฌ ์ด๋, ์ฐ๋ฝ์ฒ ๋๊ธฐํ
- ๋ฉ์์ง ์ฑ: ๋น ๋ฅธ ์ฐ๋ฝ์ฒ ๊ฒ์ ๋ฐ ๋ฉ์์ง ๋ฐ์ก
- ์ด๋ฉ์ผ ํด๋ผ์ด์ธํธ: ๋ฐ๋ ์ฌ๋ ์๋ ์์ฑ
- ์ด๋ฒคํธ ์ฑ: ์ฐธ์์ ์ถ๊ฐ, ์ด๋์ฅ ๋ฐ์ก
- ๋น์ฆ๋์ค ์ฑ: CRM, ๊ณ ๊ฐ ๊ด๋ฆฌ, ๋ช ํจ ๊ด๋ฆฌ
๐ ๋ ์์๋ณด๊ธฐ
keysToFetch์ ํฌํจ์ํค์ธ์. ๋ชจ๋ ํค๋ฅผ ๊ฐ์ ธ์ค๋ฉด ์ฑ๋ฅ์ด ์ ํ๋ ์ ์์ต๋๋ค. ํนํ ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ ํ์ํ ๊ฒฝ์ฐ์๋ง ์์ฒญํ๋ ๊ฒ์ด ์ข์ต๋๋ค.