Search
  • Riya Manchanda

How to Create a Contacts List with SwiftUI



 

SwiftUI is a fairly latest concept and thus there is a lot of scope for exploration. In this tutorial I will be taking you through developing a Contacts Screen in SwiftUI. Feel free to move along with it at your own pace, and leave any questions or criticism in the comments. Your thoughts are always welcome!


What are we building?

If you have been keeping up with my work, you will know that my app Transaction is a tool which people can use to organise their finances and keep track of all the payments they make/receive in their day-to-day lives. As part of this app, I had included a Contacts screen where users could view transactions made to individual people. The final product at the end of this tutorial should look somewhat like this:





Table of Contents:

  1. Adding Core Data Entities

  2. Adding Contacts

  3. Organising the List

  4. Deleting Contacts

  5. Searching

  6. Navigating to individual pages

  7. Adding Transactions

  8. Ordering our Contacts List


Before diving into the tutorial, you must create an Xcode project which utilises SwiftUI. I am assuming you have already done so and made sure to integrate Core Data into your project.



1. Adding Core Data Entities


The very first step toward building your own contacts page in your application, is to first organise your Core Data Model and add Entities (the Core Data equivalent of Data Tables) to it. Core Data allows you to store data offline on the user's mobile through a SQLite database. It even allows you to integrate CloudKit and sync data to multiple devices through I-Cloud.

For the sake of this tutorial, I will be creating two Entities with the following fields and relationships, identical to my app Transaction.

  • Contact

  • Fields: id (type UUID), name (type String)

  • Relationships: Transaction (one-to-many relationship)

  • Transaction

  • Fields: id (type UUID), amount (type Float), date (type Date), title (type String)

  • Relationships: Contact (many-to-one relationship)

It is quite simple to add these to your model. Follow the below steps to do so:

  • Navigate to the .xcdatamodel file of your project

  • Click on the '+' button which says 'Add Entity' and add both your entities

  • Navigate to each of your entity and add the above fields in the attributes column of your entity. Make sure these fields are not optional in the attributes inspector.

  • Add the relationships and adjust their 'Type' in the attributes inspector. Make sure as well or check the 'order' box for the person-transaction (to-many) relationship, such that we can order it easily later.

Another way to securely customise your Core Data Model is to generate NSManaged subclasses, but that is beyond the scope of this tutorial.

You can adjust the features of these entities in the Attributes Inspector as per your requirements. Here's a quick glance of what your Core Data Model should look like after making the above modifications.





Now, that we have this, we are ready to begin adding entries to our Model.



2. Adding Contacts

Before we can actually start adding contacts, we might want to make a very basic User Interface for our project and display a button which will prompt the user to add a new contact.

So, firstly we navigate to our ContentView.swift class and modify it a little bit to add a NavigationView (since we will be navigating to individual contact pages later) and a Button to add contacts.



struct ContentView: View {
    var body: some View {
 
        NavigationView {
            Text("This is our Contact Page!")
                .navigationTitle("My Contacts")
                .navigationBarItems(trailing:
                    Button (action: {
 
                    }) {
                        Image(systemName: "plus")
                    }
                )
        }
 
    }
 }
 

For now we will leave the action part of the button empty. So, this is what the output for that should look like:



Next, we should create a basic sheet or modal which will appear at the click of the button. For this, let us create a new Swift Class in the same file:



struct NewContactView: View {

    @State var name: String = ""

    var body: some View {

        VStack (spacing: 20) {
        
            Text("Add a New Contact:")
                .font(.headline)
            TextField("Enter Name", text: $name)
                .padding(20)
                .background(Color(.systemGray6))
                .cornerRadius(8)
        
        }
        .padding()
    
    }
}
    

Now we have a basic screen which will allow the user to enter the name of their contact. Note that we created a State variable called name, which records the user's textfield input. However, there is not option yet for the user to submit this form and save their contact. This is where we will go ahead and add the Core Data viewContext to our struct which will allow us to start saving entries to our 'Contact' entity.



