Preparing the interface

Composing the chat rows 👨‍🎨

Each ChatMessage instance should be represented by its own row. So, let’s start defining the look of these rows.

Each ChatMessage instance is presented by its own row, containing the sender’s username, the message’s content and indicating through its position and color whether the message was sent by the user himself or by another user 

To do this, create a new SwiftUI file and name it “ChatRow”. We’ll use instances of this view later inside our ChatView to display the whole conversation. 

We need to provide each row with a ChatMessage instance, therefore we declare a corresponding property:

struct ChatRow: View {
    
    let message: ChatMessage
    
    var body: some View {
        Text("ChatView")
    }
}

We use the second ChatMessage inside our sampleConversation array for our ChatRow_Previews struct to display a message sent by another user.

struct ChatRow_Previews: PreviewProvider {
    static var previews: some View {
        ChatView(message: sampleConversation[1])
    }
}

Let’s use the existing Text view for presenting the ChatMessage’s content. Depending on whether it’s sent by the user or not, we use a different color.

Text(message.messageText)
    .font(.body)
    .foregroundColor(message.isMe ? .white : .black)
    .lineLimit(nil)

Above this Text view, we want to show the username of the sender:

VStack(alignment: .leading) {
    Text(message.username)
        .font(.footnote)
        .foregroundColor(message.isMe ? Color("LightGrayColor") : .gray)
    Text(message.messageText)
        .font(.body)
        .foregroundColor(message.isMe ? .white : .black)
        .lineLimit(nil)
}

Let’s create the chat bubbles by adding the following modifiers to the VStack:

VStack(alignment: .leading) {
    //...
}
    .padding(10)
    .background(message.isMe ? Color.blue : Color("LightGrayColor"))
    .cornerRadius(10)

At this point, the chat bubble is always centered. Instead, we want it to be aligned on the left side when a message is sent by another user and aligned on the right side when it’s a message sent by us. Therefore, wrap the VStack into a HStack, apply some padding to the HStack and insert Spacer views depending on the ChatMessage’s isMe property.

HStack {
    if message.isMe {
        Spacer()
    }
    VStack(alignment: .leading) {
        //...
    }
        //...
    if !message.isMe {
        Spacer()
    }
}
    .padding()

Looks good so far! Let’s go to our ChatRow_Previews struct and present the third message of our sampleConversation.

struct ChatRow_Previews: PreviewProvider {
    static var previews: some View {
        ChatRow(message: sampleConversation[2])
    }
}

Our message bubble is a little bit too wide. Instead, we want each bubble to be of a maximum width of 280. At this point, we also select the correct alignment mode depending on whether the message is sent by us or not.

VStack(alignment: .leading) {
    //...
}
    //...
    .frame(maxWidth: 280, alignment: message.isMe ? .trailing : .leading)

Awesome, we are finished with composing the rows for our chat app!

Composing the ChatView 🎨 

Now that we created our chat rows, we can use them inside our ChatView. For presenting a whole conversation, we create a ForEach loop wrapped into a VStack. For now, we use the sampleConversation array as the ForEach loop’s data source and identify each ChatMessage by its messageID property. 

struct ChatView: View {
    
    var body: some View {
        VStack {
            ForEach(sampleConversation, id: \.messageID) { message in
                ChatRow(message: message)
            }
        }
    }
}

To push the whole conversation to the top, we insert a Spacer view.

VStack {
    ForEach(sampleConversation, id: \.messageID) { message in
        ChatRow(message: message)
    }
    Spacer()
}

Awesome, now our whole sampleConversation is visible in the Preview simulator.

Next, we want to implement a TextField that can be used to enter and send a new message. Therefore, place a ZStack below the Spacer and insert a Rectangle view. The whole ZStack’s height should be limited.

ZStack {
    Rectangle()
        .foregroundColor(.white)
}
    .frame(height: 70)

Now we want to add a rounded border on top of the Rectangle. To do this, insert a RoundedRectangle into the ZStack. For the RoundedRectangle to be a border only, we use the .stroke modifier. We also apply some padding.

ZStack {
    Rectangle()
        .foregroundColor(.white)
    RoundedRectangle(cornerRadius: 20)
        .stroke(Color("LightGrayColor"), lineWidth: 2)
        .padding()
}
    .frame(height: 70)

