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.
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.
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 User
s to Contact
s.
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:
- You call CometChat's
login
function and pass it the user's unique ID (email) and the app's API key. - 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. - After dispatching back to the main queue, you'll call
promise
with a successful value. This notifies anyone listening to theFuture
that new data has arrived. - 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 Publisher
s. 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!
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!
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 Subject
s 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.
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!
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 aUIViewControllerRepresentable
. This allows SwiftUI to use UIKit'sUIScrollView
. 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.
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.
Top comments (0)