Building a fully working contact list in SwiftUI


link1. Why this exists

In a recent project, I needed to build a contact list which displays all system contacts. While this wasn’t too hard to achieve, I had to incorporate a lot of stuff I have only learned about then, as I’m still relatively new to building iOS apps. The existing guides were either still for mainly UIKit based apps or only covered the basics.

link2. Structure of the app

The user should be able to do/see those things:

The interactions for our three views would flow this way:

App Interaction Flow

App Interaction Flow

So, let's jump into actually building stuff!

link3. Fetching contacts

link3.1. Requesting permissions

To fetch contacts, we use the Contacts API. As you may know, we first need to ask for permission to access the user’s contacts. This also requires us to add a string describing the reason in Info.plist, otherwise the app would crash. Add an entry for the key NSContactsUsageDescription.

Now, we can request contact access permissions this way:

1func fetchOrRequestPermission(completionHandler: @escaping (Bool) -> Void) {

2 self.contactStore = CNContactStore.init()

3 self.contactStore!.requestAccess(for: .contacts) { success, error in

4 if (success) {

5 completionHandler(true)

6 } else {

7 completionHandler(false)

8 }

9 }

10}

Rightfully, you may ask what self.contactStore is for. We should wrap all our contact access functionality inside a class to access them in various places later.

ContactService.swift
1class ContactService {

2

3 var contactStore: CNContactStore?

4

5 func fetchOrRequestPermission(completionHandler: @escaping (Bool) -> Void) {

6 // see above

7 }

8}

In my real-world app, I use a kind of singleton pattern. As we're not discussing app architecture in this post, however, anything goes. There are arguments for and against using such a pattern, but for now, it relieved more pain than it inflicted on building our app.

link3.2. Fetching all contacts

Now, we are able to fetch the user's contacts. I will explain the code using comments inside so it's easier to follow around.

ContactService.swift
1func getSystemContacts(completionHandler: @escaping ([Contact], Error?) -> Void) {

2 self.fetchOrRequestPermission() { success in // --> First, we need to make sure we have permission to fetch the contacts

3 if (success) {

4 do {

5 let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor] // --> The keysToFetch describe which content we actually want to access. It is good practice to keep this as limited as your app allows.

6

7 var contacts = [CNContact]()

8

9 let request = CNContactFetchRequest(keysToFetch: keysToFetch)

10

11 try self.contactStore!.enumerateContacts(with: request) {

12 (contact, stop) in

13 contacts.append(contact)

14 }

15

16 func getName(_ contact: Contact) -> String { // --> see below for the Contact model and why we implement one

17 return contact.lastName.count > 0 ? contact.lastName : contact.givenName // --> essentially, this is some sugar to order the contacts, as we often want to have them already ordered and not do it inside the model

18 }

19

20 let formatted = contacts.compactMap({

21 // filter out all "empty" contacts

22 if ($0.phoneNumbers.count > 0 && ($0.givenName.count > 0 || $0.familyName.count > 0)) { // --> This is a fun one. On multiple different devices I have encountered those "empty contacts" where no information is stored for some reason. We don't need them so they're filtered out as well.

23 return Contact.fromCNContact(contact: $0)

24 }

25

26 return nil

27 })

28 .sorted(by: { getName($0) < getName($1) }) // --> order by lastname/firstname

29

30 completionHandler(formatted, nil)

31 } catch {

32 print("Failed to fetch contact, error: \(error)")

33 completionHandler([], NSError()) // --> as a fallback, we return an empty array but we should capture the error elsewhere

34 }

35 } else {

36 completionHandler([], NSError()) // --> also, please don't just return empty errors, I was lazy because I know where the error is coming from, but you maybe will not or can't remember why

37 }

38 }

39}

link3.3. The Contact model

By default, the Contacts API returns a CNContact. This is a great class and provides a versatile feature set. But to make it easier, I convert it into my own contact model to keep it concise and make the expectations clear for other developers on which fields they can expect to work with. This model will probably grow with your app (as it did for mine) but it's a good starting point.

It's also good to put an abstraction between the layers in case you have some non-system sourced contacts like from your app's cloud-based address book.

