How Our iOS Team Built the SwiftUI SDK Message List

...

Over the years, in-app chat has evolved from a simple, text-based mechanism to a social feed-like experience replete with images, GIFs, videos, reactions, and more. But what’s required under the hood to power such an experience?

Building a Chat Message List with SwiftUI

Apart from the rich feature set, chats need to be very responsive in order to update data correctly based on real-time events, especially in larger chats, such as live streams or group messages. In addition to responsiveness, chats must be quite performant in terms of scrolling, memory usage, and performance in general.

These were challenges we had to consider when we started building the Message List UI Component for our SwiftUI SDK. This post aims to address these challenges and to show you our approach and solution to solving them.

SwiftUI: The Perfect UI Framework

As we mentioned earlier, building such an in-app chat experience presents three main challenges:

  1. Rich Media: To satisfy this challenge, ideally we would need a UI framework that enables us to declaratively define all the views and their possible states. The framework would pick up the heavy lifting for drawing these views and states on screen, too.
  2. Responsiveness: For the second challenge, we would need something reactive that can frequently redraw views based on state changes.
  3. Performance: For the third one, we would need something that has very light view definitions (like value types) that will allow us to do the redrawing without performance penalties.

Declarative, frequent redraws, value types – are you getting it? These features aren’t on a wishlist, but a real existing UI framework we all know as SwiftUI. Let's see how our iOS team used the framework to build our message list in our SwiftUI SDK.

Stream and SwiftUI

Choosing The Container View

When you analyze a message list in the most popular chat apps, you’ll notice that the newest messages are shown at the bottom of the list. As you scroll up, the older messages are loaded and displayed, similar to the endless scrolling you’re accustomed to in a social feed.

When we talk about iOS development in SwiftUI, there are several options available to match this use case:

  • The first option is to use a List and invert it 180 degrees along with the contents inside.
  • Another option is to use a ScrollView with a LazyVStack (our minimum supported version is iOS 14) and invert them, similar to the List approach.
  • Another approach would be to cheat a bit and fall back to the battle-tested UIKit solutions for displaying rich data, such as UICollectionView and UITableView.
  • And of course, you can also implement your own custom list view, which will load the newly displayed views on demand.

Since we were building a SwiftUI SDK, we decided against the UIKit options (unless absolutely necessary). We thought List and LazyVStack were the two most interesting options. So, we implemented both of them.

Performance-wise, List and LazyVStack were very similar to the initial attachment views we implemented (images, GIFs, text). The scrolling was smooth and there were no visible glitches. However, LazyVStack proved to be more flexible in terms of the many scrolling requirements we have, and we also didn't have to implement hacky solutions to remove the List separators on iOS 14.

So, we have decided to go with LazyVStack for the container view.

Being Responsive

In SwiftUI, when you want to display a list of elements in a ForEach method, your data source needs to conform to the Identifiable protocol. This protocol basically defines an ID, which can be of any type, as long as it uniquely identifies each view. We wanted to display a list of messages, which each have their own IDs that identify them, so this message ID route seemed like an obvious choice for the corresponding view, too.

However, that's not suitable for an interactive chat experience. The same message can have many different view states based on message reactions, thread replies, pinning, flagging, deleting, etc. For example, when a new reaction to a message comes, we instantly need to update the UI for that message. If it has the same ID as before, SwiftUI won't know that the state of this view has changed.

Therefore, our ID is computed on the go based on the message-id and all the possible related data (reactions, replies, etc.) that can have an impact on its view state. This ID is computed on every view redraw, but since it's computed for around 20 visible messages, it doesn't have any impact on the performance. With this mechanism in place, we help SwiftUI determine when to redraw the views that have changed.

Different image states

Being Performant

We conducted several types of performance testing to check the limits of our message list. Our initial tests were with around 8000 different messages in a chat conversation (mostly text and images). Afterward, we took it a step further and ran a load test for 20 minutes, wherein each second we had several images, texts, GIFs, and reactions sent.

