消息显示在错误的一侧且 size/content 不正确 (MessageKit)

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

我正在开发的应用程序中使用 MessageKit 作为聊天功能,在向 messagesCollectionView 中插入部分时遇到了一个奇怪的布局错误。打开对话时,线程会从缓存中正确加载,但是当加载下一页消息(数据源已分页)并且消息在批量更新中添加到 UICollectionView 时(请参见下面的代码),消息出错,出现在错误的索引处、错误的大小、错误的一侧,偶尔还会出现错误的文本。

我尝试过更改添加消息的方式,但我尝试过的所有方式都导致了这种情况...该应用程序的先前版本通过以下方式完全重新加载了 messagesCollectionView reloadData(),但即使使用该实现,这个错误在第一次打开线程时发生(看似 仅在 重新加载数据源并且集合视图一直滚动到底部。我对 MessageKitUICollectionView 的了解还不够,无法解决这个问题,希望对他们的复杂性有更好了解的人会发现这个错误!

//
//  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
    }

    // 
    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 ?? "?")"))
        }
    }
}

这里的问题应该相当明显,但是标记为 'Jai' 的消息应该出现在左侧,消息气泡应该包含它们包含的文本。

更新: 似乎 MessageKit 正在计算消息大小和消息的发件人在数据源中的一个索引,然后从正确的索引中获取文本index...虽然不知道发生了什么。

在使用不同的策略将部分插入 UICollectionView 几个小时后,在没有出现这种错误行为的情况下,我将 UICollectionView 颠倒过来,现在在底部添加部分,效果很好。我最初避免使用这种方法是因为害怕引入大消息线程的性能问题(我在几个论坛上读到过),但到目前为止还没有 运行 遇到任何这样的问题。如果有人遇到与我上面描述的问题类似的问题并试图将部分插入 UICollectionView 的顶部,我建议像我一样翻转 UICollectionView。它使整个过程变得更加容易。

(对于那些好奇的人,上述问题是由于更新数据源并将部分插入 UICollectionView,导致单元格重新加载,并在此过程中从数据源中的错误索引获取数据...我确定有一种方法可以纠正这个问题,但如果翻转 UICollectionView 与你的应用程序设计一起工作,它会更容易,也不会那么麻烦)