February 12, 2020

Networking in SwiftUI (7/7)

By now, you did all there is do with your app's UI. You have a way to log in, show contacts and display incoming messages. But one crucial bit is missing: Your app is not connected to anything.

In this final part of the SwiftUI course, you'll breathe life into your app by adding networking to send and receive messages to a web server!

To help you, you'll use CometChat Pro. CometChat Pro is a cross-platform chat SDK that lets you build chat apps that work on iOS, Android and the web at the same time. It takes care of storing, receiving and sending messages to your users' devices. There's no need to deal with servers, backend code or WebSockets.

You can find a link to the finished project code on GitHub.

Installing CometChat

Before you can use CometChat, you'll first need to install the SDK as a dependency. To do this, you'll use CocoaPods, a dependency manager for iOS.

Note: If you don't have CocoaPods installed, enter the following command in Terminal:

sudo gem install cocoapods

Open Terminal and, using cd, navigate to your project's root folder. (The one that contains the .xcodeproj file.)

Enter the following command:

pod init

This will initialize CocoaPods for your project and create a new file called Podfile. Open this file in your favorite text editor, and update its contents to the following:

target 'CometChat' do
use_frameworks!
pod 'CometChatPro', '~> 2.0.5'
end

Save the file and head back to Terminal to enter the following command:

pod install

This will install CometChat into your app, and create a new file with the .xcworkspace extension. Close any Xcode projects you might have active, and open the .xcworkspace file. From this point on, you'll use the workspace, instead of the project file, to develop your app.

Setting up

Now that CometChat is in the app, let's begin using it. First, you'll create a CometChat account — don't worry, it's free!

Head over to CometChat's registration page and sign up. Once you sign up, you'll get taken to a dashboard. Create a new app under Add New App. Select USA as the region and enter CometChat as the app's name. Click the + button to create the app.

Creating a CometChat account

After 15 or so seconds, you'll have an up-and-running chat service online! All you need to do is connect to it.

Open your new app's dashboard by clicking Explore. Head over to API Keys in the sidebar. Take a look at the keys names API Key with full access scope. Note down the API Key and App ID — you'll need these in a second.

Your CometChat API key and App ID

Back in Xcode, create a new plain Swift file named ChatService.swift. This file will be the bridge between CometChat's SDK and your app. All SDK-related code will go in this file.

Add the following contents to the file:

import Foundation
import Combine
import CometChatPro

extension String: Error {}

Since we're working with cool new tools like SwiftUI, it makes sense to also build ChatService using Combine. So, you'll import Combine as well as CometChat's SDK.

Next, create the ChatService class:

class ChatService {

private enum Constants {
#warning("Don't forget to set your API key and app ID here!")
static let cometChatAPIKey = "API_KEY"
static let cometChatAppID = "APP_ID"
}

}

You declare this class and an enum to hold some necessary constants. These will be your CometChat app ID and API key. Make sure to replace the placeholders with the values you saw earlier in the online dashboard.

Next, add a static function to the class that will initialize CometChat:

static func initialize() {
let settings = AppSettings.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(region: "us")
.build()

_ = CometChat(
appId: Constants.cometChatAppID,
appSettings: settings,
onSuccess: { isSuccess in
print("CometChat connected successfully: \(isSuccess)")
}, onError: { error in
print(error)
})
}

This sets up CometChat so that it works with your app. Make sure that the region matches the one you set in the online dashboard (us and eu, respectively).

You'll call this function whenever your app launches. The perfect place for this is in SceneDelegate.swift. Add the following line to the top of scene:

ChatService.initialize()

Now CometChat will get initialized when a user starts your app.

There's one more step to finish your setup. To use ChatService, your views need a way to access it. Thankfully, you already created an object that you add to your root view's environment — AppStore.

Head to AppStore.swift and add a new property to the nested AppState struct:

struct AppState {
var currentUser: Contact?
let chatService: ChatService
}

Next, modify the declaration of state to include ChatService:

@Published private(set) var state = AppState(
currentUser: nil,
chatService: ChatService())

Now, any view can fetch the ChatService by getting it from the AppStore environment object. Convenient!

Run the app and check the console. You should see a message saying that you connected to CometChat. It lives!

Logging in

With our setup done, it's time to move onto some real features. We'll start with logging in. You already implemented the UI for this, all that's left is telling CometChat a user wants to log in.

Open up ChatService.swift and add a new property:

private var user: Contact?

This property will store the currently logged in user. Because there's one shared ChatService instance in the environment, all views will have access to the same user.

CometChat has its own User class, but you won't be using this in your app. Instead, you'll add a way to convert CometChat's Users to Contacts.

