Build an iMessage Clone with Stream’s iOS SDK

Bahadir O.
Bahadir O.
Published April 16, 2020 Updated June 14, 2021

In this tutorial, we’ll build a functional clone of iMessage using Stream Chat iOS SDK. Building a messaging app used to be difficult; but in this tutorial, you’ll get a chat experience up and running in roughly 20 minutes!

If you get lost during this tutorial, you can check:

The result of our application will look similar to the following screenshots:

Stream Chat - iMessage Clone - Final

Let’s get started! 🚀

Install Cocoapods

For this tutorial we are going to use CocoaPods as dependency manager. For convenience, we also publish the SDK on Cartage and Swift Package Manager.

If you do not currently have CocoaPods installed, or your cocoapods is outdated, you can update/install it by running the following command:

bash
1
$ sudo gem install cocoapods

Cloning the iMessage Clone Starter Branch

Start by cloning the starter branch of the WhatsApp Clone Github repo:

bash
1
$ git clone -b starter git@github.com:GetStream/stream-imessage-clone.git

and install dependencies:

bash
1
$ pod install

After all of our pods are installed, you can open the Xcode workspace:

bash
1
$ open iMessageClone.xcworkspace

The starter branch sets up the project and dependencies so we can jump right into coding! You can also create a new project; in that case, please make sure your Podfile is the same as that in the repo.

Configuring The Stream Chat Client

The first thing we need to do is configure our StreamChat Client with our API key. Open your AppDelegate file and edit your didFinishLaunchingWithOptions function, so it looks like this:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Client.config = .init(apiKey: "b67pax5b2wdq", logOptions: .info)
    Client.shared.set(user: User(id: "polished-poetry-5"),
                      token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoicG9saXNoZWQtcG9ldHJ5LTUifQ.o8SWzSlb68EntudwjVul1rUCYGpla-CimXNKxj3wKOc")
    return true
}

Note: You can find your API key in the Stream Dashboard. To create an application, head over to https://getstream.io/chat/ and create a free account. Once created, click on the "Chat" tab within the dashboard. You will see that an application has been pre-provisioned for you. Within that application, you can find your credentials (API key and secret). After that, you can put your API secret and desired user id in this token generator to get the user token: https://getstream.io/chat/docs/token_generator/

Now that you've done that let's go ahead and move on.

Creating the Contacts Screen

We’ll start by creating a contacts (chats) view controller and make it look like that in the iMessage app. We’ll name it ContactsViewController.

Please create a new swift file and name it as shown above.

Note: If you’ve created a new project, rename your ViewController.swift and use it instead.

Then, paste these contents inside:

import UIKit
import StreamChat
import StreamChatCore

class ContactsViewController: ChannelsViewController {

}

Then, open Main.storyboard and change the view controller class to ContactsViewController:

Stream Chat - iOS SDK in XCode

and embed the entry view controller inside a navigation controller:

Stream Chat - iOS SDK in XCode

After you’ve embedded a navigation controller, select your navigation item in a navigation controller and tick “Prefers Large Titles”:

Stream Chat - iOS SDK in XCode

Now to display the chats! Paste these lines into your ContactsViewController:

override func viewDidLoad() {
    presenter = ChannelsPresenter(filter: .currentUserInMembers)
    
    title = "Messages"
    
    setupStyles()
    tableView.tableFooterView = nil
    
    navigationItem.largeTitleDisplayMode = .always
    
    deleteChannelBySwipe = true
    
    super.viewDidLoad()
}