struct NewContactView: View {

    @Environment(\.managedObjectContext) private var viewContext

    @State var name: String = ""

    var body: some View {

        VStack (spacing: 20) {
        
            Text("Add a New Contact:")
                .font(.headline)
            TextField("Enter Name", text: $name)
                .padding(20)
                .background(Color(.systemGray6))
                .cornerRadius(8)
                
            Button (action: {
                guard self.name != "" else {
                    return
                }
                let newPerson = Contact(context: viewContext)
                newPerson.name = self.name
                newPerson.id = UUID()
                do {
                    try viewContext.save()
                } catch {
                    print(error.localizedDescription)
                }
            }) {
                Text("Save")
            }
        
        }
        .padding()
    
    }
}
    

Okay, so here we have added multiple things. Firstly, we added Core Data context to our class as an Environment variable. We have also created a button called 'Save' which will allow our users to save their contacts. As you can see, the tap action of this button first includes checking if the name field is empty. If it is not, then we move onto creating a new instance of the Contact entity called 'newPerson'. We save the name attribute of our newPerson as self.name, which is the input value of our textfield. We also generate a unique id for our Contact to identify it distinctly. Lastly, we try to save our viewContext or our Model, and display any error that might occur to the console. And just like that, we can now start saving contacts!


But you might have realised that we cannot yet access this screen from our main page. So let us go ahead and add this screen as a Modal Sheet (a sort of slide over overlay screen) to our ContentView:



struct ContentView: View {

    @State var showNewContactView: Bool = false

    var body: some View {
 
        NavigationView {
            Text("This is our Contact Page!")
                .navigationTitle("My Contacts")
                .sheet(isPresented: $showNewContactView) {
                    NewContactView()
                }
                .navigationBarItems(trailing:
                    Button (action: {
                        showNewContactView.toggle()
                    }) {
                        Image(systemName: "plus")
                    }
                )
        }
 
    }
 }



Hurrah! And just like that we are now able to switch between screens and add new contacts. What we have done here, is create a Boolean variable which is initially false, and becomes true every time the user clicks on our 'plus' button. Then we have created a modal which is presented when our 'showNewContactView' variable is true, and the NewContactView is displayed in that modal.


That's all great, but we still have one small problem: our NewContactView does not dismiss itself once we have saved our contact. So to fix that we can add the presentation mode Environment to our NewContactView as following:



struct NewContactView: View {

    @Environment(\.managedObjectContext) private var viewContext
    @Environment (\.presentationMode) var presentationMode

    @State var name: String = ""

    var body: some View {

        VStack (spacing: 20) {
        
            Text("Add a New Contact:")
                .font(.headline)
            TextField("Enter Name", text: $name)
                .padding(20)
                .background(Color(.systemGray6))
                .cornerRadius(8)
                
            Button (action: {
                guard self.name != "" else {
                    return
                }
                let newPerson = Contact(context: viewContext)
                newPerson.name = self.name
                newPerson.id = UUID()
                do {
                    try viewContext.save()
                    presentationMode.wrappedValue.dismiss()
                } catch {
                    print(error.localizedDescription)
                }
            }) {
                Text("Save")
            }
        
        }
        .padding()
    
    }
}
    

So now, whenever we successfully save a contact to our Core Data Model, the modal will dismiss itself and we will come back to our ContentView. This should look something like this:





Voila! We are done with this step of making our Contacts page, so feel free to play around with it before moving forward.



3. Organising the List


Great job, once we have allowed our users to add contacts, we should display all their contacts on our main screen, which is our ContentView. In order to do that, we need to first fetch all the instances of our Contact entity from our model. We can do this by add the following code above our body variable in the ContentView structure.



@FetchRequest(entity: Contact.entity(), sortDescriptors: [])

var persons: FetchedResults<Contact>


Here I would like to point out that you have the option to add NSSort Descriptors which will allow to sort all your contacts, say for example, alphabetically. You can learn more about that here.