Open Contact.swift and import CometChat at the top of the file:

import CometChatPro

Next, add the following extension to the bottom of the file:

extension Contact {
init(_ cometChatUser: User) {
self.id = cometChatUser.uid ?? "unknown"
self.name = cometChatUser.name ?? "unknown"
self.avatar = cometChatUser.avatar.flatMap(URL.init)
self.isOnline = cometChatUser.status == .online
}
}

This initializes a Contact with a CometChat User instance.

Combine's Futures and Promises

Next, get back to ChatService.swift and add a method to log the user in:

func login(email: String) -> Future<Contact, Error> {

}

You might be wondering, what the hell is a Future? If you're coming from React or a similar JavaScript-based framework, you might be familiar with a thing called promises. Promises and futures are terms that are used in different ways in different languages, but they generally achieve the same thing: Replacing callbacks with a more sane programming model.

Future is a Combine publisher that will eventually produce a single value. For instance, if you're making a network request to fetch a user, you can represent that value as Future<User, Error>. Right now, there's no value, but in the future, there'll be a user or an error.

Put another way, a Future promises a value. That's why promises and futures are often used interchangeably.

Futures are usually a replacement for callbacks. Before Combine, each network request had a callback function that it would call when the request completes. Instead of returning a value, the functions would receive a callback.

Futures allow you to return a value (a future) right away — even if the data isn't yet loaded. This gives the caller more flexibility. They can get the future and do whatever they want with it: Attach callbacks, perform transformations with map or compactMap, cancel the request mid-way through, ignore the result and so on.

I think it's safe to say futures are the future of asynchronous code in Swift.

Creating a Future

Let's get back to login. Since login returns a Future, you'll have to create one in the function. Add the following to login:

return Future<Contact, Error> { promise in

}

Here, you'll immediately return a Future from the function. A Future is initialized with a closure. This closure has one argument: A function called a promise. The promise is a function that you'll call when you get the data you want to load.

To sum up: Future is a struct that gets initialized with a closure. This closure is a function that takes another function as its argument called a promise. Within the closure, you'll call this promise to push data through the promise to the caller.

It may sound complex, but after a few goes at creating futures, you'll get the hang of it!

Add the code to login inside Future's closure:

// 1
CometChat.login(
UID: email,
apiKey: Constants.cometChatAPIKey,
onSuccess: { [weak self] cometChatUser in

// 2
guard let self = self else { return }
self.user = Contact(cometChatUser)
DispatchQueue.main.async {
// 3
promise(.success(self.user!))
}
},
onError: { error in

// 4
print("Error logging in:")
print(error.errorDescription)
DispatchQueue.main.async {
promise(.failure("Error logging in"))
}
})

Here's what's going on in the closure:

  1. You call CometChat's login function and pass it the user's unique ID (email) and the app's API key.
  2. If CometChat manages to log the user in, you'll convert the user to a Contact and set the property you created earlier to store the user.
  3. After dispatching back to the main queue, you'll call promise with a successful value. This notifies anyone listening to the Future that new data has arrived.
  4. On the other hand, if something goes wrong, you'll call promise with an unsuccessful value, giving anyone listening an error.

Using Futures

Now you can hook up your login UI with the function you just created. Open up LoginView.swift and add an import to the top of the file:

import Combine

Next, add the following property to the struct:

@State private var subscriptions: Set<AnyCancellable> = []

You've already learned about cancellables. Here, you declare a set to hold all of LoginView's subscriptions. Since LoginView is a struct (and thus immutable), you'll need to mark the set as @State before you can add new items to the set.

Next, modify login so that it calls the function you created in ChatService:

func login() {
store.state.chatService.login(email: email)
.sink(receiveCompletion: { error in
print(error)
}, receiveValue: { user in
self.store.setCurrentUser(user)
self.showContacts = true
})
.store(in: &subscriptions)
}

login returns a Future, so you call that future's sink method. sink is not specific to futures, though. It's the way you attach closures to Combine Publishers. Whenever the publisher publishes a new event, the closure you provide in sink gets called.

In this case, you provide two closures: One for a failed, and one for a successful result. On failure, you'll print out the error. If the user is logged in successfully, however, you'll set the current user on the AppStore and initiate a transition to the contacts screen. Finally, you'll store the cancellable inside the set you added to the struct.

Build and run the app and try to log in. If you try to use your email you'll get an error. This is expected — your email doesn't exist in the database of users! Thankfully, there are some values you can use to test out your chat app.

