DEV Community

Cover image for Creating a chat screen in SwiftUI
marinbenc🐧 for CometChat

Posted on • Updated on • Originally published at cometchat.com

Creating a chat screen in SwiftUI

A chat app wouldn't be a chat app without a chat screen. In this part of the SwiftUI course, you'll put everything you've learned so far together, and build a cool looking chat screen.

Here's what you'll build:

Making a SwiftUI chat screen

The chat screen consists of two main views: a list of messages and a text field. Each message contains the user's avatar and a text bubble displaying the message. The text area holds a text field and a send button side-by-side.

Looking at this screen, it might seem like a daunting task to build it. Don't worry! We'll tackle it piece-by-piece. Once you put those pieces together, you'll be surprised at how great everything turned out! Let's get going.

As always, you can find a link to the finished project code on GitHub.

Modifying the avatar

Earlier in the course, you created an avatar view that you can reuse across your app. Now, it's time to reap the benefits of that investment. Instead of recreating a whole new view, you'll slightly tweak the avatar view to fit the needs of the chat message rows.

You'll change the avatar view so that the online indicator can be hidden. Open AvatarView.swift and add a new property to the class:

private let showsOnlineStatus: Bool

You'll use this variable to determine whether or not the online badge will be shown. Set this property to true at the bottom of a new initializer that you'll add to the class:

init(url: URL?, isOnline: Bool) {
  self.url = url
  self.isOnline = isOnline
  showsOnlineStatus = true
}

Next, add another initializer to the class, this time without isOnline:

init(url: URL?) {
  self.url = url
  self.isOnline = false
  showsOnlineStatus = false
}

You will call this initializer when you don't need to know if the user is online or not.

Finally, make a few changes to body to only show the online badge if showsOnlineStatus is set to true:

var body: some View {
  ZStack {
    Image("avatar_placeholder0")
      .resizable()
      .frame(width: 37, height: 37)

    // Wrap the Circle in an if block:
    if showsOnlineStatus {
      Circle()
        .frame(width: 10, height: 10)
        .foregroundColor(isOnline ? .green : .gray)
        .padding([.leading, .top], 25)
    }
  }
}

With that in place, you can get started on creating the message item.

I am creating this course in collaboration with CometChat, a modern chat platform to help you add chat to you Swift app. During the next few weeks, we’ll be releasing installments of our free SwiftUI course here on dev.to! In the course, you’ll dive deep into SwiftUI by building a real-world production-scale chat app, learning SwiftUI in a practical way, on a scale larger than a simple example app. Follow me to get notified of future parts of this course! You can also follow @CometChat on Twitter see the course of CometChat’s blog.

Creating the view for chat messages

By the end of this section, you'll create a view that shows each message the user sends or receives.

Making a SwiftUI chat screen

When a user sends a message, it will have a blue background and be aligned to the right. If the user receives a message, it will have a white background and come from the left. When you receive or send a couple of messages, only the last one of those will show the avatar. Let's get started!

First, you'll create a new model struct for you messages. Crate a new plain Swift file and name it Message.swift. Add the following to the file:

import Foundation

struct Message: Identifiable {
  let id: Int
  let text: String
  let contact: Contact
}

Just like Contact, you'll make Message identifiable, so that you can later display it in a list view. Now you can start creating the view that shows this message.

Create a new SwiftUI view file and name it ChatMessageRow.swift. Change the struct to the following:

struct ChatMessageRow: View {

  let message: Message
  let isIncoming: Bool
  let isLastFromContact: Bool

}

The struct has three properties. One is the message you'd like to show. The second property determines if the message is incoming or if it was sent out from the currently logged in user. The final property determines if this message was last in a list of multiple messages from a single contact.

[TODO: Screenshot]

The latter two properties will determine the look of this view.

Displaying the content of the message

Instead of having a 300-lines-long body behemoth, you'll make this view piece by piece. You'll create a few computed variables where you'll return parts of the view and then combine those parts in body.

The first part will be the most important part of a message: the content. Add the following computed property to the bottom of the class:

private var text: some View {
  Text(message.text)
    .padding(10)
    .modifier(BodyText())
}

Just like body, you can create computed properties that return some View, and use this pattern to clean up your code so that it's more readable. For now, the text is a simple Text with some padding and styling applied.

Next, fill up body with a horizontal stack containing the avatar view and the text:

