iosswiftuicollectionviewmessagekit

Messages showing up on wrong side and with incorrect size/content (MessageKit)


I am using MessageKit for the chat feature in an app I am developing, and am encountering a strange layout bug when inserting sections into the messagesCollectionView. When opening a conversation, the thread loads correctly from cache, but when loading the following page of messages (the data source is paginated) and messages are added to the UICollectionView in a batch update (see the code below), the messages bug out, appearing at the wrong indices, with the wrong size, on the wrong side, and occasionally with the wrong text.

I've tried changing the way I add messages, but all of the ways I've tried have resulted in this... A previous version of the app did a full reload of the messagesCollectionView via reloadData(), but even with that implementation, this bug occurred when first opening the thread (seemingly only when the data source was reloaded and the collection view was scrolled all the way to the bottom. I don't know enough about MessageKit and UICollectionView's to figure this out, hoping someone who understands their intricacies a little better will spot the bug!

//
//  ThreadDetailViewController.swift
//  --
//
//  Created by Jai Smith on 7/18/19.
//  Copyright © 2019 --. All rights reserved.
//

import UIKit
import MessageKit
import InputBarAccessoryView
import Alamofire
import os.log
import Kingfisher

class ThreadDetailViewController: MessagesViewController {

    // MARK: Properties

    var thread: MessageThread!
    var messages: Int = 0
    var reachedEnd: Bool = false
    var loadLock: Bool = false
    var page: PageManager?

    var scrollingUp: Bool = false
    var shouldFetchNextPage: Bool = false

    var messagesToAdd: Int?
    var insertingSections: Bool = false

    // MARK: Overrides

    override func viewDidLoad() {
        super.viewDidLoad()

        // set title
        self.title = thread.title

        // add notification observers
        NotificationCenter.default.addObserver(self, selector: #selector(receivedMessage), name: Notification.Name("receivedMessage"), object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(socketStateChanged), name: Notification.Name("sockState"), object: nil)

        // scroll to bottom when typing
        self.scrollsToBottomOnKeyboardBeginsEditing = true

        // set delegate/source
        messagesCollectionView.messagesDisplayDelegate = self
        messagesCollectionView.messagesDataSource = self
        messagesCollectionView.messagesLayoutDelegate = self
        messagesCollectionView.contentInset = UIEdgeInsets(top: 2.5, left: 0, bottom: 2.5, right: 0)
        messageInputBar = CustomInputBar()
        messageInputBar.delegate = self
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // start scrolled all the way to the bottom of already loaded messages
        messages = thread.messages.count
        messagesCollectionView.reloadData()
        messagesCollectionView.scrollToBottom(animated: false)

        // load messages
        self.loadMessages()
    }

    override func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        if indexPath.section == 0, let amount = messagesToAdd {
            animateMessageInsertion(amount: amount)
            self.messagesToAdd = nil
        }
    }

    // MARK: UIScrollViewDelegate

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollingUp = scrollView.panGestureRecognizer.translation(in: scrollView.superview).y > 0
    }

    // MARK: Navigation

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        super.prepare(for: segue, sender: sender)

        switch segue.identifier {
        case "ProfileDetail":
            if let profileTableViewController = segue.destination as? ProfileTableViewController {
                profileTableViewController.user = getConversants().first
            }

        default:
            fatalError("Unexpected Segue Identifier: \(segue.identifier ?? "nil")")
        }
    }

    // MARK: Public Methods

    @objc func socketStateChanged(_ notification: Notification) {
        if let status = notification.object as? Int {
            if status == 0 {
                os_log("Connecting...", log: OSLog.default, type: .debug)
            } else if status == 1 {
                // enable send button if text is entered
                if !self.messageInputBar.inputTextView.text.isEmpty {
                    self.messageInputBar.sendButton.isEnabled = true
                }
            }
        }
    }

    @objc func receivedMessage(_ notification: Notification) {
        if let message = notification.object as? Message {
            self.thread.messages.append(message)
            self.messages += 1
            self.messagesCollectionView.insertSections([self.thread.messages.count - 1])
            self.messagesCollectionView.scrollToBottom(animated: true)
        }
    }

    @objc func loadMessages() {
        // lock (prevent this method from being called twice)
        self.loadLock = true

        Credentials.shared.session.request(API.shared.messaging.history, method: .get, parameters: ["hash": thread.hash], encoding: URLEncoding.default)
            .validate(statusCode: [200])
            .validate(contentType: ["application/json"])
            .responseJSON { response in
                defer {
                    // unlock
                    self.loadLock = false

                    if self.shouldFetchNextPage {
                        self.loadMoreMessages()
                    }
                }

                switch response.result {
                case .success:
                    guard let data = response.data, let page = try? JSONDecoder().decode(PageManager.self, from: data) else {
                        os_log("Error deserializing PageManager for ThreadDetail", log: OSLog.default, type: .error)

                        return
                    }

                    // decode messages
                    var messages = [Message]()
                    while !page.results.isAtEnd {
                        do {
                            let message = try page.results.decode(Message.self)
                            messages.append(message)
                        } catch {
                            os_log("Error deserializing Message", log: OSLog.default, type: .error)
                            _ = try? page.results.decode(AnyCodable.self)
                        }
                    }

                    self.page = page
                    self.thread.messages = messages.reversed()
                    self.messages = self.thread.messages.count
                    self.messagesCollectionView.reloadData()
                    self.messagesCollectionView.scrollToBottom(animated: false)

                case .failure:
                    os_log("Error loading messages for thread", log: OSLog.default, type: .error)
                }
        }
    }

    // MARK: Private Methods

    private func getConversants() -> [User] {
        var conversants = [User]()
        for user in thread.participants.filter({ user in return user.id != Credentials.shared.user.id }) {
            conversants.append(user)
        }

        if conversants.isEmpty {
            conversants.append(Credentials.shared.user)
        }

        return conversants
    }

    // https://stackoverflow.com/a/32691888/11722138
    private func animateMessageInsertion(amount: Int) {
        guard !insertingSections else {
            return
        }

        insertingSections = true

        let contentHeight = self.messagesCollectionView.contentSize.height
        let offsetY = self.messagesCollectionView.contentOffset.y
        let bottomOffset = contentHeight - offsetY

        CATransaction.begin()
        CATransaction.setDisableActions(true)

        self.messagesCollectionView.performBatchUpdates({
            if amount > 0 {
                self.messages += amount
                self.messagesCollectionView.insertSections(IndexSet(integersIn: self.thread.messages.count - amount..<self.thread.messages.count))
            }
        }, completion: { finished in
            defer {
                self.insertingSections = false
            }

            os_log("Finished inserting new messages, animating...", log: OSLog.default, type: .debug)
            self.messagesCollectionView.contentOffset = CGPoint(x: 0, y: self.messagesCollectionView.contentSize.height - bottomOffset)
            CATransaction.commit()
        })
    }

    private func loadMoreMessages() {
        guard !loadLock else {
            return
        }

        guard let page = page else {
            shouldFetchNextPage = true
            return
        }

        guard page.next != nil else {
            return
        }

        loadLock = true

        page.getNextPage(completion: { page in
            defer {
                self.loadLock = false
            }

            guard let page = page else {
                os_log("Error loading next page", log: OSLog.default, type: .error)

                return
            }

            // decode new messages
            var messages = [Message]()
            while !page.results.isAtEnd {
                do {
                    let message = try page.results.decode(Message.self)
                    messages.append(message)
                } catch {
                    os_log("Error deserializing Message", log: OSLog.default, type: .error)
                    _ = try? page.results.decode(AnyCodable.self)
                }
            }

            // append to messages array
            self.thread.messages.insert(contentsOf: messages.reversed(), at: 0)
            self.page = page

            // queue messages to be added
            self.messagesToAdd = messages.count

            // trigger insertion if top message is already visible
            if self.messagesCollectionView.indexPathsForVisibleItems.contains(where: { indexPath in return indexPath.section == 0 }), let amount = self.messagesToAdd {
                self.animateMessageInsertion(amount: amount)
                self.messagesToAdd = nil
            }
        })
    }
}