CometChat gives you five default users with user IDs superhero1 through superhero5. You can see these users if you navigate to the Users page on CometChat's online dashboard.

Enter superhero1 as the email. This is Iron Man's top-secret username, so don't share it with anyone!

Logging in with CometChat in SwiftUI

If everything goes correctly, you'll get sent to the contacts screen. This means your app logged in to CometChat! You're successfully communicating with an online chat service.

The contacts you see are still fake, though. Let's take care of that.

Fetching Contacts from CometChat

CometChat stores each registered contact for you. This means you can easily fetch all existing users to display them on the contacts screen.

Head back to ChatService.swift and add the following code to the class:

private var usersRequest: UsersRequest?
func getUsers() -> Future<[Contact], Error> {
usersRequest = UsersRequest.UsersRequestBuilder().build()

}

You create a UsersRequest and store a reference to it. You do this using a UsersRequestBuilder: It allows you to configure your request in different ways. For instance, you can only fetch the user's friends, hide blocked users, paginate the request and so on.

Next, you'll create another future and use the request to fetch all users in your app:

return Future<[Contact], Error> { promise in
self.usersRequest?.fetchNext(
onSuccess: { cometChatUsers in
let users = cometChatUsers.map(Contact.init)
DispatchQueue.main.async {
promise(.success(users))
}
},
onError: { error in
let message = error?.errorDescription ?? "unknown"
promise(.failure(message))
print("Fetching users failed with error:")
print(message)
})
}

If the request returns successfully, you'll convert each user to a Contact and pass all the users to the caller. Otherwise, if something goes wrong, you'll print out an error.

Let's call this function from ContactsView.swift. First, import Combine in the file:

import Combine

Next, add a set of cancellables to the struct:

@State private var subscriptions: Set<AnyCancellable> = []

Then, add the following function:

private func getContacts() {
store.state.chatService.getUsers()
.sink(receiveCompletion: { error in
print(error)
}, receiveValue: { contacts in
self.items = contacts.map {
ContactRow.ContactItem(
contact: $0,
lastMessage: "",
unread: false)
}
})
.store(in: &subscriptions)
}

Here, you use sink to respond to the future as you did earlier. In this case, when you get users you'll set them into your state variable, letting the view update itself. If you get an error, you'll print it to the console.

Next, call this method when the view appears by adding the following line to the bottom of body:

.onAppear(perform: getContacts)

Finally, change the declaration of items to remove hard-coded contacts:

@State private var items: [ContactRow.ContactItem] = []

You won't be needing these anymore. Run the app, log in as superhero1 and move on to the contacts screen. Instead of seeing hard-coded contacts, you'll see the users CometChat created for you, including Captain America, Spiderman and even a few of the X-Men!

Populating a SwiftUI list from the network

Updating a contact's online status in real-time

Currently, all of your contacts are showing as offline. There are two reasons for that: One, nobody else is using your app yet. But, more importantly, you also haven't implemented a way to track their online status. CometChat tracks this and notifies you when they come online. You'll use that to update your views.

Converting delegates into Combine publishers with passthrough subjects

CometChat uses the delegate pattern to notify you of changes to a contact's online status. This is a useful pattern that is used universally in UIKit. In SwiftUI, we can take advantage of Combine and its declarative nature for code that is more straightforward than delegates. To do this, we'll use Combine's Subjects to convert the delegate methods into Combine publishers.

Back in ChatService.swift, add the following two properties to the top of the class:

private let userStatusChangedSubject = PassthroughSubject<Contact, Never>()
let userStatusChanged: AnyPublisher<Contact, Never>

You just declared your first subject! Subjects are a special kind of Combine publisher that is a bridge between imperative and reactive programming. Subjects let you manually send events to their subscribers. This is useful for wrapping code that doesn't support Combine into a neat Combine wrapper.

Next, add an initializer to the class:

init() {
userStatusChanged = userStatusChangedSubject.eraseToAnyPublisher()
}

Notice that you declared a private subject and a public AnyPublisher. Since anyone can add their events to a subject, it's good practice to make it private. The same way you would make setters private and getters public for regular variables. By calling eraseToAnyPublisher, you get a version of the subject that is immutable.

Next, add the following line to the top of login, just before the return statement:

CometChat.userdelegate = self

You register as CometChat's user delegate so that it notifies you when a user's status changes.

There's one final bit to add to ChatService. Add the following extension that implements CometChatUserDelegate to the bottom of the file:

extension ChatService: CometChatUserDelegate {

func onUserOnline(user cometChatUser: CometChatPro.User) {
DispatchQueue.main.async {
self.userStatusChangedSubject.send(Contact(cometChatUser))
}
}

func onUserOffline(user cometChatUser: CometChatPro.User) {
DispatchQueue.main.async {
self.userStatusChangedSubject.send(Contact(cometChatUser))
}
}
}

Here, you use the subject's send method to send the updated contact through the subject. Anyone listening to the subject (or the erased version) will receive this contact.

Head back to ContactsView.swift to connect it to the code you just wrote. First, add the following method:

private func updateContactsOnChange() {
store.state.chatService.userStatusChanged.sink {
newContact in

guard let index = self.items.firstIndex(
where: { $0.contact.id == newContact.id }) else {
return
}

self.items[index] = ContactRow.ContactItem(
contact: newContact,
lastMessage: "",
unread: false)
}.store(in: &subscriptions)
}

This method adds a sink to the publisher you exposed earlier. When an updated contact appears, you find that contact's index and update it. Because items is a state variable, the view will automatically update.

Finally, call this function from the end of body:

.onAppear(perform: updateContactsOnChange)

Build and run the app and log in as superhero1. All contacts will still be offline because nobody else is using your app yet. You can open another Simulator instance and log in as superhero2.

Live updating online status in SwiftUI

You'll see the user's online badge turn green. When you exit the app, it will turn grey after a while. Neat!

Messages

Okay, that was enough preamble. It's time to get to the bread and butter of your app: Sending and receiving messages. Thankfully, since you are now fetching contacts as well as listening to contact changes, implementing support for messages will be similar. You'll use the same patterns you used for contacts: A request to fetch existing messages and a subject for when new messages arrive in real-time.

First, you'll add a way to convert CometChat's TextMessage into your own Message. Open Message.swift and import CometChat into the file:

import CometChatPro

Next, add the following extension to the bottom of the file:

extension Message {
init?(_ message: CometChatPro.BaseMessage) {
guard let message = message as? TextMessage,
let sender = message.sender else {
return nil
}

self.id = message.id
self.text = message.text
self.contact = Contact(sender)
}
}

This is an optional initializer that tries to create a Message based on CometChat's TextMessage.

Receiving messages

You'll start by receiving messages from CometChat. The same way you did for your contacts, add two new properties to track new messages:

private let receivedMessageSubject = PassthroughSubject<Message, Never>()
let receivedMessage: AnyPublisher<Message, Never>

Next, erase the subject at the end of init:

receivedMessage = receivedMessageSubject.eraseToAnyPublisher()

You need to set ChatService as CometChat's message delegate. Do so by adding a new line at the top of login:

CometChat.messagedelegate = self

Next, implement this delegate in an extension at the bottom of the file:

extension ChatService: CometChatMessageDelegate {
func onTextMessageReceived(textMessage: TextMessage) {
DispatchQueue.main.async {
guard let message = Message(textMessage) else {
return
}

self.receivedMessageSubject.send(message)
}
}
}

When you get a new message, you'll convert it to your own Message struct and send it through the subject.

Now, open ChatView.swift to add code that listens for new messages. First, add an import to the top of the file:

import Combine

Then, fetch AppStore from the environment so you get access to the chat service:

@EnvironmentObject private var store: AppStore

Next, add a set of cancellables to the struct:

@State private var subscriptions: Set<AnyCancellable> = []

With those pieces in place, you can create a new function that listens for new messages:

private func updateMessagesOnChange() {
store.state.chatService.receivedMessage.sink { message in
guard message.contact.id == self.receiver.id ||
message.contact.id == self.currentUser.id else {
return
}

self.messages.append(message)
}.store(in: &subscriptions)
}

You add a sink to the messages publisher. First, you check that the message is relevant for this view: The message has to either come from the logged-in user, or this screen's receiver. This prevents messages from other users from showing up on this screen. If the message is relevant, you'll add it to the list of messages.

Call this method when the view appears by adding the following line to the bottom of body:

.onAppear(perform: updateMessagesOnChange)

Finally, remove all hard-coded messages from the declaration of messages:

@State private var messages: [Message] = []

You can run the project now to check everything is working, but you have no way of testing receiving messages: You can't send them yet! Let's fix that.

Sending messages

Now that you can receive messages, it's time to start sending them!

Back in ChatService.swift, add a new function to the class:

func send(message: String, to reciever: Contact) {
guard let user = user else {
return
}

let textMessage = TextMessage(
receiverUid: reciever.id,
text: message,
receiverType: .user)

}

The send(message:to:) function will, naturally, send your messages. It receives the message you'll send as well as the user you're sending it to. In the function, you'll construct a new TextMessage with the necessary information. This is a class from CometChat. Besides text messages, CometChat also supports media messages and even custom messages where you can pass any data you like.