This generated quite a large message list and still, the performance was good. However, in order to get to that state, we had to tackle another challenge: When analyzing the memory usage in Xcode in our initial tests, we noticed that the memory continuously increased. Instruments didn't show any obvious leakage of data in our SDK.

The next step was to determine why the memory was not released when views appeared off-screen. SwiftUI is a high-level declarative framework, which means a lot of the implementation details under the hood are not publicly available and can change over time. It also means that our explanations of its behavior in certain cases are mostly assumptions, which might or might not be correct. With that disclaimer out of the way, let's see what we think happened.

LazyVStack States

Our LazyVStack is dynamic (it has an unknown size), which depends mostly on how far the user will scroll. The data is loaded in pages, and the LazyVStack and the ForEach expand when new data is available. Most likely, SwiftUI keeps the previous state of the view still in memory, in case it needs to revert back to that state.

So for example, when you scroll up and the first 20 messages are loaded, that's one state. Then you load an additional 20, and the new LazyVStack has 40, while the one with 20 is still kept in memory in case it's needed. When the next 20 messages are coming, the LazyVStacks with 20 and 40 messages are still available as previous states that could be reused, and this grows until the LazyVStack reaches its memory limits and crashes (around 5,000 messages for an iPhone 11 Pro Max). The type of data – for example, more images and GIFs over text – didn't have much impact on this memory increase. Another thing worth noting is that the same issue happened while using a List.

Possible Solutions

So how did we solve this? We experimented with two optimizations to solve these issues: The first one was to be more aggressive with message loading. So, for example, instead of loading the initial 20, we can load the initial 100 messages. This will reduce the number of states the LazyVStack will have to keep and the memory won't increase that much. And this resulted in passing the 5,000-message threshold without crashes. But, this approach just postpones the inevitable – if the memory grows it will eventually crash again.

Therefore, we needed a way to tell SwiftUI to release the memory, since our message list will not return to its previous states. What worked for us was very simple – putting an id modifier to our ForEach iterator. On every 200th message, we set a new UUID value, which re-creates the message list without any visible glitch to the scrolling experience. After this event, the memory drops to its expected state and we can survive bigger loads of messages.

Memory graph

SwiftUI Performance Tips

Create Smaller Composable Views

Any time we add content to our message list, we must check if it has an impact on the scrolling experience. We recommend creating smaller composable views and passing them only the data they need in order to avoid unnecessary redraws.

Avoid Conditional Statements at the Root of the View Body

You should avoid adding if-statements at the root of the view's body. In our case, this was causing performance issues when we were adding a system message type (which looks different from the other types of messages).

Be Wary of Potential UIKit Component Performance Issues in SwiftUI

We noticed another interesting performance issue with our only attachment type that uses some UIKit functionality: The link preview attachment.

Since we support iOS 14, we can't use the new, awesome markdown support on the Text component. Therefore, we had to use the UITextView from UIKit for displaying highlighted links inside a message. Everything was working great, until the moment we tested on iOS 14. The scrolling was so bad, that the message list was practically unusable.

After some hardcore SwiftUI debugging (commenting out parts of the code), we figured out that setting maxWidth to .infinity was causing the issues. The assumption here is that SwiftUI has a harder time figuring out dynamic frames for UIKit components. It's something to keep in mind if you experience similar issues. Putting a Spacer instead solved the issues for us.

Conclusion

So far, SwiftUI has proved to be a great fit for our message list. When it comes to comparing SwiftUI and UIKit, SwiftUI is faster and more fun. The issues we encountered so far were all solvable; there was nothing that made us say, "OK, this doesn't work, SwiftUI is not mature yet." Of course, there are still some things that need to be improved, but the general feeling is that it can be used in production. The value it brings is too big to be ignored.

If you are interested in more details about the development of our SDK, please check our GitHub repo – everything is open-sourced. We are continuously adding new features and improving the SDK in order to provide the best possible chat experience for our users.