private func setupStyles() {
    view.directionalLayoutMargins.leading = 24
    
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.firstLineHeadIndent = view.layoutMargins.left - 16
    navigationController?.navigationBar.largeTitleTextAttributes = [NSAttributedString.Key.paragraphStyle: paragraphStyle]
    
    style.channel.nameFont = .boldSystemFont(ofSize: 16)
    style.channel.nameColor = .black
    
    style.channel.nameUnreadFont = .boldSystemFont(ofSize: 16)
    style.channel.nameUnreadColor = .black
    
    style.channel.messageFont = .systemFont(ofSize: 15)
    style.channel.messageColor = .gray
    style.channel.messageNumberOfLines = 2
    
    style.channel.messageUnreadFont = .systemFont(ofSize: 15)
    style.channel.messageUnreadColor = .gray
    style.channel.messageNumberOfLines = 2
    
    style.channel.dateFont = .systemFont(ofSize: 15)
    style.channel.dateColor = .gray
    
    style.channel.verticalTextAlignment = .top
    
    let separatorStyle = SeparatorStyle(color: UIColor.lightGray.withAlphaComponent(0.6),
                                        inset: UIEdgeInsets(top: 0, left: view.layoutMargins.left, bottom: 0, right: 0),
                                        tableStyle: .singleLine)
    style.channel.separatorStyle = separatorStyle
    
    style.channel.spacing.vertical = 2
    style.channel.spacing.horizontal = 16
    
    style.channel.avatarViewStyle?.verticalAlignment = .center
    style.channel.avatarViewStyle?.radius = 46 / 2
    
    style.channel.height = 46 + 16 * 2
    
    style.channel.edgeInsets.top = 8
    style.channel.edgeInsets.bottom = 8
    style.channel.edgeInsets.left = view.layoutMargins.left
    style.channel.edgeInsets.right = view.layoutMargins.right + 16 * 2
}

Now, if you run the app, you’ll see that our contacts screen already looks a lot like iMessage, and we have only just used the customization options provided by Stream Chat:

Stream Chat - iMessage Message List
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Our contacts cells lack the blue dot showing the unread status of a chat, the chevron symbol next to the dates, and the date texts are not formatted to look like iMessage. Let’s fix that!

For these kinds of things for which Stream Chat does not provide an API, we can subclass Stream Chat’s classes and add our functionality. Let’s do that!

Create a new file named ContactListCell.swift and paste these contents:

import UIKit
import StreamChat

class ContactListCell: ChannelTableViewCell {
    
    static var reuseIdentifier: String {
        return String(describing: self)
    }
    
    private let unreadView = UIView()
    private let dateAccessory = UIImageView()
    
    var isUnread: Bool {
        get {
            return !unreadView.isHidden
        }
        set {
            unreadView.isHidden = !newValue
        }
    }
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }
    
    private func setupUI() {
        unreadView.backgroundColor = .systemBlue
        unreadView.layer.cornerRadius = 10 / 2
        unreadView.layer.masksToBounds = true
        unreadView.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(unreadView)
        
        dateAccessory.image = UIImage(systemName: "chevron.right",
                                        withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 14),
                                                                                        scale: .default))?.withRenderingMode(.alwaysTemplate)
        dateAccessory.tintColor = UIColor.gray.withAlphaComponent(0.6)
        dateAccessory.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(dateAccessory)
        
        NSLayoutConstraint.activate([
            unreadView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            unreadView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 7),
            unreadView.heightAnchor.constraint(equalToConstant: 10),
            unreadView.widthAnchor.constraint(equalTo: unreadView.heightAnchor),
            
            dateAccessory.topAnchor.constraint(equalTo: contentView.topAnchor, constant: style.edgeInsets.top + 2),
            dateAccessory.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -16),
        ])
    }
    
    override func update(date: Date) {
        dateLabel.text = date.formatRelativeString()
    }
}

fileprivate extension Date {
    func formatRelativeString() -> String {
        let dateFormatter = DateFormatter()
        let calendar = Calendar(identifier: .gregorian)
        dateFormatter.doesRelativeDateFormatting = true

        if calendar.isDateInToday(self) {
            dateFormatter.timeStyle = .short
            dateFormatter.dateStyle = .none
        } else if calendar.isDateInYesterday(self){
            dateFormatter.timeStyle = .none
            dateFormatter.dateStyle = .medium
        } else if calendar.compare(Date(), to: self, toGranularity: .weekOfYear) == .orderedSame {
            let weekday = calendar.dateComponents([.weekday], from: self).weekday ?? 0
            return dateFormatter.weekdaySymbols[weekday-1]
        } else {
            dateFormatter.timeStyle = .none
            dateFormatter.dateStyle = .short
        }

        return dateFormatter.string(from: self)
    }
}

