Designing a Pixel Perfect iMessage Contacts List in SwiftUI

...

This post will teach you how to design layouts and compositions with SwiftUI out of sample data, container views, and modifiers.

iMessage Contacts List feature image

This tutorial takes you through creating a clone of the iOS Messages application’s contacts list. Designing the contacts list will give you the foundations and basic understanding of compositing interfaces in SwiftUI. A follow-up tutorial and its GitHub repository will show you how to implement the list interface created in this tutorial using the Stream SwiftUI SDK.

Setup and Sample Source Code

The source code for the project is hosted on this GitHub repository. It contains several files and folders, but you will need only three of the Swift files to complete this tutorial. These are:

  • MessageDataModel.swift: Located in the Datastore folder
  • HeaderView.swift: Located in the Components folder
  • MessagesView.swift: Located in the UI Design folder

To get the most out of this tutorial, I recommend watching the video version from the Stream Developers YouTube channel.

Touring the Data Storage

In SwiftUI, you can populate list views using static or dynamic content. To display information in the contacts list, you will store the sample data using a dictionary in the Swift file MessagesDataModel.swift. Download the code as a GitHub gist and explore the list data or follow the steps below to create the data file in your Xcode project:

  1. Add a new folder in your project’s navigator and name it DataStore. To create a new folder, you can right-click on any file or folder in the navigator and click the option New Group.
  2. After right-clicking on DataStore, select the option New File and name it as MessagesDataModel.swift.
  3. Below the import Foundation declaration, define the data structure called MessageStructure as follows:
// Data structure
struct MessagesStructure: Identifiable { // Identifiable protocol - makes it possible to use value types that need to have a stable notion of identity.
    var id = UUID() // A universally unique identifier to identify a particular datafield and types
    var unreadIndicator: String
    var avatar: String
    var name: String
    var messageSummary: String
    var timestamp: String
}

The snippet above creates the structure of the message list using struct and the Identifiable protocol in Swift. A struct or structure in Swift allows you to specify some properties to store values.

Declaring a structure as identifiable helps you to define data types that need to have a stable notion of identity. Inside the curly braces comes the definition of the structure. Here, you need to define id as a universally unique identifier (UUID). This makes it feasible for the data fields you define to be identified.

  1. Next, define all the data fields such as unreadIndicator, avatar, name, messageSummary, and timestamp as variables using the var keyword. The fields represent text and image views that will display in the contacts list interface.
  2. Finally, assign a dictionary literal to the constant MessagesData and use it to store the sample data. In Swift, the elements of a dictionary are stored using “key-value” pairs. Unlike an array, which is an ordered collection, the order of items in a dictionary is not important when you want to retrieve values. Your dictionary literal must contain a “comma-separated” list of “key-value” entries separated by a colon. Surround the keys and their associated values with a square bracket as shown in the code below:
let MessageData = [
    MessagesStructure(unreadIndicator: "unreadIndicator", avatar: "jared", name:  
       "Jared", messageSummary: "That's great, I can help you with that! What type  
       of product are you...", timestamp: "13:30 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "martin", name: "Martin Steed", 
       messageSummary: "I don't know why people are so anti pineapple pizza. I kind 
       of like it.", timestamp: "12:40 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "jeroen", name: "Zach Friedman", 
       messageSummary: "(Sad fact: you cannot search for a gif of the word "gif", 
       just gives you gifs.)", timestamp: "11:00 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "carla", name: "Akua Mansa", 
       messageSummary: "There's no way you'll be able to jump your motorcycle over 
       that bus.", timestamp: "10:36 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "zain", name: "Dee McRobie", 
       messageSummary: "Tabs make way more sense than spaces. Convince me I'm 
       wrong. LOL.", timestamp: "9:59 AM"),
    MessagesStructure(unreadIndicator: "unreadIndicator", avatar: "nash", name: 
       "Nash", messageSummary: "(Sad fact: you cannot search for a gif of the word 
       "gif", just gives you gifs.)", timestamp: "9:26 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "fra", name: "Francesco M.", 
       messageSummary: "I don't know why people are so anti pineapple pizza. I kind 
       of like it.", timestamp: "9:20 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "luke", name: "Luke", 
       messageSummary: "There's no way you'll be able to jump your motorcycle over 
       that bus.", timestamp: "9:16 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "maren", name: "Ama Aboakye", 
       messageSummary: "Tabs make way more sense than spaces. Convince me I'm 
       wrong. LOL.", timestamp: "9:00 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "zoey", name: "Zoey", 
       messageSummary: "That's what I'm talking about!", timestamp: "8:59 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "gordon", name: "Gordon Hayes", 
       messageSummary: "(Sad fact: you cannot search for a gif of the word "gif", 
       just gives you gifs.)", timestamp: "8:51 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "amos", name: "Amos G.", 
       messageSummary: "Maybe email isn't the best form of communication.", 
       timestamp: "9:36 AM"),
    MessagesStructure(unreadIndicator: "unreadIndicator", avatar: "maren", name: 
       "Yaa Aso", messageSummary: "There's no way you'll be able to jump your 
      motorcycle over that bus.", timestamp: "8:50 AM"),
    MessagesStructure(unreadIndicator: "", avatar: "dillion", name: "Dillion 
       Megida", messageSummary: "That's what I'm talking about!", timestamp: "8:45 
      AM"),
    MessagesStructure(unreadIndicator: "", avatar: "adam", name: "Adam Rush", 
       messageSummary: "(Sad fact: you cannot search for a gif of the word "gif", 
       ust gives you gifs.)", timestamp: "8:40 AM"),
    MessagesStructure(unreadIndicator: "unreadIndicator", avatar: "thierry", name: 
       "Thierry S.", messageSummary: "Maybe email isn't the best form of 
       communication.", timestamp: "8:36 AM")

Define the Layout Tree/Structure

Layout Tree

The basic structure of the layout you will build in this tutorial is illustrated in the diagram above (the visual appearance is shown on the phone screen). In SwiftUI, you can build layouts by composing views into containment hierarchies. Container views are normally found at the root level of the hierarchy, while child views such as text, images, and shapes are found at the bottom.

In the preview above, there is a root container that has a navigation view and a list container. The list is a collection view made up of rows consisting of the status, avatar, and the column views. The column container has a summary and a row view. At the bottom of the hierarchy is the name view and a horizontal container consisting of the timestamp and the right arrow icon.

You can watch the Apple Developer video SwiftUI Essentials to learn more about SwiftUI’s layout hierarchy diagrams.

Create the Header View

Header View

The header contains the following:

  • Search bar
  • Screen title
  • Edit and compose buttons

The elements of the header are embedded in a Navigation View. Although this tutorial focuses on building a single screen, you will still use the navigation view to compose the header section. In this way, when you have more screens in your app, it will allow users to traverse to the different sections.

To create the header, add a new Swift file HeaderView.swift to the Xcode project. You can also download the code as a GitHub gist or replace the content of the new file with the code below:

//import SwiftUI

struct  HeaderView: View {

    let accentPrimary = Color( colorLiteral(red: 0.03921568627, green: 
                        0.5176470588, blue: 1, alpha: 1))
    @State private var searchText = ""

    var body: some View {
        NavigationView{
            Text("Searching for \(searchText)?")
                .searchable(text: $searchText)
                .navigationTitle("Messages")
                .navigationBarTitleDisplayMode(.inline)
                .navigationBarItems(
                    leading: Button {
                        print("Pressed edit button")
                    } label: {
                        Text("Edit")
                    },

                    trailing: Button {
                            print("Pressed compose button")
                        } label: {
                            Image(systemName: "square.and.pencil")
                        }
                )
        }
        .frame(height: 80)
    }
}

struct  HeaderView_Previews: PreviewProvider {
    static var previews: some View {
        HeaderView()
            .preferredColorScheme(.dark)
    }
}

Here is how it works:

  1. You will define a constant as the color of the navigation items using a color literal let accentPrimary = Color(#colorLiteral(red: 0.03921568627, green: 0.5176470588, blue: 1, alpha: 1)). Additionally, declare a search field and assign an empty string to it using a state variable @State private var searchText = "".
  2. Then, create a text view out of the state variable searchText and make it searchable. The searchable modifier is only available in iOS 15. Making a text view searchable automatically converts it into a search bar.
Text("Searching for \(searchText)?")
                .searchable(text: $searchText)
  1. Next, wrap the search bar in a NavigationView{// Content}.
  2. Give the page navigation a title using the navigation title modifier .navigationTitle("Messages"). This creates a large title, but you can change it to a smaller version by displaying it inline with the navigation buttons using the modifier .navigationBarTitleDisplayMode(.inline).
  3. Finally, you should give the navigation view some items to display. Here, you will show the edit and compose buttons. The navigation view allows you to display the items in two locations: leading (left edge) and trailing (right edge). Use the snippet below to create the navigation bar items.
.navigationBarItems(
              leading: Button {
                  print("Pressed edit button")
              } label: {
                  Text("Edit")
              },

              trailing: Button {
                      print("Pressed compose button")
                  } label: {
                      Image(systemName: "square.and.pencil")
                  }
          )

As you can see, the leading edge has the edit button and the trailing edge contains the compose button.

Create the Views Compositor

The views compositor is divided into two sections: The header and the scrollable message list. In this Swift file, you will bring the contents of the header created previously in the file called HeaderView.swift and pull the data you created in MessagesDataModel.swift to build the list.

Create a new Swift file and name it MessagesView.swift. In the declaration section of your code, introduce the variable messages and set it as an empty array using the message structure you defined in MessagesDataModel.swift, var messages: [MessagesStructure] = []. You will use this variable to populate the message list.

In addition to this, declare the constant readIndicator using a color literal and set the transparency of the color to zero. The color constant you defined here will be used later for aligning elements in the list, so its visibility is not important.

In the body section of your code, introduce a root column container and use it to hold the header and the list of messages.

var body: some View {
        VStack {

            // Header content
            // List of messages

        } // Vertical container for the list and header
    }

Importing the Header Contents

When designing the layout of app screens, it is essential to ensure that your code is compact and well organized by placing the various sections and elements of the code in separate files. That is why you created the header section in a different Swift file. You can bring the contents of the header into the MessagesView.swift by stating the file name of the header, followed by parenthesis as shown below.

var body: some View {
        VStack {

            HeaderView()

            // List of messages

        } // Vertical container for the list and header
    }

Composition of the Message List

Message List Composition

The message list contains the following elements:

  • online status
  • profile image
  • name
  • message summary
  • timestamp.
    These items are stored in a data source in the file MessagesDataModel.swift. Use a list to display the data in rows.

A list is a collection view that shows its content in one column with several rows. After populating the list with the data, there will be several rows that will not fit on the visible area of the screen. This will add an automatic scrolling effect to the list.

You should use the snippet below to display the data in the list:

List(messages) { item in
                HStack {
                    ZStack {
                        readIndicator
                            .frame(width: 11, height: 11)
                        Image(item.unreadIndicator)
                    }

                    Image(item.avatar)
                        .resizable()
                        .clipShape(Circle())
                        .frame(width: 45, height: 45)

                    VStack(alignment: .leading){
                        HStack{
                            Text("\(item.name)")

                            Spacer()

                            Text("\(item.timestamp)")
                                .font(.headline)
                                .foregroundColor(.secondary)

                            Image(systemName: "chevron.forward")
                                .font(.headline)
                                .foregroundColor(.secondary)
                        }

                        Text("\(item.messageSummary)")
                            .font(.headline)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .listStyle(.plain)

Begin by creating a list instance using a closure and pass the variable messages you created previously to the list. The list content builder uses item in to iterate over the entire message data set in the storage.

List(messages) { item in
            }

To display the data inside each row, use an ‘HStack’ as a root container for the list. This horizontal parent view displays the small blue circle representing the online status, avatar, and the text elements.

HStack { 
    // readIndicator
      // avatar
      // VStack
}

Next, use column and row containers to arrange the name, timestamp, disclosure indicator (right arrow), and the message summary. In the data model, these data fields are stored as string variables. For instance, the name is declared as var name: String. To display them here, you will use a text view. You can pick the data fields using the element iterator followed by its variable name. For example, to bring the name field from the database, use Text("\(item.name)").

The snippet below is used to layout the above elements of the list.

VStack(alignment: .leading){
       HStack{
          Text("\(item.name)")

             Spacer()

             Text("\(item.timestamp)")
                .font(.headline)
                .foregroundColor(.secondary)

             Image(systemName: "chevron.forward")
                .font(.headline)
                .foregroundColor(.secondary)
                        }

             Text("\(item.messageSummary)")
               .font(.headline)
               .foregroundColor(.secondary)
    }

By default, the list you create in SwiftUI has a background with rounded corners. To override this behavior, add the list row modifier and set it to a plain list. This will remove the background.

List(messages) { item in 
    HStack { 
       // readIndicator 
       // avatar 
       // VStack 
     } 
 }.listStyle(.plain)

Putting It All Together

Image of the final iMessage Contacts List clone

In the Swift file MessagesView.swift you created previously, you should put the list below the header component as shown in the code below, and that completes this tutorial.

//  MessagesView.swift
//  iMessageClone

import SwiftUI

struct MessagesView: View {

    var messages: [MessagesStructure] = []

    let readIndicator = Color( colorLiteral(red: 0.3098039329, green: 
                        0.01568627544, blue: 0.1294117719, alpha: 0))

    var body: some View {
        VStack {

            HeaderView()

            List(messages) { item in
                HStack {
                    ZStack {
                        readIndicator
                            .frame(width: 11, height: 11)
                        Image(item.unreadIndicator)
                    }

                    Image(item.avatar)
                        .resizable()
                        .clipShape(Circle())
                        .frame(width: 45, height: 45)

                    VStack(alignment: .leading){
                        HStack{
                            Text("\(item.name)")

                            Spacer()

                            Text("\(item.timestamp)")
                                .font(.headline)
                                .foregroundColor(.secondary)

                            Image(systemName: "chevron.forward")
                                .font(.headline)
                                .foregroundColor(.secondary)
                        }

                        Text("\(item.messageSummary)")
                            .font(.headline)
                            .foregroundColor(.secondary)
                    }
                }
            }
            .listStyle(.plain)

        } // Vertical container for the list and header
    }
}
struct MessagesView_Previews: PreviewProvider {
    static var previews: some View {
        MessagesView(messages: MessageData)
            .preferredColorScheme(.dark)
    }
}

Where to Go From Here?

In this tutorial, you learned about how to create a clone of the iOS Messages app’s contact list. You can watch the video version of this tutorial from the Stream Developers YouTube Channel. Also, you can get the completed project files from this GitHub repository. If you are new to Stream Chat SwiftUI, check the article iOS Chat With The Stream SwiftUI SDK to get started.