The next step now is to create a List, which is the equivalent of TableView in Storyboard or UIKit, where we will display all our contacts. For this I want to create a separate structure for each of the Contact's tile or item, and I want to style in a particular way:




struct PersonTileView: View {
 
@ObservedObject var person: Contact
 
var body: some View {

    HStack {
    
        Text(person.name)
            .font(.custom("Roboto-Bold", size: 20))
            .foregroundColor(Color.black)
 
      }

   }.padding()
}
    

There are two things to note here: first, this View takes a variable person whenever it is called, whose name attribute it then displays, and second, I have used a custom font in this part which you will have to add separately. You can read more about it here.

Now that we have this, let us actually create a List in our ContentView where we create an instance of this View for each contact in a user's list. That's right, we will be using some looping here. So add the following code to the NavigationView of your ContentView:




NavigationView {

    List {
        ForEach (persons) { person in
            PersonTileView(person: person)
        }
    }
    
     .navigationTitle("My Contacts")
     .sheet(isPresented: $showNewContactView) {
         NewContactView()
     }
     .navigationBarItems(trailing:
         Button (action: {
             showNewContactView.toggle()
         }) {
             Image(systemName: "plus")
         }
     )
}
        


This is pretty straightforward, we create a List and to add items to that list, we loop over our fetched Contact results and create a PersonTileView for each person, passing that person as a parameter to the View. This should look like this:





For now this is good enough for our list, we will be adding more information to these tiles later, once we begin dealing with the transactions for each person.



4. Deleting Contacts


Before we jump into navigating to individual contacts, it would be ideal to add a feature which allows user to delete contacts which they accidentally create or no longer require. Jumping straight to business, let us begin by thinking of how we want to present our 'delete' button to our user. For this tutorial, I have chosen to use a context menu, which is the menu of buttons which appears when you tap and hold some subview in IOS applications. I want to add a context menu to each individual contact, which is each individual PersonTileView as follows:


List {
    ForEach (persons) { person in
    
        PersonTileView(person: person)
            .contextMenu {
                Button(action: {
                    
                }) {
                    Text("Delete")
                }
            }
                
    }
}

Next what I want to do is, create a function which will take a contact as a parameter and deletes it from our Core Data Model (our view context). For this we will first have to add the view context Environment to our ContentView as well, and then add the following code to our ContentView structure:


func delete (person: Contact) {

    viewContext.delete(person)
    do {
        try viewContext.save()
    } catch {
        print(error.localizedDescription)
    }
        
}

Then we simply call this function for the selected person, every time a delete button is clicked:


.contextMenu {

    Button(action: {
        delete(person: person)
    }) {
        Text("Delete")
    }
        
}
        

This is what it should look like:



Bravo! You users can now delete their contacts form their Contacts List.



5. Searching


If you've read my previous post about how to make a successful, you will know that one very important features that will allow users to conveniently browse through their contacts, is a search field. Let us begin by designing a static search field using textfield and adding a variable for the text input:



@State var searchText: String = ""



HStack {
    TextField("Search", text: $searchText)
        .frame(height: 30)
        .padding(10)
        .padding(.horizontal, 25)
        .background(Color(.systemGray6))
        .cornerRadius(8)
        .overlay(
            HStack {
                Image(systemName: "magnifyingglass")
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .padding(.leading, 8)
            }
        ) 
 
}
.padding(.horizontal, 15)
.padding(.top, 15)
 

Here we have created a basic textfield, and added an image overlay to it for aesthetic. This what it should look like:



However, this search field will not be feeling quite there to you yet. Two standard features that one might want to add to this search field to make it more usable, is a 'cancel' button to dismiss it, and a 'x' button to clear the field when the user wants to start typing over again. We also need to keep in mind that these buttons only appear when the textfield is focused.


For this, we will first create a boolean state variable which will help us determine whether or not the textfield is focused:



@State var isFocused: Bool = false


Great, now we need to add a tap gesture to our text field, such that whenever the user taps on it, the variable will become true and prompt the buttons (which we will create after) to be displayed:



TextField("Search", text: $searchText)
        .frame(height: 30)
        .padding(10)
        .padding(.horizontal, 25)
        .background(Color(.systemGray6))
        .cornerRadius(8)
        .overlay(
            HStack {
                Image(systemName: "magnifyingglass")
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .padding(.leading, 8)
            }
        ) 
        .onTapGesture {
            self.isFocused = true
        }
        

And now we can add add buttons to our view, but within an 'if condition' statement, such that the buttons are only displayed if the variable isFocused is true:



HStack {
    TextField("Search", text: $searchText)
        .frame(height: 30)
        .padding(10)
        .padding(.horizontal, 25)
        .background(Color(.systemGray6))
        .cornerRadius(8)
        .overlay(
            HStack {
                Image(systemName: "magnifyingglass")
                    .foregroundColor(Color("Title"))
                    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                    .padding(.leading, 8)
 
                if self.isFocused {
                    Button(action: {
                        self.searchText = ""
                    }) {
                        Image(systemName: "multiply.circle.fill")
                            .foregroundColor(.gray)
                            .padding(.trailing, 8)
                    }
                }
           }
 
        ).onTapGesture {
            self.isFocused = true
        }
 
    if isFocused {
        Button(action: {
        
            self.isFocused = false
            self.searchText = ""
     UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)

        }) {
            Text("Cancel")
        }
        .padding(.trailing, 10)
        .transition(.move(edge: .trailing))
        .animation(.default)
    }
 
 
}
.padding(.horizontal, 15)
.padding(.top, 15)


Okay, I know that is a lot take in, we have just done a lot o things at once. First we added an 'x' button as an overlay on the textfield, which is displayed when the isFocused variable is true. When this button is clicked, the searchText variable is reset to an empty string, and so is the textfield input.

Moving on, we added a cancel button next to the textfield in a horizontal stack which is also displayed when the isFocused variable is true. We also added animation to this button, to add a sliding effect to it. Pressing on this button sets the isFocused variable to false and resets the searchText string. However, that alone is not enough to defocus the textfield, so in order to dismiss the user's keyboard, we added the following line of code:


     UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)


Awesome, we know have a satisfying search field which can be dismissed with the click of a button. You would have noticed that the search does not really work yet, i.e. it does not sort through our contacts list to show the desired results yet. To implement that feature, what we could do is, filter our persons list (which is our list of the fetched contacts that the user has), and check if the name attribute of each contact matches with or contains the string stored in our name variable (as prefix of course). In order to do that, let us create a new variable which contains this filtered list.



let filteredPeople = persons.filter {

    searchText.isEmpty || ($0.name.lowercased().prefix(searchText.count) == searchText.lowercased())

}
 

Here we are using Swift's filter feature to create a new array, which contains all the elements that return for the condition in the bracket. The condition prompts the function to return for all elements if searchText is empty. Otherwise, it takes the contact in our array of contacts, accesses its name attribute, makes it lowercase, and then checks if the few letters of the name are the same (equivalence operator) as the search text (lowercase). Once we are through with this and we have our filtered array, we can now pass this into our ForEach loop inside our List in the ContentView:



let filteredPeople = persons.filter {

    searchText.isEmpty || ($0.name.lowercased().prefix(searchText.count) == searchText.lowercased())

}

NavigationView {

    // Your Search Field Code

    let filteredPeople = persons.filter {

        searchText.isEmpty || ($0.name.lowercased().prefix(searchText.count) == searchText.lowercased())

    }

    List {
        ForEach (filteredPeople) { person in
    
            PersonTileView(person: person)
                .contextMenu {
                    Button(action: {
                        delete(person: person)
                    }) {
                        Text("Delete")
                    }
                }
                
        }
    }
    .padding()
        
}
          

This should look something like this by now:



And just like that, our search feature is implemented! That wasn't as complicated as expected, right? Now that we have a fantastic Contacts, it is now time to allow the user to navigate to the individual contact pages.



6. Navigating to Individual Pages


