Creating a messenger UI in SwiftUI

SwiftUI was introduced a few months ago, with great excitement from the iOS community. Is it as great as everyone claims? Is it as easy to use as they say? Does it really enable much faster development?

To find out for myself, I decided to build something that I’ve already built before. A messaging UI!

Note: this article assumes at least a beginner level familiarity with SwiftUI.

The declarative reference frame

Before we get creative, I’d like to walk you through my thought process when developing reactive, declarative UI. It’s vastly different from the old ways of UIKit.
The first, and most glaring difference is that UI is a function of state. Literally.

view = f(state)

In order to have a View present a String, an Integer, a User, or anything at all really, it must be passed to the view somehow. When the model changes, SwiftUI will re-render the View. Thus, the only way to update a view is to update the model representing it.

It’s a stark contrast to the way iOS apps are usually created. Using models to represent views is encouraged when using UIKit, but it’s not an explicit requirement of the framework. Using tags on UIButtons to represent state is something that usually has far too many upvotes on Stack Overflow answers.

State handling

There are five common scenarios when it comes to handling state in views:
1. Read-only access to state

struct UsernameView: View {
     let username: String

     var body: some View {
          Text(username)
     }
}

2. Observing and updating state local to the View that has a reference to it

struct UsernameInputView: View {
     @State var username = ""

     var body: some View {
          TextField(username, text: $username)
     }
}

3. Observing and updating state not owned by the View that has a reference to it

struct UsernameInputView: View {
     @Binding var username: String

     var body: some View {
          TextField(username, text: $username)
     }
}

4. Observing state of a reference type object

struct UserInputView: View {
     @ObservedObject var user: User

     var body: some View {
          VStack {
               Text(user.name)
               Text(user.surname)
          }
     }
}

5. Observing and updating state that does not belong to any particular View

struct HighScoreView: View {
     @EnvironmentObject var highScore: HighScore

     var body: some View {
          Text(highScore.value)
     }
}

The sample app will only use the first two approaches due to its simplicity. In a real app, you’ll be using all of the above (and more) to handle state, depending on the particular use case and complexity.

Let’s jump right in.

Message

A messaging app wouldn’t be useful if we couldn’t send any messages. Let’s build a quick & dirty Message object:

struct Message: Hashable, Identifiable {
     enum Sender: Hashable {
          case me
          case other(named: String)
     }

     let id: Int
     let sender: Sender
     let content: String
}

It’s not much, but it’s more than enough for our use case. All we care about is that we can distinguish between messages we’ve sent and the messages we’ve received; and the contents of the message, of course. We’ll use the sender property to decide how to color our message. We need the `id` property so that SwiftUI can perform diffing and add new items to our UI.

MessageView

The core of our message view is pretty simple as well:

struct MessageView: View {
     let message: Message

     var body: some View {
          Text(message.content)
               .multilineTextAlignment(.leading)
               .fixedSize(horizontal: false, vertical: true)
               .padding(.horizontal, 12)
               .padding(.vertical, 4)
               .background(message.sender == .me ? Color.blue : Color.gray)
               .foregroundColor(message.sender == .me ? Color.white : Color.black)
               .cornerRadius(16)
     }
}

It’s a bit of text with some padding, coloring, and a bit of corner radius. The fixed size modifier is there to help us layout the message once we put it in a scroll view, as the height of the message view would be ambiguous without it.

If we put a Lorem Ipsum message into our fancy message view, here’s what we would get:

Ain’t that pretty?

Let’s add some spacing to make it clear who’s sending which messages.

struct MessageView: View {
     let message: Message

     var body: some View {
          HStack {
               if message.sender == .me {
                    Spacer()
               }

               Text(message.content)
                    .multilineTextAlignment(.leading)
                    .fixedSize(horizontal: false, vertical: true)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 4)
                    .background(message.sender == .me ? Color.blue : Color.gray)
                    .foregroundColor(message.sender == .me ? Color.white : Color.black)
                    .cornerRadius(16)

               if message.sender != .me {
                    Spacer()
               }
          }
     }
}