Now, we insert a TextField into the ZStack. But before doing this, we need to declare a State for holding the TextField’s input.

struct ChatView: View {
    
    @State var newMessageInput = ""
    
    var body: some View {
        //...
    }
}

So, let’s insert a TextField into the ZStack below the RoundedRectangle and bind it to the newMessageInput State.

ZStack {
    //...
    TextField("New message...", text: $newMessageInput, onCommit: {
        print("Send Message")
    })
        .padding(30)
}
    .frame(height: 70)

Besides sending the message when the user hits the keyboard’s return key, we want to implement a small “send” icon next to the text field. So, wrap the TextField into a HStack and insert a Button showing the paperplane system image from the SF Symbols app.

HStack {
    TextField("New message...", text: $newMessageInput, onCommit: {
        print("Send Message")
    })
        .padding(30)
    Button(action: {
        print("Send message.")
    }) {
        Image(systemName: "paperplane")
            .imageScale(.large)
            .padding(30)
    }
}

Finally, we wrap the overall ZStack into a NavigationView and add a navigation bar title to it.

NavigationView {
    VStack {
        //...
    }
        .navigationTitle("Chat App")
}

Your preview should now look like this:

ScrollView and ScrollViewReader ↕️

However, we are not yet able to scroll through the chat. To change this, we can easily embed the ForEach Loop into a ScrollView. 

ScrollView {
    ForEach(sampleConversation, id: \.messageID) { message in
        ChatRow(message: message)
    }
}

A ScrollView embeds all contained views in a scrollable content region. Run a Live preview to test this!

Note: By default, the content of a ScrollView is displayed vertically. For a horizontal ScrollView, we would first have to wrap the ForEach loop into an HStack.

However, we have a small problem if we have more chat messages than will fit the screen. Add a few more ChatMessage instances to the sampleConversation array. 

let sampleConversation = [
    //...
    ChatMessage(messageText: "Do you have any vacation plans coming up?", username: "Another user", isMe: false),
    ChatMessage(messageText: "I'm thinking about going to Spain", username: "Me", isMe: true),
    ChatMessage(messageText: "What about you ?🤔", username: "Me", isMe: true),
    ChatMessage(messageText: "Sounds great!", username: "Another user", isMe: false),
    ChatMessage(messageText: "Thinking about flying to Sweden for christmas! 🎄", username: "Another user", isMe: false),
    ChatMessage(messageText: "I would love to go to Sweden one day!", username: "Me", isMe: true)
]

When the user now opens the app, he always has to scroll down manually to see the latest messages.

Instead, we want the ScrollView to scroll down to the newest ChatMessage automatically. 

To programmatically change the scroll position of a ScrollView, we have to embed it into a ScrollViewReader, similar to a GeometryReader. 

ScrollViewReader { scrollView in
    ScrollView {
        ForEach(sampleConversation, id: \.messageID) { message in
            ChatRow(message: message)
        }
    }
}

We can now set the scroll position using the scrollView. But first, we have to assign an id to each ChatMessage in our ForEach loop. For this, we use the index of the specific ChatMessage in the sampleConversation array.

ForEach(sampleConversation, id: \.messageID) { message in
    ChatRow(message: message)
        .id(sampleConversation.firstIndex(of: message))
}

Using the .onAppear modifier, we can now specify that the lowest chat message is shown initially.

ScrollViewReader { scrollView in
    ScrollView {
        ForEach(sampleConversation, id: \.messageID) { message in
            ChatRow(message: message)
                .id(sampleConversation.firstIndex(of: message))
        }
            .onAppear(perform: {
                scrollView.scrollTo(sampleConversation.count-1)
            })
    }
}

A glance at the Preview simulator confirms that everything works the way we want it to.

Great! We finished preparing the interface of our chat app and are now ready to use Firebase for retrieving messages from and sending messages to a real-time database. 

5 replies on “Preparing the interface”

Hi, I can’t get the scroll to move to the last written message.
I’ve only managed to move it to the second to last one by subtracting -2 from the count, instead of -1, but this way the last one remains hidden.
It doesn’t do it either when you start the app.
I had to add an onRecive, so that it scrolls when writing or receiving a new message.

Thank you

Typo: in the code sample under “…ZStack should only be 70 points high”, `.frame(height 70` is missing a closing bracket `)`

Leave a Reply

Your email address will not be published. Required fields are marked *