Build an iOS chat app using Swift and Chatkit - Part 3: Building the iOS app
To follow this series you will need Xcode, Cocoapods, PHP and Laravel installed on your machine. Some knowledge of Xcode and Swift will be helpful.
IMPORTANT:
ChatKit has been retired from the Pusher product stack as of April, 2020. This tutorial will no longer be functional in its current version. Explore our other Pusher tutorials to find out how Channels and Beams can help you build.
We previously created the API backend we need our iOS application to connect to. In this part, we will create the application itself using Xcode and Swift. We will be building out the application based on the prototypes we created in the first part of the series.
Here are the prototypes we made using our prototyping tool:
Let’s go through some requirements you need to have to follow this part of the article.
Creating our app’s storyboard in Xcode
We previously created the scenes of our application in Xcode using the Clean Swift templates. Now we are going to work on creating our storyboards and linking them to the view controllers we created.
Creating the launch scene
First add a Navigation View Controller and a View Controller to the storyboard as seen below. The Navigation Controller will be the entry point to our application. Set the custom class for the second View Controller as the LaunchViewController
we created in the first part of the article.
We will use the LaunchViewController
to determine which initial screen to present to the user when the app is launched. If the user is logged in we’ll present the contacts list, if not, we’ll present the Welcome scene so the user can log in or sign up.
Creating the welcome scene
The next scene we will create is the welcome scene. When a user is not logged in, they will be redirected to this scene. From here they can sign up or log in to their account. In the Main.storyboard
add the View Controller and create a “Present Modally” manual segue from the launch controller to the controller you just created.
After creating the welcome view controller, you can start designing it to look like the prototype using a combination of UILabel
s, UIButton
s, and UIView
s. Then set the name of the manual segue to Welcome. Finally, set the custom class for the view controller to WelcomeViewController
Creating the signup and login scene
Next create two new View Controllers. The first will be for login and the second will be for sign up. Set theView Controller for the Signup controller to SignupViewController
and the custom class for the login View Controller to LoginViewController
.
Create a “Present Modally” segue from the Sign In button to the LoginViewController
and from the Don’t have an account? Sign up here button to the SignupViewController
. Name the segues Login and Signup.
In the Signup view create three input fields and create an @IBOutlet
for them in the SignupViewController
, we named ours emailTextField
, nameTextField
and passwordTextField
. Create an @IBAction
called cancelButtonWasPressed
for the cancel button and signupButtonWasPressed
for the sign up button.
In the login view, we will follow almost the same steps as the sign up controller, but, this time the @IBAction
will be named loginButtonWasPressed
and there will be no nameTextField
.
Creating the ListContacts and Chatroom scenes
Next, create a Navigation controller and make sure the root controller for it is a UITableViewController
. Create a manual segue named ListMessages from the LaunchViewController
to the navigation controller and set the custom class of the UITableViewController
to ListContactsViewController
.
Create a “Present Modally” manual segue named MainNavigator between the navigation controller and the Login and Signup scenes.
Lastly, create a new UIViewController
and create a segue named Chatroom from the Prototype Cells to the new View Controller, then set the custom class of the View Controller to ChatroomViewController
.
Here is a screenshot of our entire storyboard:
Adding functionality to our scenes
Now that we have our scenes in place and hooked up to our View Controllers, let’s start adding the functionality to them. We will start at the very first piece of code that gets called when the application is launched: AppDelegate
.
In the AppDelegate
file, right below the import
statement add the code block below:
struct AppConstants {
static let ENDPOINT: String = "http://127.0.0.1:8000"
static let CLIENT_ID: Int = API_CLIENT_ID
static let CLIENT_SECRET: String = "API_CLIENT_SECRET"
static let CHATKIT_INSTANCE_LOCATOR: String = "CHATKIT_INSTANCE_LOCATOR"
}
In the struct
above, we define some constants for our application. We will use this as some configuration value handler for our application. Replace the values for the API_CLIENT_ID
, API_CLIENT_SECRET
and CHATKIT_INSTANCE_``LOCATOR
with the actual values.
💡 You can get the
API_CLIENT_*
key values from when you were setting up Passport in the previous part of the tutorial, and you can get theCHATKIT_INSTANCE_``LOCATOR
from the Chatkit dashboard.
Launch scene
Open the Scenes/Launch/LaunchSceneController
file and let us start adding some functionality to the class.
In the file, replace the code with this
We have simplified most of the code that comes with the Clean Swift template. In the viewDidLoad
method, we check to see if the user is logged in using an Authenticator
class, which we will create later. If the user is logged in then we route the user to the list messages page else we go to the welcome page.
Next, open the LaunchRouter
class and in the file paste this code
In the router we have defined the routeToWelcome
and routeToListContacts
methods. Both methods do what is necessary to route the user to either the Welcome or MainNavigator segue.
This is all we need to do for the Launch scene. You can delete the other template files in the Scenes/Launch
directory as we will not be using them.
Welcome scene
For our Welcome scene we do not need to do anything actually as the scene has no logic. In the WelcomeViewController
you can just paste the code below:
import UIKit
class WelcomeViewController: UIViewController {
}
You can delete the other template files in the Welcome scene as we will not be needing them and they serve no purpose.
Login scene
For our Login scene we will start by adding the logic to our LoginModels
class. The models will help us format the response from the API to what our application will be able to consume.
In the LoginModels
file paste this code.
In the code above we have defined a series of struct
s and in them we have Request
and Response
. The Request
standardises the parameters required for the request to the login endpoint while the Response
takes the raw response from the API and saves them as either a ChatkitToken
or UserToken
object. We will define the ChatToken
and the UserToken
object later in the article
Next open the LoginInteractor
and paste this code into the file.
In this class we have a login
method that just calls a login
method on the UsersWorker
class. Depending on the response from that call, we either show a login error or we route to the contacts list.
Next, open the LoginRouter
class and in the file paste the following code:
import UIKit
@objc protocol LoginRoutingLogic {
func routeToListContacts()
}
class LoginRouter: NSObject, LoginRoutingLogic {
weak var viewController: LoginViewController?
func routeToListContacts() {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let destinationVC = storyboard.instantiateViewController(withIdentifier: "MainNavigator") as! UINavigationController
viewController!.show(destinationVC, sender: nil)
}
}
The router above has just one method. This method routes the app to the list contacts page when called.
The last class we want to edit for this scene is the LoginViewController
. This is the class that pieces the other classes above. Open the file and paste this code into the file.
In the view controller we have the usual set up methods used by Clean Swift but we also have the @IBAction
s and @IBOutlet
s we created when we were creating our storyboards.
In the cancelButtonPressed
method we just dismiss the login screen modal, in the loginButtonPressed
method we call the login
method on the interactor, and in the showValidationError
method we show an alert with an error message.
Signup scene
For the Login scene functionality we will start with the models. Open the SignupModels
file in the Scene/Signup
directory and paste the code below into it:
import Foundation
enum Signup {
struct Request {
var name: String
var email: String
var password: String
}
struct Response {
var user: User?
init(data: [String:Any]) {
self.user = User(
id: data["id"] as! Int,
name: data["name"] as! String,
email: data["email"] as! String,
chatkit_id: data["chatkit_id"] as! String
)
}
}
}
In the models we have Request and Response structs. Their functionality was described above.
Next, paste the code below into the SignupInteractor
class in the same directory:
import Foundation
protocol SignupBusinessLogic {
func createAccount(request: Signup.Request)
}
class SignupInteractor: SignupBusinessLogic {
var viewController: SignupFormErrorLogic?
var router: (NSObjectProtocol & SignupRoutingLogic)?
var worker = UsersWorker()
func createAccount(request: Signup.Request) -> Void {
self.worker.signup(request: request) { user, error in
guard error == nil else {
self.viewController?.showValidationError("Error creating account!")
return
}
self.router?.routeToListContacts()
}
}
}
In the createAccount
method, we call the signup
method on the UsersWorker
and then depending on the response we either route to the contacts list or show an error.
Next, open the router class SignupRouter
and paste the code below into it:
import UIKit
@objc protocol SignupRoutingLogic {
func routeToListContacts()
}
class SignupRouter: NSObject, SignupRoutingLogic {
weak var viewController: SignupViewController?
func routeToListContacts() {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let destinationVC = storyboard.instantiateViewController(withIdentifier: "MainNavigator") as! UINavigationController
viewController!.show(destinationVC, sender: nil)
}
}
As in the login router, this method just routes the user to the contacts screen using the MainNavigator
segue.
For the SignupViewController
, use this code.
In this view controller, we set up the Clean Swift components then in the cancelButtonPressed
method we dismiss the signup modal and in the signupButtonPressed
method we create the account using the interactor. The showValidationError
shows an alert when it is called, usually when there is an error signing up.
ListContacts scene
The ListContacts scene is supposed to show a list of the contacts available to chat with. Open the ListContactsModels
file and paste this code into the file.
In the code above we have the usual Request
and Response
(explained above). We also have a ParseContact
struct that takes the raw data and an array of rooms
. It parses it into a Contact
model object with User
and PCRoom
object. We reference this in the Response
s above so as to avoid duplication.
We also have a ViewModel
struct that we use to format the data in a way the presenter needs to display the data to the user.
Next open the ListContactsInteractor
class and paste the code below into it: https://github.com/pusher/sample-chatroom-ios-chatkit/blob/master/words/Scenes/ListContacts/ListContactsInteractor.swift
In the method above, we have fetchContacts
, which uses the UsersWorker
class to fetch the contacts from the API and also the addContact
class, which also uses the same worker to add contacts.
After a successful call, in both methods we call the presenter, which formats the data and makes a call to the View Controller and displays the content.
Let’s update the code for the ListContactsPresenter
. Open the file and paste the code below into the file:
import Foundation
protocol ListContactsPresentationLogic {
func presentContacts(_ contacts: [Contact])
func presentAddedContact(_ contact: Contact)
}
class ListContactsPresenter: ListContactsPresentationLogic {
weak var viewController: ListContactsDisplayLogic?
var displayedContacts: [ListContacts.Fetch.ViewModel.DisplayedContact] = []
func presentContacts(_ contacts: [Contact]) {
displayedContacts = []
for contact in contacts {
displayedContacts.append(ListContacts.Fetch.ViewModel.DisplayedContact(
id: contact.user.chatkit_id,
name: contact.user.name,
isOnline: false
))
}
displayContacts()
}
func presentAddedContact(_ contact: Contact) {
displayedContacts.append(ListContacts.Fetch.ViewModel.DisplayedContact(
id: contact.user.chatkit_id,
name: contact.user.name,
isOnline: false
))
displayContacts()
}
private func displayContacts() {
let vm = ListContacts.Fetch.ViewModel(displayedContacts: displayedContacts)
viewController?.displayFetchedContacts(viewModel: vm)
}
}
The code above has three methods. presentContacts
and presentAddedContact
do pretty much the same thing: format the contacts, append it to the displayedContacts
array, and call the displayContacts
method. The displayContacts
method just calls displayFetchedContacts
on the View Controller.
Let us update the code for the ListContactsViewController
. Paste the following into the controller:
import UIKit
import PusherChatkit
protocol ListContactsDisplayLogic: class {
func displayFetchedContacts(viewModel: ListContacts.Fetch.ViewModel)
}
class ListContactsViewController: UITableViewController, ListContactsDisplayLogic {
var interactor: ListContactsBusinessLogic?
var displayedContacts: [ListContacts.Fetch.ViewModel.DisplayedContact] = []
var router: (NSObjectProtocol & ListContactsRoutingLogic & ListContactsDataPassing)?
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
private func setup() {
let viewController = self
let interactor = ListContactsInteractor()
let presenter = ListContactsPresenter()
let router = ListContactsRouter()
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
presenter.viewController = viewController
router.viewController = viewController
router.dataStore = interactor
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let scene = segue.identifier {
let selector = NSSelectorFromString("routeTo\(scene)WithSegue:")
if let router = router, router.responds(to: selector) {
router.perform(selector, with: segue)
}
}
}
}
This is the basic set up that comes with the Clean Swift templates. It just sets up the connections between all the ListContacts scene classes. Next, let’s add the methods below to the class. These are specific to our implementation:
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Contacts"
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Add",
style: .plain,
target: self,
action: #selector(showAddContactPopup)
)
initialiseChatkit()
}
private func initialiseChatkit() {
let userId = CurrentUserIDDataStore().getID()
let chatManager = ChatManager(
instanceLocator: AppConstants.CHATKIT_INSTANCE_LOCATOR,
tokenProvider: ChatkitTokenDataStore(),
userID: userId.id!
)
chatManager.connect(delegate: self) { user, error in
guard error == nil else { return }
self.interactor?.currentUser = user
self.fetchContacts()
}
}
var emailTextField: UITextField?
@objc func showAddContactPopup(_ sender: Any) {
let alert = UIAlertController(
title: "Add",
message: "Enter the users email address",
preferredStyle: .alert
)
alert.addTextField { emailTextField in
emailTextField.placeholder = "Enter email address"
self.emailTextField = emailTextField
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Add Contact", style: .default) { action in
let request = ListContacts.Create.Request(user_id: self.emailTextField!.text!)
self.interactor?.addContact(request: request)
})
present(alert, animated: true, completion: nil)
}
private func fetchContacts() {
interactor?.fetchContacts(request: ListContacts.Fetch.Request())
}
func displayFetchedContacts(viewModel: ListContacts.Fetch.ViewModel) {
displayedContacts = viewModel.displayedContacts
tableView.reloadData()
}
In the viewDidLoad
method, we configure the navigation bar and add an “Add” button to it. We also set the title to “Contacts”. The showAddContactPopup
method shows an alert controller with a text field for the contact you want to add. When you click add, the contact will be added.
The initialiseChatkit
method connects to Chatkit and stores the currentUser
in the interactor while the fetchContacts
method gets all the contacts from the API while the displayFetchedContacts
method simply displays them.
Next we will add the class extension of ListContactsViewController
that implements the UITableViewDelegate
protocol. At the bottom of the ListContactsViewController
paste the following:
extension ListContactsViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return displayedContacts.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCell(withIdentifier: "ContactTableViewCell")
if cell == nil {
cell = UITableViewCell(style: .subtitle, reuseIdentifier: "ContactTableViewCell")
}
let contact = displayedContacts[indexPath.row]
cell?.textLabel?.text = contact.name
cell?.detailTextLabel?.text = contact.isOnline ? "online" : "Seen recently"
return cell!
}
}
The method should be familiar to you as it just helps us feed data to the UITableView
so our contacts display properly on the table.
The last bit of code for the ListContacts scene is the ListContactsRouter
. This will manage the routing to the Chatroom scene. Open the ListContactsRouter
class and add the following code:
import UIKit
@objc protocol ListContactsRoutingLogic {
func routeToChatroom(segue: UIStoryboardSegue)
}
protocol ListContactsDataPassing {
var dataStore: ListContactsDataStore? { get }
}
class ListContactsRouter: NSObject, ListContactsRoutingLogic, ListContactsDataPassing {
var dataStore: ListContactsDataStore?
weak var viewController: ListContactsViewController?
func routeToChatroom(segue: UIStoryboardSegue) {
let destinationVC = segue.destination as! ChatroomViewController
var destinationDS = destinationVC.router!.dataStore!
passDataToChatroom(source: dataStore!, destination: &destinationDS)
}
func passDataToChatroom(source: ListContactsDataStore, destination: inout ChatroomDataStore) {
let selectedRow = viewController?.tableView.indexPathForSelectedRow?.row
destination.contact = source.contacts?[selectedRow!]
destination.currentUser = source.currentUser
}
}
In the routeToChatroom
method we call passDataToChatroom
which passes data (the Contact
object and the current user) to the ListContactsViewController
for usage.
Chatroom scene
In the Chatroom scene we will start with the ChatroomModels
. Open the file and paste the following code:
import Foundation
import MessageKit
import PusherChatkit
enum Chatroom {
struct Messages {
struct Fetch {
struct Request {
var room: PCRoom
}
struct Response {
var messages: [Message] = []
init(messages: [PCMessage]) {
for message in messages {
let res = Chatroom.Messages.Create.Response(message: message)
self.messages.append(res.message)
}
}
}
}
struct Create {
struct Request {
var text: String
var sender: Sender
var room: PCRoom
}
struct Response {
var message: Message
init(message: PCMessage) {
self.message = Message(
text: message.text,
sender: Sender(id: message.sender.id, displayName: message.sender.displayName),
messageId: String(describing: message.id),
date: ISO8601DateFormatter().date(from: message.createdAt)!
)
}
}
}
}
}
Like the other methods, we have the usual Request
and Response
struct (explained above).
In the ChatroomInteractor.swift
file paste this code.
In the subscribeToRoom
method, we subscribe the currentUser
to a room. We also set the PCRoomDelegate
to the interactor which means we can implement methods that handle events on the interactor.
In the addChatMessage
method we add a new message to the room as the currentUser
. When the user is added we pass the messageId
to the completion handler.
In the extension class, we implement the newMessage
method of the PCRoomDelegate
. The method is fired automatically anytime there is a new message in the room. So we handle the new message by presenting it to the controller using the ChatroomPresenter
s presentMessages
method.
Next, open the ChatPresenter.swift
file and paste the code below into it:
import Foundation
protocol ChatroomPresentationLogic {
func presentMessages(response: Chatroom.Messages.Fetch.Response)
}
class ChatroomPresenter: ChatroomPresentationLogic {
weak var viewController: ChatroomDisplayLogic?
func presentMessages(response: Chatroom.Messages.Fetch.Response) {
viewController?.displayChatMessages(response: response)
}
}
The only method there is the presentMessages
method. It just calls the displayChatMessages
method on the view controller.
Next, in the ChatroomRouter
just paste in the following code:
import Foundation
protocol ChatroomDataPassing {
var dataStore: ChatroomDataStore? { get }
}
class ChatroomRouter: NSObject, ChatroomDataPassing {
weak var viewController: ChatroomViewController?
var dataStore: ChatroomDataStore?
}
Then in the ChatroomViewController
we are going to split the controller into extensions as the code is lengthy. First paste the Clean Swift set up code into the file:
import UIKit
import MessageKit
import PusherChatkit
import MessageInputBar
protocol ChatroomDisplayLogic: class {
func displayChatMessages(response: Chatroom.Messages.Fetch.Response)
}
class ChatroomViewController: MessagesViewController, ChatroomDisplayLogic {
var messages: [Message] = []
var interactor: ChatroomBusinessLogic?
var router: (NSObjectProtocol & ChatroomDataPassing)?
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
private func setup() {
let viewController = self
let interactor = ChatroomInteractor()
let presenter = ChatroomPresenter()
let router = ChatroomRouter()
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
presenter.viewController = viewController
router.viewController = viewController
router.dataStore = interactor
}
}
Next, we will paste the class extension that handles the loading of the chat messages, and configures MessageKit and Chatkit as the view is loaded:
extension ChatroomViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.initialiseChatkit()
self.configureMessageKit()
self.navigationItem.title = router?.dataStore?.contact?.user.name
}
private func initialiseChatkit() {
guard let room = router?.dataStore?.contact?.room else { return }
guard let currentUser = router?.dataStore?.currentUser else { return }
self.interactor?.currentUser = currentUser
self.interactor?.subscribeToRoom(room: room)
}
private func configureMessageKit() {
messageInputBar.delegate = self
messagesCollectionView.messagesDataSource = self
messagesCollectionView.messagesLayoutDelegate = self
messagesCollectionView.messagesDisplayDelegate = self
scrollsToBottomOnKeyboardBeginsEditing = true
maintainPositionOnKeyboardFrameChanged = true
}
func displayChatMessages(response: Chatroom.Messages.Fetch.Response) {
self.messages = response.messages
self.messagesCollectionView.reloadData()
self.messagesCollectionView.scrollToBottom()
}
}
In the extension above we have the initialiseChatkit
method. It takes the current user passed on from the ListContacts scene and saves it to the interactor. Then the next method is the configureMessageKit
where we configure MessageKit. Lastly, we have the displayChatMessages
method that displays the messages.
Next, paste the extension below the previous class:
extension ChatroomViewController: MessagesDataSource {
func isFromCurrentSender(message: MessageType) -> Bool {
return message.sender == currentSender()
}
func currentSender() -> Sender {
return Sender(id: (interactor?.currentUser?.id)!, displayName: (interactor?.currentUser?.name)!)
}
func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
return self.messages.count
}
func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
return self.messages[indexPath.section]
}
func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
avatarView.initials = self.initials(fromName: message.sender.displayName)
}
func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
return NSAttributedString(
string: message.sender.displayName,
attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)]
)
}
func messageBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
struct ConversationDateFormatter {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
}
return NSAttributedString(
string: ConversationDateFormatter.formatter.string(from: message.sentDate),
attributes: [NSAttributedStringKey.font: UIFont.preferredFont(forTextStyle: .caption2)]
)
}
}
extension ChatroomViewController: MessagesLayoutDelegate {
func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
return 16
}
func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
return 16
}
func avatarPosition(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> AvatarPosition {
return AvatarPosition(horizontal: .natural, vertical: .messageBottom)
}
func messagePadding(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIEdgeInsets {
return isFromCurrentSender(message: message)
? UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 4)
: UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 30)
}
func footerViewSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize {
return CGSize(width: messagesCollectionView.bounds.width, height: 10)
}
func heightForLocation(message: MessageType, at indexPath: IndexPath, with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
return 200
}
}
extension ChatroomViewController: MessagesDisplayDelegate {
}
extension ChatroomViewController: MessageInputBarDelegate {
func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) {
guard interactor?.currentUser != nil else { return }
guard let room = router?.dataStore?.contact?.room else { return }
let request = Chatroom.Messages.Create.Request(text: text, sender: currentSender(), room: room)
self.interactor?.addChatMessage(request: request) { id, error in
guard error == nil else { return }
inputBar.inputTextView.text = String()
}
}
}
The first extension above extends the [MessagesDataSource](https://messagekit.github.io/Protocols/MessagesDataSource.html)
protocol and has to conform to some of its methods. Here we implement currentSender
, numberOfMessages
and messageForItem
, all self explanatory.
The next extension on the list is for the [MessagesLayoutDelegate](https://messagekit.github.io/Protocols/MessagesLayoutDelegate.html)
protocol and it implements some of the methods but there are other methods you can implement to change the layouts display. There is also the implementation of [MessagesDisplayDelegate](https://messagekit.github.io/Protocols/MessagesDisplayDelegate.html)
, which is empty but you can look through the documentation to see methods you can implement.
Lastly, we implement [MessageInputBarDelegate](https://messagekit.github.io/Protocols/MessageInputBarDelegate.html)
and in there we have the messageInputBar
method, which is called after the send button is clicked on the chat screen. In this method we send the message using the interactor and then empty the input.
With this we are done with our scenes. Next, we will add the code for our supporting classes used in the scenes.
Implementing supporting classes for our application
The first set of supporting classes we will implement are the models. The models we want to implement are User
, Message
, Contact
, Room
, UserToken
, and ChatToken
. Here are the class contents:
Implementing the models
To implement the models, create a new folder/group in the root directory called Models and start creating the model files below in them. Get the code from the GitHub links.
The code for the Message.swift
model
The code for the Contact.swift
model
The code for the User.swift
model
In the UserToken
class above we implement the NSCoding
protocol, we need this so we can save the UserToken
object to UserDefaults.
The code to the ChatToken.swift
model
Like the UserToken
model, the ChatToken
does the same thing with the NSCoding
protocol.
When we have received a chatkit_id
back from the server we want to store that in UserDefaults
so that we can use it to connect to Chatkit.
The code to the CurrentUserID.swift
model
Implementing the services
Create a new folder/group in the root directory for the project on Xcode and name it Services and in there add the following files:
Authenticator.swift
class:
import Foundation
class Authenticator {
func isLoggedIn() -> Bool {
return getAccessToken().count > 0
}
private func getAccessToken() -> String {
guard let token = ChatkitTokenDataStore().getToken().access_token, token.count > 0 else {
return ""
}
return token
}
}
This Authenticator
class checks if the user is logged in. It does this by checking if the token from the API is saved in UserDefaults.
UserTokenDataStore.swift
class:
import Foundation
class UserTokenDataStore {
static var DATA_KEY = "WORDS_API_TOKEN"
func getToken() -> UserToken {
if let token = UserDefaults.standard.object(forKey: type(of: self).DATA_KEY) as! Data? {
return NSKeyedUnarchiver.unarchiveObject(with: token) as! UserToken
}
return UserToken(token_type: nil, access_token: nil, expires_in: nil)
}
func setToken(_ token: UserToken) {
let encodedData = NSKeyedArchiver.archivedData(withRootObject: token)
UserDefaults.standard.set(encodedData, forKey: type(of: self).DATA_KEY)
}
}
The UserDataTokenStore
class saves and fetches the token required to make calls to our backend API.
Add the ChatTokenDataStore.swift
class with this code
The class above does the same as the UserTokenDataStore
, however it checks for the token required to make calls to the Chatkit API. It also extends the [PCTokenProvider](https://github.com/pusher/chatkit-swift/blob/master/Source/PCTokenProvider.swift)
and thus we have the fetchToken
method that fetches the token from the UserDefaults.
Add the CurrentUserIDDataStore.swift
class with this code
The class above does the same as the two token data stores. It takes some information, in this case the user’s Chatkit ID, and fetches and stores it in UserDefaults
.
Implementing the global worker
The last class we want to implement is the UsersWorker
. Create a UsersWorker
class and paste the following into the file:
import UIKit
import Alamofire
class UsersWorker {
}
// MARK: Errors
enum ContactsError: Error {
case CannotAdd
case CannotFetch
}
enum UsersStoreError: Error {
case CannotLogin
case CannotSignup
case CannotFetchChatkitToken
}
Now we will start adding methods to the UsersWorker
class. The first method is the fetchContacts
method, which calls the API, using Alamofire, to get a list of contacts for display:
func fetchContacts(currentUser: PCCurrentUser, completionHandler: @escaping ([Contact]?, ContactsError?) -> Void){
let enc = JSONEncoding.default
let url = AppConstants.ENDPOINT + "/api/contacts"
let headers = authorizationHeader(token: nil)
Alamofire
.request(url, method: .get, parameters: nil, encoding: enc, headers: headers)
.validate()
.responseJSON { response in
switch (response.result) {
case .success(let data):
DispatchQueue.main.async {
let data = data as! [[String:Any]?]
let res = ListContacts.Fetch.Response(for: currentUser, data:data)
completionHandler(res.contacts, nil)
}
case .failure(_):
completionHandler(nil, ContactsError.CannotFetch)
}
}
}
The next method to add to the class is the addContact
method. The method makes the call to the API to add a contact:
func addContact(currentUser: PCCurrentUser, request: ListContacts.Create.Request, completionHandler: @escaping (Contact?, ContactsError?) -> Void) {
let params = ["user_id": request.user_id]
let headers = authorizationHeader(token: nil)
postRequest("/api/contacts", params: params, headers: headers) { data in
guard data != nil else {
return completionHandler(nil, ContactsError.CannotAdd)
}
DispatchQueue.main.async {
let response = ListContacts.Create.Response(for: currentUser, data: data!)
completionHandler(response.contact, nil)
}
}
}
The next method to add to the class is the login
method. The method makes a call to the API to login. The API returns the token for the API as the response. We make an additional call to the API to get the Chatkit token for the user so we can make calls to the Chatkit API on behalf of the user:
func login(request: Login.Account.Request, completionHandler: @escaping (UserToken?, UsersStoreError?) -> Void) {
let params: Parameters = [
"grant_type": "password",
"username": request.email,
"password": request.password,
"client_id": AppConstants.CLIENT_ID,
"client_secret": AppConstants.CLIENT_SECRET,
]
postRequest("/oauth/token", params: params, headers: nil) { data in
guard data != nil else {
return completionHandler(nil, UsersStoreError.CannotLogin)
}
let response = Login.Account.Response(data: data!)
let request = Login.Chatkit.Request(
username: request.email,
password: request.password,
token: response.userToken
)
self.fetchChatkitToken(request: request) { token, error in
guard error == nil else {
return completionHandler(nil, UsersStoreError.CannotFetchChatkitToken)
}
ChatkitTokenDataStore().setToken(token!)
UserTokenDataStore().setToken(response.userToken)
DispatchQueue.main.async {
completionHandler(response.userToken, nil)
}
}
}
}
Next we will add the signup
method, this will call the API to create a user and then it will log the user in and fetch the Chatkit token for that user:
func signup(request: Signup.Request, completionHandler: @escaping (User?, UsersStoreError?) -> Void) {
let params: Parameters = [
"name": request.name,
"email": request.email,
"password": request.password
]
postRequest("/api/users/signup", params: params, headers: nil) { data in
guard data != nil else {
return completionHandler(nil, UsersStoreError.CannotSignup)
}
let response = Signup.Response(data: data!)
CurrentUserIDDataStore().setID(CurrentUserID(id: response.user?.chatkit_id))
let request = Login.Account.Request(
email: request.email,
password: request.password
)
self.login(request: request) { token, error in
guard error == nil else {
return completionHandler(nil, UsersStoreError.CannotLogin)
}
DispatchQueue.main.async {
completionHandler(response.user, nil)
}
}
}
}
The next method to add is the fetchChatkitToken
. It fetches the Chatkit token from the API:
func fetchChatkitToken(request: Login.Chatkit.Request, completionHandler: @escaping (ChatkitToken?, UsersStoreError?) -> Void) {
let headers = authorizationHeader(token: request.token.access_token!)
postRequest("/api/chatkit/token", params: nil, headers: headers) { data in
guard data != nil else {
return completionHandler(nil, UsersStoreError.CannotFetchChatkitToken)
}
DispatchQueue.main.async {
let response = Login.Chatkit.Response(data: data!)
completionHandler(response.token, nil)
}
}
}
The last two methods to add will be helpers, the postRequest
and authorizationHeader
methods:
private func postRequest(_ url: String, params: Parameters?, headers: HTTPHeaders?, completion: @escaping([String:Any]?) -> Void) {
let enc = JSONEncoding.default
let url = AppConstants.ENDPOINT + url
Alamofire
.request(url, method: .post, parameters:params, encoding:enc, headers:headers)
.validate()
.responseJSON { response in
switch (response.result) {
case .success(let data): completion((data as! [String:Any]))
case .failure(_): completion(nil)
}
}
}
private func authorizationHeader(token: String?) -> HTTPHeaders {
let accessToken = (token == nil)
? UserTokenDataStore().getToken().access_token
: token
return ["Authorization": "Bearer \(accessToken!)"]
}
The first method is a wrapper around Alamofire and the second method generates a HTTPHeaders array where we specify the token to send along with requests to the API.
Now you can run the application in the emulator and it should work.
If you are running the API server locally XCode might not allow you to make requests to the local server. You can get around this by adding App Transport Security Settings
to your Info.plist
file and set Allow Artibrary Loads
to YES
.
Conclusion
In this part we were able to create the iOS application.
The source code to the application built in this series is available on GitHub.
IMPORTANT:
ChatKit has been retired from the Pusher product stack as of April, 2020. This tutorial will no longer be functional in its current version. Explore our other Pusher tutorials to find out how Channels and Beams can help you build.
5 June 2018
by Neo Ighodaro