var body: some View {
  HStack(alignment: .bottom, spacing: 0) {
    AvatarView(url: message.contact.avatar)
    text
    Spacer()
  }
}

Alongside the two views in the stack, you add a spacer so that everything is aligned to the left. By setting the stack's alignment to .bottom, you make sure the items start at the bottom of the stack.

Before I show you what this looks like, let's modify the preview a bit. Scroll down to the PreviewProvider and add the following property to the struct:

private static let chatMessage = Message(
  id: UUID().hashValue,
  text: "Pellentesque ipsum. Mauris elem enes tumen mauris vitae tortor. Pellentesque ipsum.",
  contact: Contact(name: "Name", avatar: nil, id: "id", isOnline: true))

This is a dummy message that you'll use to preview the view. Next, modify previews to show three different permutations of what your view can look like:

static var previews: some View {
  Group {
    ChatMessageRow(
      message: chatMessage,
      isIncoming: true,
      isLastFromContact: true)
      .previewLayout(.fixed(width: 300, height: 200))

    ChatMessageRow(
      message: chatMessage,
      isIncoming: true,
      isLastFromContact: false)
      .previewLayout(.fixed(width: 300, height: 200))

    ChatMessageRow(
      message: chatMessage,
      isIncoming: false,
      isLastFromContact: true)
      .previewLayout(.fixed(width: 300, height: 200))
  }
}

You create one outgoing, one incoming, and one message row where the message is not the last message from that contact. Take a look at the preview to see your work so far:

Creating the message item of a SwiftUI chat UI

That's a good start! You're showing the avatar and the message, but you're missing the bubble around the message.

Showing a chat bubble

To show the chat bubble, you'll use a rounded rectangle view that you'll set as the background of the text view.

To start, add another computed property to ChatMessageRow, right above text:

private var chatBubble: some View {
  RoundedRectangle(cornerRadius: 6)
    .foregroundColor(.white)
    .shadow(color: .shadow, radius: 2, x: 0, y: 1)
}

Remember the shape views I mentioned earlier in the course? Another one of those is RoundedRectangle, perfect for this use case: A chat bubble.

You'll set this chat bubble as the background of the text. Inside text, call the background method with the chat bubble:

Text(message.text)
  .padding(10)
  .modifier(BodyText())
  .background(chatBubble)

In SwiftUI, backgrounds can be infinitely complex. In this case, you're setting it to a rounded rect. You can also set it to colors, image views, other shape views or any other SwiftUI view!

Making a chat bubble in SwiftUI

The text now has a background, but to make it a true speech bubble it needs a tail from the user's avatar to the message, like in comic books.

To add this, you'll create a triangular view that you'll place between the avatar and the message in the stack. Add a new function to the struct:

private func chatBubbleTriange(
  width: CGFloat, 
  height: CGFloat, 
  isIncoming: Bool) -> some View {

}

This function receives a width and a height for the chat bubble tail, as well as whether the message is incoming or not. You'll use this information to construct the bubble and return it from the function.

Next, add the following code to the function:

Path { path in
  path.move(to: CGPoint(x: 0, y: height * 0.5))
  path.addLine(to: CGPoint(x: width, y: height))
  path.addLine(to: CGPoint(x: width, y: 0))
  path.closeSubpath()
}
.fill(Color.white)
.frame(width: width, height: height)
.shadow(color: .shadow, radius: 2, x: 0, y: 1)

You use a Path view to construct the triangle. Path is a special type of view that lets you create any arbitrary shape that you can think of.

If this sounds complex, don't worry. Path has a bunch of helper methods to construct shapes like straight lines, arcs, curves, rectangles, ellipses and more. By combining those simpler shapes, you can end up with shapes that would make Kandinsky jealous.

When constructing a path, think of it like drawing with an imagined pen. You move the pen to its start position by calling move(to:). Once it's in position, you can press it down and draw by calling one of the addX methods, where X can be anything from line, arc, curve and other shapes.

In this case, you first move the path to the center-left position of the view. You then draw a line to the top-right corner and then draw a line to the bottom-right corner. Finally, you call closeSubpath which closes the path by drawing a line back to the start position.

Now that you have the tail, add it to body:

var body: some View {
  HStack(alignment: .bottom, spacing: 0) {
    AvatarView(url: message.contact.avatar)

    chatBubbleTriange(width: 15, height: 14, isIncoming: isIncoming)

    text

    Spacer()
  }
}