Next, add the following code to the function to send the message:

CometChat.sendTextMessage(
message: textMessage,
onSuccess: { [weak self] sentMessage in
DispatchQueue.main.async {
self?.receivedMessageSubject.send(Message(
id: sentMessage.id,
text: message,
contact: user))
}
},
onError: { error in
print("Error sending message:")
print(error?.errorDescription ?? "")
})

You send the message by calling CometChat's sendTextMessage. If it's sent successfully, you'll add the message to your subject. The view will respond to the message by adding it to the list of messages.

Now you can test this. Run the app and log in as superhero1. When you reach the contacts screen, start chatting with Captain America (superhero2). Launch another simulator instance and, this time, log in as superhero2. Open Iron Man and chat away!

Live updating the SwiftUI chat app with new messages

The fact that you're chatting with yourself doesn't make your app any less impressive: You now have a functioning chat app that lets you communicate with another person over a web server! Congrats!

Note: You might notice that, after you get a few messages, new messages appear off-screen. This can be solved by scrolling to the last message whenever a new one appears. In pure SwiftUI, that's easier said than done! Currently, there's no API to manipulate the scroll position of a scroll view. The best way to do this is to wrap UIScrollView in a UIViewControllerRepresentable. This allows SwiftUI to use UIKit's UIScrollView. Check out Apple's official tutorial on how to do this.

Loading past messages

There's only one final thing to add to your app. If you reset the app, all the messages that you sent and received are gone! Don't worry, they're still saved on CometChat — you just need to fetch them.

Back in ChatService.swift, add one final method to the class:

private var messagesRequest: MessagesRequest?
func getMessages(from sender: Contact) -> Future<[Message], Error> {
let limit = 50

messagesRequest = MessagesRequest.MessageRequestBuilder()
.set(limit: limit)
.set(uid: sender.id)
.build()

}

Similarly to how you fetched the contacts, you'll create a message request using a builder. In this case, you'll fetch the last 50 messages sent to and from a specific user.

Next, add the following code to the method:

return Future<[Message], Error> { promise in
self.messagesRequest!.fetchPrevious(
onSuccess: { fetchedMessages in
let messages = (fetchedMessages ?? [])
.compactMap(Message.init)

DispatchQueue.main.async {
promise(.success(messages))
}
},
onError: { error in
let message = error?.errorDescription ?? "unknown"
print("Fetching messages failed with error:")
print(message)
promise(.failure(message))
})
}

You create a future like before. When you successfully get the messages, you'll send them through the future by calling promise. If something goes wrong, however, you'll send an error.

Now, open ChatView.swift and add a new method:

private func getMessages() {
store.state.chatService.getMessages(from: receiver)
.sink(receiveCompletion: { error in
print(error)
}, receiveValue: { messages in
self.messages = messages
})
.store(in: &subscriptions)
}

You call the function to get the messages and, on success, update the state variable. On failure, you'll print out an error.

Finally, call this method when the view appears at the bottom of body:

.onAppear(perform: getMessages)

Run the app one final time, login in as superhero1 and start chatting with Captain America. You'll see all of your past messages load.

Loading previous messages in the SwiftUI chat app

Conclusion

After 7 parts of this SwiftUI course and, really not that much code, you made a fully working chat app!

Throughout this course you've learned:

  • How to create, compose and layout SwiftUI views.
  • How to think in a SwiftUI mindset.
  • How to manage state in SwiftUI.
  • How to thread data through your app using Combine.
  • How to use the Environment.
  • How to initiate network calls and return Futures.

This is more than you need to start going off on your own and discovering SwiftUI for yourself. But, if you want some more in-depth knowledge, here's some reading material:

  • Apple's WWDC 2019 sessions on SwiftUI are great learning resources in video form. However, some information may be a little outdated now.
  • SwiftUI by Tutorials, RayWenderlich.com's book on SwiftUI provides some more in-depth knowledge on SwiftUI and might give a different viewpoint than I did.
  • Additionally, there's also a bundle of SwiftUI, Combine and Catalyst books, so you can learn everything new and shiny in one go.
  • SwiftUI by Example is a free online book by Paul Hudson, though might be less in-depth than other resources.

SwiftUI is still fairly young so the biggest learning resource you have is your head. Go out, experiment, challenge yourself and figure stuff out! When you do, don't forget to write about it! (Or at least answer StackOverflow questions. :])

Good luck and have fun on your SwiftUI journeys! If you have any questions, feel free to comment below or message me on Twitter.