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.
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.
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)