We’ve extended Stream Chat’s ChannelTableViewCell to add our components (unread indicator and date accessory), and we’ve overridden update(date:) function to display our date string.

To be able to use our new cell, we need to add some code inside ContactsViewController.

Inside viewDidLoad, before tableView.tableFooterView = nil line:

tableView.register(ContactListCell.self, forCellReuseIdentifier: ContactListCell.reuseIdentifier)

and paste this new function:

override func updateChannelCell(_ cell: ChannelTableViewCell, channelPresenter: ChannelPresenter) {
    super.updateChannelCell(cell, channelPresenter: channelPresenter)
    if let cell = cell as? ContactListCell {
        cell.isUnread = channelPresenter.isUnread
    }
}

You only need to register your cell type, dequeuing is done automatically by our SDK and overriding updateChannelCell is enough for customizing your cell.

Now if you run it, you’ll see that we were able to implement the missing features:

Stream Chat - iMessage Clone Example

Great!

Building the Messaging Screen

Now, we move onto the Messaging screen. Currently, we haven’t touched it yet, so it looks nothing like iMessage:

iMessage Tutorial – Messaging Screen

Let’s start customizing our screen!

Paste this code inside ContactsViewController's setupStyles function:

style.incomingMessage.avatarViewStyle = nil
style.incomingMessage.backgroundColor = UIColor(red: 233/255, green: 233/255, blue: 235/255, alpha: 1)
style.incomingMessage.borderColor = .clear
style.incomingMessage.textColor = .black
style.incomingMessage.font = .systemFont(ofSize: 17, weight: .regular)
style.incomingMessage.edgeInsets.left = 16
style.incomingMessage.infoFont = .systemFont(ofSize: 0)
style.incomingMessage.nameFont = .systemFont(ofSize: 0)

style.outgoingMessage.avatarViewStyle = nil
style.outgoingMessage.backgroundColor = UIColor(red: 35/255, green: 126/255, blue: 254/255, alpha: 1)
style.outgoingMessage.borderColor = .clear
style.outgoingMessage.textColor = .white
style.outgoingMessage.font = .systemFont(ofSize: 17, weight: .regular)
style.outgoingMessage.edgeInsets.right = 16
style.outgoingMessage.infoFont = .systemFont(ofSize: 0)

style.composer.backgroundColor = .white
style.composer.placeholderTextColor = UIColor.gray.withAlphaComponent(0.5)
style.composer.placeholderText = "iMessage"
style.composer.height = 40
style.composer.font = .systemFont(ofSize: 18)
style.composer.cornerRadius = style.composer.height / 2
let borderColor = UIColor.gray.withAlphaComponent(0.6)
let borderWidth: CGFloat = 1
style.composer.states = [.active: .init(tintColor: borderColor, borderWidth: borderWidth),
                            .edit: .init(tintColor: borderColor, borderWidth: borderWidth),
                            .disabled: .init(tintColor: borderColor, borderWidth: borderWidth),
                            .normal: .init(tintColor: borderColor, borderWidth: borderWidth)]
style.composer.edgeInsets.left = 60
style.composer.edgeInsets.right = 16

If you run the app now, you’ll see colors and message bubbles look like those of iMessage, but the navigation bar title is buggy, as it displays a significantly larger title. In addition, we are missing some UI buttons that iMessage has built-in.

To fix the “large title situation” (and implement our custom navigation item title), we have to subclass Stream Chat’s ChatViewController.

Create a new swift file and name it MessagesViewController.swift. Then, paste in the contents:

import UIKit
import StreamChat
import RxSwift

class MessagesViewController: ChatViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.largeTitleDisplayMode = .never
        setupNavigationBar()
    }
    
    private func setupNavigationBar() {
        guard let channel = presenter?.channel else {
            return
        }
        
        navigationItem.rightBarButtonItem = nil
        
        let chatNavigationTitleView = ChatNavigationTitleView()
        chatNavigationTitleView.update(title: channel.name ?? "", imageURL: channel.imageURL)
        navigationItem.titleView = chatNavigationTitleView
    }
}