Contact.swift
1struct Contact {

2 var id = UUID()

3 var givenName: String

4 var lastName: String

5

6 var numbers: [PhoneNumber]

7

8 var systemContact: CNContact? // --> This one is important! We keep a reference to later make it easier for editing the same contact.

9

10 struct PhoneNumber { // --> We also have a PhoneNumber struct as they can have labels and we want to display them

11 var label: String

12 var number: String

13 }

14

15 init(givenName: String, lastName: String, numbers: [PhoneNumber], systemContact: CNContact) {

16 self.givenName = givenName

17 self.lastName = lastName

18 self.numbers = numbers

19 self.systemContact = systemContact

20 }

21

22 init(givenName: String, lastName: String, numbers: [PhoneNumber]) {

23 self.givenName = givenName

24 self.lastName = lastName

25 self.numbers = numbers

26 }

27

28 static func fromCNContact(contact: CNContact) -> Contact {

29 let numbers = contact.phoneNumbers.map({

30 (value: CNLabeledValue<CNPhoneNumber>) -> Contact.PhoneNumber in

31

32 let localized = CNLabeledValue<NSString>.localizedString(forLabel: value.label ?? "")

33

34 return Contact.PhoneNumber.init(label: localized, number: value.value.stringValue)

35

36 })

37

38 return self.init(givenName: contact.givenName, lastName: contact.familyName, numbers: numbers, systemContact: contact)

39 }

40

41 func fullName() -> String {

42 return "\(self.givenName) \(self.lastName)"

43 }

44}

That's it for fetching contacts!

link4. Main view

Let's get visual now. It's already time to build our first view. The main view (as in: the contact list) is the starting point of our app.

But actually, we'll start with the view model first and then use it to display our view.

link4.1. View model

ContactListViewModel.swift
1import Foundation

2import Contacts

3

4class ContactListViewModel: ObservableObject {

5 var contactService = ServiceRegistry.contactService // --> here, you pull in the ContactService we've built

6

7 @Published var newContact = CNContact() // --> I'll come back to this later when we talk about editing and creating new contacts

8 @Published var contacts: [Contact] = []

9 @Published var showNewContact = false // --> This is for the modal

10 @Published var noPermission = false // --> Also, we should display a hint when the user hasn't granted permission so they know what's going on

11

12 @Published var searchText = "" // --> this is for the search we're going to implement

13

14 init() {

15 self.fetch()

16 }

17

18 func fetch() {

19 self.contactService.getSystemContacts { (contacts, error) in

20 guard error == nil else {

21 self.contacts = []

22 self.noPermission = true

23 return

24 }

25

26 self.contacts = contacts // --> Because we're doing the heavy lifting inside the service, it takes almost no steps to fetch the contacts

27 }

28 }

29

30 private func contactFilter(contact: Contact) -> Bool {

31 if self.searchText.count == 0 {

32 return true

33 }

34

35 return contact.fullName().localizedCaseInsensitiveContains(self.searchText)

36 }

37}

link4.2. View

Now, let's take a look at the view displaying our lovely contacts.

ContactList.swift
1import SwiftUI

2

3struct ContactList: View {

4 @ObservedObject var viewModel = ContactListViewModel()

5

6 var body: some View {

7 VStack {

8 SearchBar(text: $searchText, placeholder: "Search contacts") // --> see below how to build this

9

10 List {

11 // Filtered list of names

12 ForEach(contacts.filter { contactFilter(contact: $0)}, id:\.id) {contact in // --> We display all filtered contacts

13 NavigationLink(destination: ContactDetailView(contact: contact)) {

14 ContactItem(contact: contact)

15 }

16 }

17 }

18 .modifier(ResignKeyboardOnDragGesture())

19 }

20 }

21}

So, what is actually happening here?

link4.3. ContactItem: Basic list item

SwiftUI makes it easy to break views into reusable components, so we tidy up our app a bit by putting ContactItem in a separate view.

ContactItem.swift
1struct ContactItem: View {

2 var contact: Contact

3

4 var body: some View {

5 VStack(alignment: .leading, spacing: 5) {

6 HStack(spacing: 5) {

7 Text(contact.givenName)

8 Text(contact.lastName)

9 }

10 }

11 }

12}

13

link4.4. SearchBar: Interfacing with UIKit

We're pulling in a SearchBar. This view will interface with UIKit and display the UISearchBar, which is already pre-made by Apple. Let's take a look.