If we were to run our app again with the same Lorem Ipsum text, we wouldn’t see much of a change. The message view would move a tiny bit to the right. We’ll see the real benefit once we put our messages into a scroll view. Speaking of which…

Many messages

You might be wondering why I’ve talked only about scroll views when SwiftUI has a pretty easy to use List View? It’s simple. The separators of a List couldn’t be removed at the time of writing.

What’s the alternative to a List? Well, what is a List?
A series of items. Luckily, there’s a View just for that – the ForEach.

Let’s create the base of our Messages list view:

struct MessageListView: View {
     let messages: [Message]

     var body: some View {
          ForEach(self.messages) { message in
               MessageView(message: message)
                    .padding(.horizontal, 8)
          }
     }
}

If we now pass some mock messages to our Message List View, we’d get this:

That’s looking good. Don’t worry, we’ll fix the dark text on a gray background later. What we’ll need to do right now is to add some horizontal padding for our messages – we don’t want any message to take up more than half of the width of the screen.

If we add this modifier to our Message View in our Message List view we’ll get some padding:

.padding(EdgeInsets(
     top: 0,
     leading: message.sender == .me ? 64 : 0 ,
     bottom: 0,
     trailing: message.sender == .me ? 0 : 64))

However, that’s not exactly half of the screen width. We can’t even know what half of the screen width is, as it can change per device, per orientation.

If we want to get precise, we need to use a GeometryReader. When we wrap a view inside of a GeometryReader, we’ll know exactly how tall and wide that view is.

Replace the `body` of the Message List View with this:

GeometryReader { geometry in
     ForEach(self.messages) { message in
          MessageView(message: message)
               .padding(.horizontal, 8)
               .padding(EdgeInsets(
                    top: 0,
                    leading: message.sender == .me ? geometry.size.width * 0.5 : 0 ,
                    bottom: 0,
                    trailing: message.sender == .me ? 0 : geometry.size.width * 0.5))
     }
}

And…

The good news is that our messages do indeed get correct padding. The bad news is that their position is always 0, which makes them render on top of each other. The other good news is that it doesn’t matter, because once we wrap up our ForEach in a Scroll View…

GeometryReader { geometry in
     ScrollView {
          ForEach(self.messages) { message in
               MessageView(message: message)
                    .padding(.horizontal, 8)
                    .padding(EdgeInsets(
                         top: 0,
                         leading: message.sender == .me ? geometry.size.width * 0.5 : 0 ,
                         bottom: 0,
                         trailing: message.sender == .me ? 0 : geometry.size.width * 0.5))
          }
     }
}

The issue disappears. Plus, we get to scroll through our message, which is kind of what we wanted to achieve anyway.

At this point, the base for our UI is finished. We can change the colors, add some dynamic gradient backgrounds, add support for media embeds, and more. Here’s a taste of what’s possible:

<span data-mce-type="bookmark" style="display: inline-block; width: 0px; overflow: hidden; line-height: 0;" class="mce_SELRES_start"></span>

What was changed? Tightened spacing between messages of the same sender, an arrow pointing to the sender’s direction, a navigation view, and a way to actually “send” messages.

Where to go from here?

Check out the Github Repo to find out how the app above was implemented.

For fun’s sake, see how hard it would be to add image support to the code above.
Spoiler alert: not too difficult.

Closing thoughts

SwiftUI allows for far faster development by simplifying core concepts. However, that simplification does come at a cost of flexibility and power. For example: there is no simple way to observe a ScrollView’s contentOffset, and there is no simple way to set a contentOffset of a ScrollView. The error messages are also not helpful at times.
The framework is not mature enough, which is to be expected, as it was introduced not three odd months ago. These issues will be fixed with time.

UIKit on the other hand is mature and battle tested. Developing with it has its known quirks, but it is stable once you get to know it. The power of UIKit lies in the hands of the programmer.

My development speed is nearly doubled just by using SwiftUI. Adding new features to old code is also straight forward, as the framework itself makes you want to split out your code into components as soon as it starts to get complex. I will gladly make use of SwiftUI in personal projects, even with all of its quirks.