// MARK: Extensions

extension ThreadDetailViewController: MessagesDataSource {
    func currentSender() -> SenderType {
        return Credentials.shared.user
    }

    func messageForItem(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageType {
        // check if more messages should be loaded
        if !loadLock, indexPath.section < 4, page == nil ? true : scrollingUp {
            loadMoreMessages()
            loadLock = true
        }

        return thread.messages[indexPath.section]
    }

    func numberOfSections(in messagesCollectionView: MessagesCollectionView) -> Int {
        return messages
    }

    func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        if indexPath.section - 1 >= 0 {
            let prevMessage = self.thread.messages[indexPath.section - 1]
            if message.sentDate.timeIntervalSince(prevMessage.sentDate).isLess(than: 24 * 3600.0) {
                return nil
            }
        }

        return NSAttributedString(string: message.sentDate.displayFormatDate(), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)])
    }

    func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        if indexPath.section - 1 >= 0 {
            let prevMessage = self.thread.messages[indexPath.section - 1]
            if prevMessage.sender.senderId == message.sender.senderId && cellTopLabelAttributedText(for: message, at: indexPath) == nil {
                return nil
            }
        }

        return NSAttributedString(string: message.sender.displayName, attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)])
    }

    func messageBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? {
        if indexPath.section + 1 < numberOfSections(in: messagesCollectionView) {
            let nextMessage = self.thread.messages[indexPath.section + 1]
            if nextMessage.sentDate.timeIntervalSince(message.sentDate).isLessThanOrEqualTo(3600.0) {
                return nil
            }
        }

        return NSAttributedString(string: message.sentDate.displayFormatTime(), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption2)])
    }
}