I highly recommend taking a look at the Interfacing with UIKit guide by Apple if you've never integrated a UIKit view before.

SearchBar.swift
1import SwiftUI

2

3struct SearchBar: UIViewRepresentable {

4 @Binding var text: String

5 var placeholder: String?

6

7 class Coordinator: NSObject, UISearchBarDelegate {

8

9 @Binding var text: String

10

11 init(text: Binding<String>) {

12 _text = text

13 }

14

15 func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {

16 text = searchText

17 }

18 }

19

20 func makeCoordinator() -> SearchBar.Coordinator {

21 return Coordinator(text: $text)

22 }

23

24 func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {

25 let searchBar = UISearchBar(frame: .zero)

26 searchBar.delegate = context.coordinator

27

28 searchBar.autocapitalizationType = .none // --> here, we make some adjustments to the view so it better fits to our app

29 searchBar.searchBarStyle = .minimal

30 searchBar.placeholder = placeholder

31

32 return searchBar

33 }

34

35 func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {

36 uiView.text = text

37 }

38}

That's it for the SearchBar, it provides us with all the functions we expect of a basic search bar. Also, we basically get search for free as seen in the view model.

Now, we should take a look at creating a new contact first before showing some details on our contacts, so I'll discuss ContactDetailView later.

link5. Creating a new contact

It's time to add a button for contact creation now.

Add those modifiers to the VStack inside the ContactList:

ContactList.swift
1.navigationBarItems(trailing:

2 Button(action: { self.viewModel.showNewContact = true }) {

3 Image(systemName: "person.crop.circle.badge.plus")

4 .resizable()

5 .scaledToFit()

6 .frame(width: 20)

7}

8.sheet(isPresented: self.$viewModel.showNewContact, onDismiss: { self.viewModel.fetch() }) {

9 NavigationView() {

10 EditContactView(contact: self.$viewModel.newContact) // --> We'll get to that now

11 }

12}

13.disabled(self.viewModel.noPermission)) // --> The user can't add any contacts inside our app if we have no permissions, so disable the button

link5.1. EditContactView: Some more UIKit interfacing!

UIKit provides a view (CNContactViewController) we can use to create/edit contacts. This is great becasue we don't need to write our own and it makes sure that we can adapt to new versions of iOS relatively quickly.

It's mostly the same as for 4.4. SearchBar, but obviously with some adjustments.

EditContactView.swift
1import Foundation

2import SwiftUI

3import ContactsUI

4

5struct EditContactView: UIViewControllerRepresentable {

6 class Coordinator: NSObject, CNContactViewControllerDelegate, UINavigationControllerDelegate {

7 func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { // --> this gets called when the user taps done or cancels the editing/creation of a contact

8 if let c = contact {

9 self.parent.contact = c

10 }

11

12 viewController.dismiss(animated: true)

13 }

14

15 var parent: EditContactView

16

17 init(_ parent: EditContactView) {

18 self.parent = parent

19 }

20 }

21

22 @Binding var contact: CNContact

23

24 init(contact: Binding<CNContact>) {

25 self._contact = contact

26 }

27

28 typealias UIViewControllerType = CNContactViewController

29

30 func makeCoordinator() -> Coordinator {

31 return Coordinator(self)

32 }

33

34 func makeUIViewController(context: UIViewControllerRepresentableContext<EditContactView>) -> EditContactView.UIViewControllerType {

35 if self.contact.identifier.count != 0 { // --> for editing: here, we check if the contact is actually empty (so, a new one) or already exists

36 do {

37 let descriptor = CNContactViewController.descriptorForRequiredKeys()

38

39 let store = CNContactStore()

40

41 let editContact = try store.unifiedContact(withIdentifier: self.contact.identifier, keysToFetch: [descriptor])

42

43 let vc = CNContactViewController(for: editContact) // --> if there's a contact we can edit, we disply the system's view for editing contacts

44 vc.delegate = context.coordinator

45

46 return vc

47 } catch {

48 print("could not find contact, this happens when we are trying to create a new contact that doesn't exist yet")

49 print(error)

50 }

51 }

52

53 let vc = CNContactViewController(forNewContact: CNContact()) // --> otherwise, we can assume that we want to create a new contact, so we display a fresh view

54 vc.delegate = context.coordinator

55 return vc

56 }

57

58 func updateUIViewController(_ uiViewController: EditContactView.UIViewControllerType, context: UIViewControllerRepresentableContext<EditContactView>) {

59

60 }

61}