Making a chat bubble in SwiftUI

The shape is good, but the view needs some tweaks. We need to trick the user into thinking the tail is a part of the message bubble. Add the following method calls to the bottom of chatBubbleTriangle:

.zIndex(10)
.clipped()
.padding(.trailing, -1)
.padding(.leading, 10)
.padding(.bottom, 12)

You modify the tail's z-index so that it's above the chat message. You then clip the view so that there's no shadow at the right edge. Finally, you adjust the padding so that it's overlapping with the chat message by one point.

Making a chat bubble in SwiftUI

Now it looks like a real speech bubble! This makes you one step closer to a real app since no chat app would be complete without speech bubbles. Let's tweak them a little bit more.

Adding color to the speech bubbles

Next, you'll color outgoing messages blue, and incoming messages white. Start by changing the text color in the text computed variable, so that it's visible on both backgrounds:

Text(message.text)
  .padding(10)
  .foregroundColor(isIncoming ? .body : .white)
  .modifier(BodyText())
  .background(chatBubble)

Make a similar modification to the chat bubble, except you can color it blue if the message is outgoing:

private var chatBubble: some View {
  RoundedRectangle(cornerRadius: 6)
    .foregroundColor(isIncoming ? .white : .cometChatBlue)
    .shadow(color: .shadow, radius: 2, x: 0, y: 1)
}

Finally, you need to match the tail's color to the bubble. In chatBubbleTriangle, replace the first method call to fill with the following line:

.fill(isIncoming ? Color.white : Color.cometChatBlue)

The preview now shows a blue message when it's not incoming. Nice!

Making a chat bubble in SwiftUI

Chaining messages

As we discussed earlier, if the same user sends a couple of messages, you'll only show the avatar at the last message. For other messages, you'll show a spacer of the same width as the avatar in its place.

Modify body to add this change:

var body: some View {
  HStack(alignment: .bottom, spacing: 0) {
    if isLastFromContact {
      AvatarView(url: message.contact.avatar)
      chatBubbleTriange(width: 15, height: 14, isIncoming: isIncoming)
    } else {
      Spacer().frame(width: 61)
    }
    text
    Spacer()
  }
}

If the message is last from this contact, you show the avatar and the tail. Otherwise, you show a blank space. Earlier you learned that spacers take up as much space as they can. By modifying the spacer's frame, you make sure it has a fixed width.

Making a chat bubble in SwiftUI

Now, only the last message in a chain shows the avatar. We're almost there!

Reversing a horizontal stack in SwiftUI

One final thing you need to tweak is to align outgoing messages to the right. To do this, you'll need to reverse the order of items in the HStack, as well as flip the chat bubble's tail.

You'll start by flipping the tail. Since you built it as a path, you can reverse the x coordinates of the path to mirror the triangle.

In chatBubbleTriangle, reverse the x-axis of the path if the message is outgoing by replacing the Path initializer with the following code:

Path { path in
  path.move(to: CGPoint(x: isIncoming ? 0 : width, y: height * 0.5))
  path.addLine(to: CGPoint(x: isIncoming ? width : 0, y: height))
  path.addLine(to: CGPoint(x: isIncoming ? width : 0, y: 0))
  path.closeSubpath()
}

You'll also need to reverse the padding by replacing the last three lines of the function with the following:

.padding(.trailing, isIncoming ? -1 : 10)
.padding(.leading, isIncoming ? 10 : -1)
.padding(.bottom, 12)

Now your tail is sufficiently flipped.

Making a chat bubble in SwiftUI

Next, you'll move on to the stack view by reversing the order of the items in the stack.

Right now, there's no easy way to do this in SwiftUI. The way to do this is to add an if check inside the stack, and repeat the views in the reverse order.

HStack(alignment: .bottom, spacing: 0) {
  if isIncoming {

    if isLastFromContact {
      AvatarView(url: message.contact.avatar)
      chatBubbleTriange(width: 15, height: 14, isIncoming: true)
    } else {
      Spacer().frame(width: 61)
    }
    text
    Spacer()

  } else {

    Spacer()
    text
    if isLastFromContact {
      chatBubbleTriange(width: 15, height: 14, isIncoming: false)
      AvatarView(url: message.contact.avatar)
    } else {
      Spacer().frame(width: 61)
    }
  }
}

Not the cleanest solution, but it's the best we can do right now.

