diff --git a/Cartfile.resolved b/Cartfile.resolved index 7acd3164..87c9aee3 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,3 +1,3 @@ -github "Quick/Nimble" "v8.0.1" -github "Quick/Quick" "v2.0.0" +github "Quick/Nimble" "v8.0.5" +github "Quick/Quick" "v2.2.0" github "nathantannar4/InputBarAccessoryView" "4.3.0" diff --git a/Documentation/QuickStart.md b/Documentation/QuickStart.md index 53afed5a..5d877594 100644 --- a/Documentation/QuickStart.md +++ b/Documentation/QuickStart.md @@ -15,17 +15,18 @@ public protocol MessageType { var kind: MessageKind { get } } ``` -First, each `MessageType` is required to have a `Sender` which contains two properties, `id` and `displayName`: +First, each `MessageType` is required to have a `SenderType` which contains two properties, `senderId` and `displayName`: ### Sender ```Swift -public struct Sender { +public protocol SenderType { - public let id: String - - public let displayName: String + var senderId: String { get } + + var displayName: String { get } } + ``` -**MessageKit** uses the `Sender` type to determine if a message was sent by the current user or to the current user. +**MessageKit** uses the `SenderType` type to determine if a message was sent by the current user or to the current user. Second, each message must have its own `messageId` which is a unique `String` identifier for the message. @@ -91,13 +92,20 @@ class ChatViewController: MessagesViewController { You must implement the following 3 methods to conform to `MessagesDataSource`: ```Swift + +public struct Sender: SenderType { + public let senderId: String + + public let displayName: String +} + // Some global variables for the sake of the example. Using globals is not recommended! let sender = Sender(id: "any_unique_id", displayName: "Steven") let messages: [MessageType] = [] extension ChatViewController: MessagesDataSource { - func currentSender() -> Sender { + func currentSender() -> SenderType { return Sender(id: "any_unique_id", displayName: "Steven") } diff --git a/Sources/Controllers/MessagesViewController+Keyboard.swift b/Sources/Controllers/MessagesViewController+Keyboard.swift index 9ebbecf1..2abbd030 100644 --- a/Sources/Controllers/MessagesViewController+Keyboard.swift +++ b/Sources/Controllers/MessagesViewController+Keyboard.swift @@ -45,9 +45,15 @@ internal extension MessagesViewController { @objc private func handleTextViewDidBeginEditing(_ notification: Notification) { - if scrollsToBottomOnKeyboardBeginsEditing { - guard let inputTextView = notification.object as? InputTextView, inputTextView === messageInputBar.inputTextView else { return } - messagesCollectionView.scrollToBottom(animated: true) + if scrollsToLastItemOnKeyboardBeginsEditing || scrollsToBottomOnKeyboardBeginsEditing { + guard let inputTextView = notification.object as? InputTextView, + inputTextView === messageInputBar.inputTextView else { return } + + if scrollsToLastItemOnKeyboardBeginsEditing { + messagesCollectionView.scrollToLastItem() + } else { + messagesCollectionView.scrollToBottom(animated: true) + } } } @@ -62,12 +68,12 @@ internal extension MessagesViewController { // ignore this notification. return } - + guard self.presentedViewController == nil else { // This is important to skip notifications from child modal controllers in iOS >= 13.0 return } - + // Note that the check above does not exclude all notifications from an undocked keyboard, only the weird ones. // // We've tried following Apple's recommended approach of tracking UIKeyboardWillShow / UIKeyboardDidHide and ignoring frame @@ -82,18 +88,18 @@ internal extension MessagesViewController { // We could make it work by adding extra checks for the state of the keyboard and compensating accordingly, but it seems easier // to simply check whether the current keyboard frame, whatever it is (even when undocked), covers the bottom of the collection // view. - + guard let keyboardEndFrameInScreenCoords = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardEndFrame = view.convert(keyboardEndFrameInScreenCoords, from: view.window) - + let newBottomInset = requiredScrollViewBottomInset(forKeyboardFrame: keyboardEndFrame) let differenceOfBottomInset = newBottomInset - messageCollectionViewBottomInset - + if maintainPositionOnKeyboardFrameChanged && differenceOfBottomInset != 0 { let contentOffset = CGPoint(x: messagesCollectionView.contentOffset.x, y: messagesCollectionView.contentOffset.y + differenceOfBottomInset) messagesCollectionView.setContentOffset(contentOffset, animated: false) } - + messageCollectionViewBottomInset = newBottomInset } @@ -116,7 +122,7 @@ internal extension MessagesViewController { // we only need to adjust for the part of the keyboard that covers (i.e. intersects) our collection view; // see https://developer.apple.com/videos/play/wwdc2017/242/ for more details let intersection = messagesCollectionView.frame.intersection(keyboardFrame) - + if intersection.isNull || (messagesCollectionView.frame.maxY - intersection.maxY) > 0.001 { // The keyboard is hidden, is a hardware one, or is undocked and does not cover the bottom of the collection view. // Note: intersection.maxY may be less than messagesCollectionView.frame.maxY when dealing with undocked keyboards. diff --git a/Sources/Controllers/MessagesViewController.swift b/Sources/Controllers/MessagesViewController.swift index bc4bc00e..e5122f16 100644 --- a/Sources/Controllers/MessagesViewController.swift +++ b/Sources/Controllers/MessagesViewController.swift @@ -36,10 +36,18 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { /// The `InputBarAccessoryView` used as the `inputAccessoryView` in the view controller. open lazy var messageInputBar = InputBarAccessoryView() + /// A Boolean value that determines whether the `MessagesCollectionView` scrolls to the + /// last item whenever the `InputTextView` begins editing. + /// + /// The default value of this property is `false`. + /// NOTE: This calls scrollToLastItem where as the below flag calls scrollToBottom - check methods for differences + open var scrollsToLastItemOnKeyboardBeginsEditing: Bool = false + /// A Boolean value that determines whether the `MessagesCollectionView` scrolls to the /// bottom whenever the `InputTextView` begins editing. /// /// The default value of this property is `false`. + /// NOTE: This calls scrollToBottome where as the above flag calls scrollToLastItem - check methods for differences open var scrollsToBottomOnKeyboardBeginsEditing: Bool = false /// A Boolean value that determines whether the `MessagesCollectionView` diff --git a/Sources/Views/MessagesCollectionView.swift b/Sources/Views/MessagesCollectionView.swift index 56cc313a..05515be2 100644 --- a/Sources/Views/MessagesCollectionView.swift +++ b/Sources/Views/MessagesCollectionView.swift @@ -100,8 +100,24 @@ open class MessagesCollectionView: UICollectionView { cell?.handleTapGesture(gesture) } + // NOTE: It's possible for small content size this wouldn't work - https://github.com/MessageKit/MessageKit/issues/725 + public func scrollToLastItem(at pos: UICollectionView.ScrollPosition = .bottom, animated: Bool = true) { + guard numberOfSections > 0 else { return } + + let lastSection = numberOfSections - 1 + let lastItemIndex = numberOfItems(inSection: lastSection) - 1 + + guard lastItemIndex >= 0 else { return } + + let indexPath = IndexPath(row: lastItemIndex, section: lastSection) + scrollToItem(at: indexPath, at: pos, animated: animated) + } + + // NOTE: This method seems to cause crash in certain cases - https://github.com/MessageKit/MessageKit/issues/725 + // Could try using `scrollToLastItem` above public func scrollToBottom(animated: Bool = false) { - performBatchUpdates(nil) { _ in + performBatchUpdates(nil) { [weak self] _ in + guard let self = self else { return } let collectionViewContentHeight = self.collectionViewLayout.collectionViewContentSize.height self.scrollRectToVisible(CGRect(0.0, collectionViewContentHeight - 1.0, 1.0, 1.0), animated: animated) } diff --git a/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift b/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift index 6cbfb632..717d154e 100644 --- a/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift +++ b/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift @@ -71,7 +71,7 @@ class MessagesDisplayDelegateTests: XCTestCase { at: IndexPath(item: 0, section: 0), in: sut.messagesCollectionView) - XCTAssertEqual(backgroundColor, .white) + XCTAssertEqual(backgroundColor, .backgroundColor) } func testBackgroundColorForMessageWithEmoji_returnsClearForDefault() { @@ -143,7 +143,7 @@ class TextMessageDisplayDelegateTests: XCTestCase { at: IndexPath(item: 0, section: 0), in: sut.messagesCollectionView) - XCTAssertEqual(textColor, .white) + XCTAssertEqual(textColor, .backgroundColor) } func testTextColorFromYou_returnsDarkTextForDefault() { @@ -151,7 +151,7 @@ class TextMessageDisplayDelegateTests: XCTestCase { at: IndexPath(item: 0, section: 0), in: sut.messagesCollectionView) - XCTAssertEqual(textColor, .darkText) + XCTAssertEqual(textColor, .labelColor) } func testTextColorWithoutDataSource_returnsDarkTextForDefault() { @@ -161,7 +161,7 @@ class TextMessageDisplayDelegateTests: XCTestCase { at: IndexPath(item: 0, section: 0), in: sut.messagesCollectionView) - XCTAssertEqual(textColor, .darkText) + XCTAssertEqual(textColor, .labelColor) } func testEnableDetectors_returnsEmptyForDefault() { diff --git a/Tests/ViewsTests/AvatarViewTests.swift b/Tests/ViewsTests/AvatarViewTests.swift index e919f141..602632c7 100644 --- a/Tests/ViewsTests/AvatarViewTests.swift +++ b/Tests/ViewsTests/AvatarViewTests.swift @@ -42,7 +42,9 @@ class AvatarViewTests: XCTestCase { func testNoParams() { XCTAssertEqual(avatarView.layer.cornerRadius, 15.0) - XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) + // For certain dynamic colors, need to compare cgColor in XCTest + // https://stackoverflow.com/questions/58065340/how-to-compare-two-uidynamicprovidercolor + XCTAssertEqual(avatarView.backgroundColor!.cgColor, UIColor.grayColor.cgColor) } func testWithImage() { @@ -50,7 +52,7 @@ class AvatarViewTests: XCTestCase { avatarView.set(avatar: avatar) XCTAssertEqual(avatar.initials, "?") XCTAssertEqual(avatarView.layer.cornerRadius, 15.0) - XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) + XCTAssertEqual(avatarView.backgroundColor!.cgColor, UIColor.grayColor.cgColor) } func testInitialsOnly() { @@ -59,13 +61,13 @@ class AvatarViewTests: XCTestCase { XCTAssertEqual(avatarView.initials, avatar.initials) XCTAssertEqual(avatar.initials, "DL") XCTAssertEqual(avatarView.layer.cornerRadius, 15.0) - XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) + XCTAssertEqual(avatarView.backgroundColor!.cgColor, UIColor.grayColor.cgColor) } func testSetBackground() { - XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) + XCTAssertEqual(avatarView.backgroundColor!.cgColor, UIColor.grayColor.cgColor) avatarView.backgroundColor = UIColor.red - XCTAssertEqual(avatarView.backgroundColor, UIColor.red) + XCTAssertEqual(avatarView.backgroundColor!, UIColor.red) } func testGetImage() {