🌐 KO

πŸ“‡ 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

🎯 Practical Usage

πŸ“š Learn More

⚑️ Performance Tips: μ—°λ½μ²˜ 쑰회 μ‹œ ν•„μš”ν•œ ν‚€λ§Œ keysToFetch. Fetching all keys may degrade performance. Request image data only when needed.

πŸ“Ž Apple Official Resources

πŸ“˜ Documentation 🎬 WWDC Sessions