Making a chat bubble in SwiftUI

Your messages are now aligned to the right if they're coming from the current user. This concludes creating the message row. You can now take a small break by creating the text field. Or, take a small break, and then create the text field. :)

Creating the chat text field

A chat screen is two things: The messages and a text field. In this section, you'll make a nice looking text field to let the user type in their messages.

Before we get started, you'll need to download another image. Download the send icon from here. Once downloaded, drag and drop the image inside you Assets.xcassets file and name it send_message.

Start creating the text field by creating a new SwiftUI View file called ChatTextField.swift. Add two properties and a method to the struct:

struct ChatTextField: View {

  let sendAction: (String) -> Void

  @State private var messageText = ""

  private func sendButtonTapped() {
    sendAction(messageText)
    messageText = ""
  }

}

The text field will receive a callback to call when the send button gets tapped. It will track the entered text inside a state variable, and pass the text via the callback back to the main chat screen.

Next, modify the preview so that it shows the text field with a bit of spacing on the top:

static var previews: some View {
  VStack {
    Spacer()
    ChatTextField(sendAction: { _ in })
  }.previewLayout(.fixed(width: 300, height: 80))
}

Now you can start working on the view. Inside body, add a horizontal stack that contains a text field and a button to send the message.

var body: some View {
  HStack {
    TextField("Type something", text: $messageText)
      .frame(height: 60)

    Button(action: sendButtonTapped) {
      Image("send_message")
        .resizable()
        .frame(width: 25, height: 25)
        .padding(.leading, 16)
    }
  }
  .padding([.leading, .trailing])
  .background(Color.white)
}

You fix the text field's size to 60 points. In the button, you show the send icon and make sure its size is 25 by 25 points. Finally, you add a bit of padding to the whole stack and give it a white background.

Creating a chat text field in SwiftUI

You should do one final modification by adding a shadow to the top of the view. Because you want the shadow only on top, you'll add a new thin rectangular view to the top, and apply a shadow to that rectangle.

var body: some View {
  VStack(spacing: 0) {
    Rectangle()
      .frame(height: 1)
      .foregroundColor(.white)
      .shadow(color: .shadow, radius: 3, x: 0, y: -2)

    HStack {
      ...
    }
    .padding([.leading, .trailing])
    .background(Color.white)
  }
  .frame(height: 60)
}

To add the rectangle, you first wrap the whole view inside of a vertical stack. You add the rectangle to the top of the stack and apply a shadow to the rectangle. You'll also set the VStack's frame to be 60 points high.

Creating a chat text field in SwiftUI

Looking good! You now have a text field and a view for each message. It's time to assemble them!

Putting it all together in a chat view

Now, it's time to cash in all of your hard work so far, and put together the pieces to form a great looking chat view.

Create a new SwiftUI view file named ChatView.swift. Add the following properties to the struct:

struct ChatView: View {

  let currentUser: Contact
  let receiver: Contact

}

The chat view will receive two users: One that's currently logged in, and one that the user will chat with.

You'll need to modify the previews to pass values for these two properties:

static var previews: some View {
  ChatView(
    currentUser: Contact(name: "Me", avatar: nil, id: "me", isOnline: true),
    receiver: Contact(name: "Other", avatar: nil, id: "other", isOnline: true))
}

Next, scroll back to the ChatView struct and add the following state property:

@State private var messages: [Message] = [
  Message(id: 0, text: "Morbi scelerisque luctus velit", contact: Contact(name: "Name", avatar: nil, id: "id", isOnline: true)),
  Message(id: 1, text: "Pellentesque ipsum. Mauris elem enes tumen mauris vitae tortor. Pellentesque ipsum. ", contact: Contact(name: "Name", avatar: nil, id: "id", isOnline: true)),
  Message(id: 2, text: "Phasellus enim erat esi, vestibulum veles?", contact: Contact(name: "Name", avatar: nil, id: "me", isOnline: true)),
  Message(id: 3, text: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", contact: Contact(name: "Name", avatar: nil, id: "id", isOnline: true)),
  Message(id: 4, text: "Mauris tincidunt sem", contact: Contact(name: "Name", avatar: nil, id: "me", isOnline: true)),
]

These are the messages you'll show on the screen. Right now, you're hard-coding them in the struct. Later, you'll be listening to a WebSocket channel and adding messages to this array as they come in — but that's in a later part of this course.

Now you can add a list of messages to the view. Add a body to the struct:

var body: some View {
  List {
    ForEach(0..<messages.count, id: \.self) { i in
      ChatMessageRow(
        message: self.messages[i],
        isIncoming: self.messages[i].contact.id != self.currentUser.id,
        isLastFromContact: true)
    }
  }
}

As you learned earlier in the course, you combine a ForEach with a List to create a list of ChatMessageRows. You know the message is incoming if the message's sender is different from the current user.

Removing separators from a SwiftUI List

Looks like you're having issues with the table view's separators. You've already learned a trick to deal with these: Using UIAppearance. Add an initializer to the top of the struct:

init(currentUser: Contact, receiver: Contact) {
  self.currentUser = currentUser
  self.receiver = receiver

  UITableView.appearance().tableFooterView = UIView()
  UITableView.appearance().separatorStyle = .none
  UITableView.appearance().backgroundColor = .clear
  UITableViewCell.appearance().backgroundColor = .clear
}

In the initializer, you use the global appearance proxies for the table view to remove the separators. You also remove all background colors from both the table view and the cells. This lets you change the background color in SwiftUI.

Removing separators from a SwiftUI List

Remember that the chat message looks different for chained messages? You'll have to add a way to determine if each message was the lest message sent by a user or just another message in the chain. Add the following method to the struct:

private func isMessageLastFromContact(at index: Int) -> Bool {
  let message = messages[index]
  let next = index < messages.count - 1 ? messages[index + 1] : nil
  return message.contact != next?.contact
}

You fetch the message at that index, as well as the next message. If the next message has a different sender, the current message is the last in the chain.

Next, pass the result of this function to each chat message row. You'll also use this function to modify the spacing between the rows.

List {
  ForEach(0..<messages.count, id: \.self) { i in
    ChatMessageRow(
      message: self.messages[i],
      isIncoming: self.messages[i].contact.id != self.currentUser.id,
      isLastFromContact: self.isMessageLastFromContact(at: i))
      .listRowInsets(EdgeInsets(
        top: i == 0 ? 16 : 0,
        leading: 12,
        bottom: self.isMessageLastFromContact(at: i) ? 20 : 6,
        trailing: 12))
  }
}

If the message is the last in a chain, you'll add a larger spacing to the bottom. You'll also add a bigger top spacing to the first row in the list so that it doesn't bump into the navigation bar.

Making a SwiftUI chat screen

The list is looking great now! Let's move on to adding the text field you created earlier to the view.

Start by creating a new function that will get called when the text field's send button is tapped:

private func onSendTapped(message: String) {
  // TODO: Send message
}

Next, wrap body in a VStack and add the text field to the bottom of the stack:

VStack {
  List {
    ...
  }

  ChatTextField(sendAction: onSendTapped)
}

When wrapping views inside a stack, Xcode gives you several tricks you can use. If you Command-Click the view, you can select Embed in VStack to wrap a VStack around that view. If you decide to do it manually, by selecting a piece of text and hitting Control-i Xcode will reindent the text for you. Pretty nifty!

Making a SwiftUI chat screen

Since you made a reusable text field, this one line of code is all you need to add the text field to the view. Good job, you!

Changing the background of a SwiftUI List

Finally, you'll add a background to the screen. Previously, you used the background method to color a view. Another way to do this is to use a ZStack. After all, a ZStack stacks views one on top of the other. If the top view has a clear background, the bottom view will show through and act as a background.

Add the whole body to a ZStack, where you show the background color at the bottom of the stack:

ZStack {
  Color.background.edgesIgnoringSafeArea(.top)

  VStack {
    List {
      ...
    }

    ChatTextField(sendAction: onSendTapped)
  }
}

In SwiftUI, Color is not only a struct that represents a color. It can also be used as a standalone view by itself! You use this fact to add a background color to the ZStack. Because you set the table view's and the cells' background color to .clear, the background shows through them.

Making a SwiftUI chat screen

Conclusion

Our chat app is starting to take shape! We now have a way to log in, pick a person to chat with and, finally, a way to chat with them. At least, in theory.

The remainder of this course will deal with less visual aspects of your app. The next section will deal with propagating state and data through your app and sharing data between SwiftUI views. Once you master that, you'll move on to networking and hooking your app up to the Internet.

So, unless you want to keep chatting with dummy users forever, keep reading! :)

Top comments (0)