I will also be showing you how to navigate to individual contact pages in this tutorial, and also allow users to add transactions for each contact, just like the functionality in my application Transaction. To link our contact list to a contact page, We first need to make a new View for our Contact screen.



struct ContactPageView:View {
 
    @ObservedObject var person: Contact
 
    var body: some View {
        Text("This is \(person.name)'s Contact Page!")
    }
        
}
 

As you can see, this view takes one variable, which is the person/contact whose we want to show, and then display that person's name.


Now, it is time to connect our PersonTileView(s) to their own instances of ContactPageView. This can be done through NavigationView; recall that we have already embedded our list within a NavigationView. Now we can simply embed our PersonTileView(s) inside of NavigationLinks and give it a parameter - destination:



 ForEach( filteredPeople) { person in
 
     NavigationLink(destination: ContactPageView(person: person)) {  
    
         PersonTileView(person: person)
    
     }
      .contextMenu {
     
          Button(action: {
              delete(person: person)
          }) {
              Text("Delete")
          }
      }
      
 }
 

This is the result:



Notice that the grey highlight stays on the navigation link when you return to the previous page. That is a SwiftUI issue which we will be tackling in some future post.

And voila! Your list items are now links to their own individual Contact Pages for each person in the user's contacts list. Pretty exciting, no?



7. Adding Transactions


Although this step is not exactly part of making a Contacts List and is lightly outside the scope of this tutorial, I have decided to cover it for the sake of the next step, so that we can order our Contacts List based on the last person contacted (transaction added).


Naturally, this step will be somewhat identical to when we made the New Contact feature, so no surprises there. As we know, a lot of software development depends on reusing and recycling existing code. Implementing this feature would go something like:



struct NewTransactionView: View {

    @Environment(\.managedObjectContext) private var viewContext
    @Environment (\.presentationMode) var presentationMode

    @State var amount: String = ""
    @State var date: Date = Date()
    @State var title: String = ""
    var contact: Contact

    var body: some View {

        VStack (spacing: 20) {
        
            Form {
        
                    TextField("Enter Amount", text: $amount)
                    DatePicker("Date", selection: $date, displayedComponents: .date)
                    TextField("Enter Payment Title", text: $title)
                    Button (action: {
                        guard self.amount != "" && self.title != "" else {
                            return
                        }
                        let transaction = Transaction(context: viewContext)
                        let formatter = NumberFormatter()
                        formatter.numberStyle = .decimal
                        let nsNumber = formatter.number(from: self.amount)
                        transaction.amount = nsNumber!.floatValue
                        transaction.date = self.date
                        transaction.contact = contact
                        transaction.title = self.title
                        transaction.id = UUID()
                        do {
                            try viewContext.save()
                            presentationMode.wrappedValue.dismiss()
                        } catch {
                            print(error.localizedDescription)
                        }
                    }) {
                        Text("Save")
                    }
                 
            }        
        }
        .padding()
        .navigationBarTitle("Add New Transaction")
    
    }
}
    

And there we have our NewTransactionView. Now that we are allowing the user to add transactions, we would also want to display our button and each of the transactions in the individual Contact's pages:




 struct ContactPageView:View {
 
    @ObservedObject var person: Contact
    @State var showNewTransactionView: Bool = false
 
    var body: some View {
        List {
            ForEach (person.transactions?.array as! [Transaction]) { transaction in 
                HStack {
                    VStack (alignment: .leading, spacing: 7) {
                        Text(transaction.title ??)
                        Text("\(transaction.date!)" as String)
                    }
                    Spacer()
                    Text("\(transaction.amount)" as String)
                }
            }
        }
            .navigationBarTitle(person.name)
            .navigationBarItems(trailing:
                Button (action: {
                    showNewTransactionView.toggle()
                }) {
                    Image(systemName: "plus")
                }
            )
            .sheet(isPresented: $showNewTransactionView) {
                NewTransactionView(person: person)
            }
    }
        
}
 


