Skip to content
Skip
2.9k

Contacts

A cross-platform contacts framework for Skip apps, providing a unified API for querying, creating, updating, and deleting contacts on both iOS and Android.

On iOS, SkipContacts wraps Apple’s Contacts and ContactsUI frameworks. On Android, it uses the ContactsContract content provider.

To use SkipContacts in your project, add the dependency to your Package.swift:

dependencies: [
.package(url: "https://source.skip.tools/skip-contacts.git", "0.0.0"..<"2.0.0")
]

And add SkipContacts as a dependency of your target:

.target(name: "MyApp", dependencies: [
.product(name: "SkipContacts", package: "skip-contacts")
])

Add the following usage description to your app’s Info.plist or .xcconfig:

<key>NSContactsUsageDescription</key>
<string>This app needs access to your contacts.</string>

Or in your .xcconfig:

INFOPLIST_KEY_NSContactsUsageDescription = This app needs access to your contacts.

Add the following permissions to your AndroidManifest.xml (or the test target’s Skip/AndroidManifest.xml):

<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />

Always check and request contacts permission before performing operations:

import SkipContacts
// Check current permission status (synchronous, no prompt)
let status = ContactManager.queryContactsPermission()
switch status {
case .authorized:
// Full access granted
break
case .limited:
// Limited access (iOS 18+)
break
case .denied:
// User denied access
break
case .restricted:
// Access restricted by policy
break
case .unknown:
// Not yet determined - request permission
let result = await ContactManager.requestContactsPermission()
if result == .authorized {
// Access granted
}
}
let manager = ContactManager.shared
let result = try manager.getContacts()
for contact in result.contacts {
print("\(contact.displayName): \(contact.phoneNumbers.first?.value ?? "")")
}
let options = ContactFetchOptions(
nameFilter: "John",
pageSize: 20,
pageOffset: 0,
sortOrder: .givenName,
includeImages: true,
includeNote: true
)
let result = try manager.getContacts(options: options)
// Check if there are more results
if result.hasNextPage {
// Fetch next page
let nextOptions = ContactFetchOptions(
nameFilter: "John",
pageSize: 20,
pageOffset: 20
)
let nextResult = try manager.getContacts(options: nextOptions)
}
if let contact = try manager.getContact(id: contactID, includeImages: true) {
print(contact.displayName)
print(contact.givenName)
print(contact.familyName)
}
let hasAny = try manager.hasContacts()
let contact = Contact(
contactType: .person,
givenName: "Jane",
familyName: "Doe",
organizationName: "Acme Corp",
jobTitle: "Engineer"
)
contact.phoneNumbers = [
ContactPhoneNumber(label: .mobile, value: "+1-555-0123"),
ContactPhoneNumber(label: .work, value: "+1-555-0456")
]
contact.emailAddresses = [
ContactEmailAddress(label: .work, value: "jane@acme.com"),
ContactEmailAddress(label: .home, value: "jane@example.com")
]
contact.postalAddresses = [
ContactPostalAddress(
label: .home,
street: "123 Main St",
city: "Springfield",
state: "IL",
postalCode: "62701",
country: "USA"
)
]
contact.birthday = ContactDate(label: .birthday, day: 15, month: 6, year: 1990)
contact.relationships = [
ContactRelationship(label: .spouse, name: "John Doe")
]
contact.note = "Met at conference"
let newID = try manager.createContact(contact)
// Fetch the contact first
if var contact = try manager.getContact(id: contactID) {
contact.jobTitle = "Senior Engineer"
contact.phoneNumbers.append(
ContactPhoneNumber(label: .home, value: "+1-555-9999")
)
try manager.updateContact(contact)
}
try manager.deleteContact(id: contactID)
// List groups
let groups = try manager.getGroups()
for group in groups {
print("\(group.name) (\(group.id ?? ""))")
}
// Create a group
let groupID = try manager.createGroup(name: "Book Club")
// Add a contact to a group
try manager.addContactToGroup(contactID: contactID, groupID: groupID)
// Remove a contact from a group
try manager.removeContactFromGroup(contactID: contactID, groupID: groupID)
// Delete a group
try manager.deleteGroup(id: groupID)
// List containers (accounts)
let containers = try manager.getContainers()
for container in containers {
print("\(container.name) - \(container.type)")
}
// Get default container
let defaultID = try manager.getDefaultContainerID()

SkipContacts provides SwiftUI view modifiers for presenting native contact interfaces.

Present the system contact picker to let the user select a contact:

struct MyView: View {
@State var showPicker = false
@State var selectedContactID: String?
var body: some View {
Button("Pick Contact") {
showPicker = true
}
.withContactPicker(
isPresented: $showPicker,
onSelectContact: { contactID in
selectedContactID = contactID
// Fetch full contact details
if let contact = try? ContactManager.shared.getContact(id: contactID) {
print("Selected: \(contact.displayName)")
}
},
onCancel: {
print("Picker cancelled")
}
)
}
}

Display a contact’s details using the native viewer:

struct ContactDetailView: View {
@State var showViewer = false
let contactID: String
var body: some View {
Button("View Contact") {
showViewer = true
}
.withContactViewer(
isPresented: $showViewer,
contactID: contactID
)
}
}