With that, we are pulling in the contact editing view you already know from the phone app.

Also, that's already it for creating a new contact! SwiftUI really helps us to keep our code tidy.

link6. Contact details view

We're almost done! Now, we want to display some details for the contacts we've fetched. For this, we create ContactDetailView, as you've seen in ContactList.

Like with ContactList, let's first look at ContactDetailViewModel!

ContactDetailViewModel.swift
1import Foundation

2import Contacts

3

4class ContactDetailViewModel: ObservableObject, Identifiable {

5 @Published var contact: Contact

6 @Published var showEditContactView = false

7

8 init(contact: Contact) {

9 self.contact = contact

10 }

11

12 func updateContact() { // --> When the user is done editing a contact, we inform our view to update the contact to show the newly edited content, so we fetch the updated version

13 let contactStore = CNContactStore.init()

14 let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactPhoneNumbersKey] as [CNKeyDescriptor]

15

16 do {

17 let updated = try contactStore.unifiedContact(withIdentifier: self.contact.systemContact!.identifier, keysToFetch: keysToFetch)

18 self.contact = Contact.fromCNContact(contact: updated)

19 } catch {

20 print(error)

21 }

22 }

23}

It's time for the view! You've probably got the grip now on how it should go...

ContactDetailView.swift
1

2import SwiftUI

3

4struct ContactDetailView: View {

5 @ObservedObject var viewModel: ContactDetailViewModel

6

7 init(contact: Contact) {

8 self.viewModel = ContactDetailViewModel(contact: contact)

9 }

10

11 var body: some View {

12 Form() { // --> Form gives us a neat style to replicate the style we already know from the standard contacts app

13 HStack() {

14 Text(viewModel.contact.givenName)

15 Text(viewModel.contact.lastName)

16 }

17 .padding(.vertical)

18 .font(.system(.title))

19

20

21 Section() {

22 ForEach(viewModel.contact.numbers, id: \.number) { number in // --> here, we display all the numbers we got

23 Button(action: { /* do something */ }) {

24 VStack(alignment: .leading, spacing: 5) {

25 Text(number.label)

26 .font(.system(.subheadline))

27 .foregroundColor(.secondary)

28 Text(number.number)

29 }

30 }

31 }

32 }

33 }

34 .navigationBarItems(trailing:

35 Button(action: { self.viewModel.showEditContactView = true }) {

36 Text("Edit")

37 }

38 .disabled(self.viewModel.contact.systemContact == nil) // --> we don't want to let the user edit a non-system contact (like the aforementioned cloud-contacts, for example)

39 .sheet(isPresented: self.$viewModel.showEditContactView) {

40 NavigationView() {

41 EditContactView(contact: .constant(self.viewModel.contact.systemContact!)) // --> here, we pass the system contact we set in the beginning!

42 }

43 .onDisappear() {

44 self.viewModel.updateContact() // --> notify our view model to update the displayed contact

45 }

46 }

47 )

48 }

49}

As before, we almost get all the heavy-lifting for free thanks to the vast capabilities UIKit already provides without the hassle thanks to SwiftUI!

link7. Summary

What? Already done?

Yes, we've built all the stuff needed for a fully working contact list, editing capabilities and a detail view that you can pull into your app! Even if you have different use cases, it's not too much effort to adapt, as we did all the groundwork needed!

link8. Building forward...

There's some stuff left we could pull into to make our contact list even more useful.

Some of those improvements include:

Let's see, I may come back with some posts on how to do those things 😉

Happy hacking!

Got any thoughts, improvements or comments? Feel free to reach out to me on hey@timweiss.net! I'd love to hear what you think of this post!


1. Why this exists2. Structure of the app3. Fetching contacts3.1. Requesting permissions3.2. Fetching all contacts3.3. The Contact model4. Main view4.1. View model4.2. View4.3. ContactItem: Basic list item4.4. SearchBar: Interfacing with UIKit5. Creating a new contact5.1. EditContactView: Some more UIKit interfacing!6. Contact details view7. Summary8. Building forward...

Home

Swift Stuffchevron_right
Cloud Computingchevron_right