extension ThreadDetailViewController: InputBarAccessoryViewDelegate {
    func inputBar(_ inputBar: InputBarAccessoryView, textViewTextDidChangeTo text: String) {
        if !SocketManager.shared.sock.isConnected {
            inputBar.sendButton.isEnabled = false
        }
    }

    func inputBar(_ inputBar: InputBarAccessoryView, didPressSendButtonWith text: String) {
        inputBar.inputTextView.text = ""
        SocketManager.shared.sendMessage(text, hash: thread.hash)
    }
}

extension ThreadDetailViewController: MessagesLayoutDelegate {
    func messagePadding(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIEdgeInsets {
        return UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
    }

    func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return messageTopLabelAttributedText(for: message, at: indexPath)?.size().height ?? 0
    }

    func messageBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return messageBottomLabelAttributedText(for: message, at: indexPath)?.size().height ?? 0
    }

    func cellTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat {
        return cellTopLabelAttributedText(for: message, at: indexPath)?.size().height ?? -5
    }
}

extension ThreadDetailViewController: MessagesDisplayDelegate {
    func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
        if let index = UserList.index, let id = (message.sender as? User)?.id, let avatarURL = index[id]?.avatar {
            var image: UIImage = UIImage(named: "placeholder")!
            KingfisherManager.shared.retrieveImage(with: avatarURL, completionHandler: { result in
                switch result {
                case .success:
                    do {
                        image = try result.get().image
                        messagesCollectionView.reloadItems(at: [indexPath])
                    } catch {
                        os_log("Couldn't retrieve image with Kingfisher", log: OSLog.default, type: .error)
                    }

                case .failure:
                    os_log("Failed to load profile image for %@ in Message", log: OSLog.default, type: .error, index[id]?.displayName ?? "nil")
                }
            })

            var initials: String
            if let user = index[id] {
                initials = "\(user.firstName.capitalized.first ?? " ")\(user.lastName.capitalized.first ?? " ")"
            } else {
                initials = "nil"
            }

            avatarView.set(avatar: Avatar(image: image, initials: initials))
        } else {
            avatarView.set(avatar: Avatar(image: UIImage(named: "placeholder"), initials: "\(message.sender.displayName.first ?? "?")"))
        }
    }
}

The issue here should be fairly obvious, but messages labeled 'Jai' should be appearing on the left, and the message bubbles should hug the text they contain.

Bug

UPDATE: Seems like MessageKit is calculating the message size and the sender for a message one index off in the datasource, and then grabbing the text from the correct index... Can't figure out where that's happening though.


Solution

  • After several hours screwing around with different strategies for inserting sections into the UICollectionView without this buggy behavior cropping up at one point or another, I've flipped the UICollectionView upside down and am now adding sections to the bottom, which works perfectly. I had originally avoided this approach for fear of introducing performance issues with large message threads (which I'd read about on several forums), but haven't run into any issues like this as of yet. If anyone is encountering issues similar to the ones I described above and is trying to insert sections into the top of a UICollectionView, I'd recommend flipping the UICollectionView as I did. It makes the whole process much easier.

    (For those curious, the issue above was derived from updating the data source and inserting sections into the UICollectionView, causing cells to reload, and in the process get data from the incorrect index in the data source... I'm sure there is a way to correct this, but if flipping the UICollectionView works with your app design it is much easier and less of a hassle)