Present the native editor for creating or editing contacts:

// Create a new contact with defaults
struct CreateContactView: View {
@State var showEditor = false
var body: some View {
Button("New Contact") {
showEditor = true
}
.withContactEditor(
isPresented: $showEditor,
options: ContactEditorOptions(
defaultGivenName: "Jane",
defaultFamilyName: "Doe",
defaultPhoneNumber: "+1-555-0123",
defaultEmailAddress: "jane@example.com"
),
onComplete: { result in
switch result {
case .saved: print("Contact saved")
case .deleted: print("Contact deleted")
case .canceled: print("Cancelled")
case .unknown: print("Unknown result")
}
}
)
}
}
// Edit an existing contact
struct EditContactView: View {
@State var showEditor = false
let contact: Contact
var body: some View {
Button("Edit Contact") {
showEditor = true
}
.withContactEditor(
isPresented: $showEditor,
options: ContactEditorOptions(contact: contact),
onComplete: { result in
print("Editor result: \(result)")
}
)
}
}

The Contact class contains all fields for a contact record:

PropertyTypeDescription
idString?Unique identifier (nil for new contacts)
contactTypeContactType.person or .organization
namePrefixStringe.g., “Dr.”, “Mr.”
givenNameStringFirst name
middleNameStringMiddle name
familyNameStringLast name
nameSuffixStringe.g., “Jr.”, “PhD”
nicknameStringNickname
phoneticGivenNameStringPhonetic first name
phoneticMiddleNameStringPhonetic middle name
phoneticFamilyNameStringPhonetic last name
previousFamilyNameStringMaiden name
organizationNameStringCompany name
departmentNameStringDepartment
jobTitleStringJob title
phoneNumbers[ContactPhoneNumber]Phone numbers
emailAddresses[ContactEmailAddress]Email addresses
postalAddresses[ContactPostalAddress]Postal addresses
urlAddresses[ContactURLAddress]URL addresses
instantMessageAddresses[ContactInstantMessageAddress]IM addresses
socialProfiles[ContactSocialProfile]Social profiles (iOS only)
birthdayContactDate?Birthday
dates[ContactDate]Other dates (anniversary, etc.)
relationships[ContactRelationship]Relationships
noteStringNotes
imageContactImage?Contact photo
displayNameStringComputed display name

All labeled values (phone, email, address, etc.) support standard labels and custom labels:

Phone labels: main, home, work, mobile, iPhone, homeFax, workFax, pager, other

Email labels: home, work, iCloud, other

Address labels: home, work, other

Date labels: birthday, anniversary, other

Relationship labels: spouse, child, mother, father, parent, sibling, friend, manager, assistant, partner, other

URL labels: home, work, homepage, other

let address = ContactPostalAddress(
label: .home,
street: "123 Main St",
city: "Springfield",
state: "IL",
postalCode: "62701",
country: "USA",
isoCountryCode: "US"
)
print(address.formattedAddress) // "123 Main St, Springfield, IL, 62701, USA"

Dates support year-less values for recurring events like birthdays:

let birthday = ContactDate(label: .birthday, day: 15, month: 6, year: 1990)
let anniversary = ContactDate(label: .anniversary, day: 20, month: 9) // no year
if let image = contact.image {
if let thumbnail = image.thumbnailData {
// Use thumbnail data
}
if let fullImage = image.imageData {
// Use full-size image data
}
}

Access the underlying CNContactStore for advanced operations not covered by the cross-platform API:

#if !SKIP
import Contacts
let store = ContactManager.shared.contactStore
// Use CNContactStore directly
let predicate = CNContact.predicateForContacts(matchingEmailAddress: "test@example.com")
let keys: [CNKeyDescriptor] = [CNContactGivenNameKey as CNKeyDescriptor]
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: keys)
#endif

In #if SKIP blocks, you can use Android’s ContactsContract directly:

#if SKIP
let context = ProcessInfo.processInfo.androidContext
let resolver = context.getContentResolver()
let cursor = resolver.query(
android.provider.ContactsContract.Contacts.CONTENT_URI,
nil, nil, nil, nil
)
// Process cursor...
cursor?.close()
#endif
FeatureiOSAndroid
Social profilesSupportedNot available
Contact groupsFull supportFull support
Containers/AccountsFull support (iCloud, Exchange, CardDAV)Approximate (via RawContacts accounts)
Contact pickerCNContactPickerViewControllerACTION_PICK intent
Contact viewerCNContactViewControllerACTION_VIEW intent
Contact editorCNContactViewControllerACTION_INSERT/EDIT intent
Multiple selectionSupportedNot supported (single pick)
Image dataThumbnail + full-sizeThumbnail + full-size
NotesFull supportFull support
Previous family nameSupportedNot available
Phonetic namesSupportedSupported

This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.

Building the module requires that Skip be installed using Homebrew with brew install skiptools/skip/skip. This will also install the necessary build prerequisites: Kotlin, Gradle, and the Android build tools.

The module can be tested using the standard swift test command or by running the test target for the macOS destination in Xcode, which will run the Swift tests as well as the transpiled Kotlin JUnit tests in the Robolectric Android simulation environment.

Parity testing can be performed with skip test, which will output a table of the test results for both platforms.