Great going, our users can now add new transactions for each of their individual people in their contacts list! One thing to notice here is that we are not fetching the Transaction entity separately, rather we are simply accessing the transactions relationship of our Contact. Another important point is that, the collection of the Contact's transactions is by default an NSSet, and in order to convert into an iterable format, we made it into an array. This should be looking somewhat like this:





Notice the weird formatting of the dates, there are many ways to format dates but that will be covered in some other tutorial. After we have done this, we are now ready to move on to the last part of this tutorial.



8. Ordering Contacts List


This is perhaps one of the most interesting features that we are implementing in this tutorial. What we are going to is, we will order our Contacts list not alphabetically, but according to the last person for whom we added a transaction, sort of like WhatsApp feature where we see the most recently contacted at the top. For this, we will be sorting through our filtered array (the array which contains our search results) using Swift's sort function, and making yet another array out of it (for simplicity's sake). It goes something like this:



let sortedPeople = filteredPeople.sorted {
 
    switch (($0.transactions?.array.last as? Transaction)?.date, ($1.transactions?.array.last as? Transaction)?.date) {
 
        case (($0.transactions?.array.last as? Transaction)?.date as Date, ($1.transactions?.array.last as? Transaction)?.date as Date):
            return ($0.transactions?.array.last as! Transaction).date > ($1.transactions?.array.last as! Transaction).date
        
        case (nil, nil):
            return false
        
        case (nil, _):
            return false
        
        case (_, nil):
            return true
        
        default:
            return true
 
    }

 }


I know, that can be a little overwhelming. It is slightly messy to access the date of each person's transactions again and again, but this is one of the least complicated methods of implementing our desired functionality, trust me. What we have essentially done here is, since our transactions set is an ordered one, we access the last transaction for every contact, and we then sort in descending based on their date attributes. Let us add this sorted array in our ForEach loop as follows:



 ForEach( sortedPeople ) { person in
 
     NavigationLink(destination: ContactPageView(person: person)) {  
    
         PersonTileView(person: person)
    
     }
      .contextMenu {
     
          Button(action: {
              delete(person: person)
          }) {
              Text("Delete")
          }
      }
 
      .padding(12)
      .padding(.trailing, 15)
      
 }
 

Now that we have implemented this feature, to check whether it worked, let us add some details about our users' transactions in the PersonTileView(s):




struct PersonTileView: View {
 
@ObservedObject var person: Contact
 
var body: some View {

    HStack {
    
        VStack(alignment: .leading, spacing: 8) {
 
            Text(person.name)
                .font(.custom("Roboto-Bold", size: 20))
 
            if let transaction = person.transactions?.array as? [Transaction] {
                if !transaction.isEmpty {
                    Text("\(transaction.last!.date)" as String)
                        .font(.custom("Open-Sans", size: 12))
                }
            
            } else {
 
                Text( "No Transactions")
                    .font(.custom("Open-Sans", size: 12))
 
            }
        }
 
      Spacer()

      if let transaction = person.transactions?.array as? [Transaction] {
    
          let amount = transaction.map{($0.amount)}.reduce(0,+)
          let currencySymbol = Locale.current.currencySymbol!
        
          Text("\(currencySymbol) \(amount)")
              .foregroundColor(Color.green)
              .font(.custom("Roboto-Bold", size: 20))
    
      }
     }.padding()

   }
}
    


So what I have done here is, I added the date of the person's last transaction to the contact, and the total amount that the person has paid to the contact till date.


This is what the final thing should look like:




Congratulations! Just like that, you have developed your very Contacts Page with SwiftUI. Now it's time for you to experiment with this mini application and try out different features you can include to improve it.



Conclusion

Wow, that was long, wasn't it? I would like to extend my heartfelt gratitude to those of you who made it through the entire post, and I sincerely hoped this was of help to you. If it was, do not forget to let me know by dropping your likes and comments. Remember, programming is a lot about trial and error, so never be afraid to take risks! Before I sign off, I would like to request you to let me know of any specific topic you want me to post about in the comments!


Until Next Time ~

1,167 views0 comments