class ChatNavigationTitleView: UIView {
    private let avatar = AvatarView(cornerRadius: 12)
    private let titleLabel = UILabel()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }
    
    private func setupUI() {
        addSubview(avatar)
        avatar.translatesAutoresizingMaskIntoConstraints = false
        
        addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.font = .systemFont(ofSize: 12)
        
        NSLayoutConstraint.activate([
            avatar.centerXAnchor.constraint(equalTo: centerXAnchor),
            avatar.topAnchor.constraint(equalTo: topAnchor),
            avatar.leftAnchor.constraint(equalTo: leftAnchor),
            avatar.rightAnchor.constraint(equalTo: rightAnchor),
            
            titleLabel.topAnchor.constraint(equalTo: avatar.bottomAnchor, constant: 0),
            titleLabel.centerXAnchor.constraint(equalTo: avatar.centerXAnchor),
            titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])
    }
    
    func update(title: String, imageURL: URL?) {
        titleLabel.text = title
        titleLabel.sizeToFit()
        avatar.update(with: imageURL, name: title, baseColor: .clear)
    }
}

We’ve added a new class ChatNavigationTitleView to imitate iMessage’s title view. It’s not the same, since iOS restricts our access to the navigation bar, but it’s close enough! We could’ve used a custom navigation bar to imitate 1-1, but it’d be another tutorial, then…

To use our new MessagesViewController, we need to add this into our ContactsViewController:

override func createChatViewController(with channelPresenter: ChannelPresenter) -> ChatViewController {
    MessagesViewController(nibName: nil, bundle: nil)
}

This completes our work in ContactsViewController 🎉 If you run the app now, you'll see that our messages screen looks a lot more like iMessage now. But we still have some UI buttons to add to make it look more like iMessage. Let's add them now.

Add these in your MessagesViewController as properties:

private let cameraButton = UIButton()
private let soundRecordButton = UIButton()

Then, add the setupUI function:

private func setupUI() {
    view.addSubview(cameraButton)
    cameraButton.translatesAutoresizingMaskIntoConstraints = false
    cameraButton.setImage(UIImage(systemName: "camera.fill",
                                    withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 24))),
                            for: .normal)
    cameraButton.tintColor = UIColor.gray.withAlphaComponent(0.7)
    
    view.addSubview(soundRecordButton)
    soundRecordButton.translatesAutoresizingMaskIntoConstraints = false
    soundRecordButton.setImage(UIImage(systemName: "waveform.circle.fill",
                                        withConfiguration: UIImage.SymbolConfiguration(font: .systemFont(ofSize: 30, weight: .bold))),
                            for: .normal)
    soundRecordButton.tintColor = UIColor.gray.withAlphaComponent(0.7)
    
    NSLayoutConstraint.activate([
        cameraButton.centerYAnchor.constraint(equalTo: composerView.centerYAnchor),
        cameraButton.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 16),
        cameraButton.heightAnchor.constraint(equalToConstant: 40),
        
        soundRecordButton.centerYAnchor.constraint(equalTo: composerView.centerYAnchor),
        soundRecordButton.rightAnchor.constraint(equalTo: composerView.rightAnchor, constant: -2),
    ])
    
    composerView
        .sendButtonVisibility
        .asDriver(onErrorJustReturn: (isHidden: false, isEnabled: false))
        .drive(onNext: { [weak self] state in
            self?.soundRecordButton.isHidden = !state.isHidden
        }).disposed(by: disposeBag)
}

Lastly, add this call to your viewDidLoad (as the last call):

setupUI()

and now run the app:

iMessage Clone

Wrapping Up!

We have now completed the messaging section of our iMessage imitator, using Stream Chat’s API! If you want to take this further, make sure to check the Stream docs and our iOS chat SDKto find out all the features that are available to you.

This concludes our tutorial, happy coding! 🎉

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->