diff --git a/Example/Sources/Layout/CustomMessageFlowLayout.swift b/Example/Sources/Layout/CustomMessageFlowLayout.swift index 70df0376..74b0633e 100644 --- a/Example/Sources/Layout/CustomMessageFlowLayout.swift +++ b/Example/Sources/Layout/CustomMessageFlowLayout.swift @@ -30,9 +30,9 @@ open class CustomMessagesFlowLayout: MessagesCollectionViewFlowLayout { open lazy var customMessageSizeCalculator = CustomMessageSizeCalculator(layout: self) open override func cellSizeCalculatorForItem(at indexPath: IndexPath) -> CellSizeCalculator { -// if isSectionReservedForTypingBubble(indexPath.section) { -// return typingMessageSizeCalculator -// } + if isSectionReservedForTypingIndicator(indexPath.section) { + return typingIndicatorSizeCalculator + } let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) if case .custom = message.kind { return customMessageSizeCalculator diff --git a/Example/Sources/View Controllers/AdvancedExampleViewController.swift b/Example/Sources/View Controllers/AdvancedExampleViewController.swift index 88955154..3eb41451 100644 --- a/Example/Sources/View Controllers/AdvancedExampleViewController.swift +++ b/Example/Sources/View Controllers/AdvancedExampleViewController.swift @@ -222,10 +222,10 @@ final class AdvancedExampleViewController: ChatViewController { fatalError("Ouch. nil data source for messages") } -// guard !isSectionReservedForTypingBubble(indexPath.section) else { -// return super.collectionView(collectionView, cellForItemAt: indexPath) -// } - + guard !isSectionReservedForTypingIndicator(indexPath.section) else { + return super.collectionView(collectionView, cellForItemAt: indexPath) + } + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) if case .custom = message.kind { let cell = messagesCollectionView.dequeueReusableCell(CustomCell.self, for: indexPath) diff --git a/MessageKit.xcodeproj/project.pbxproj b/MessageKit.xcodeproj/project.pbxproj index 9b277bfa..64c83376 100644 --- a/MessageKit.xcodeproj/project.pbxproj +++ b/MessageKit.xcodeproj/project.pbxproj @@ -35,20 +35,20 @@ 1FF377AA20087D78004FD648 /* MessagesViewController+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF377A920087D78004FD648 /* MessagesViewController+Menu.swift */; }; 1FF377AC20087DA2004FD648 /* MessagesViewController+Keyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF377AB20087DA2004FD648 /* MessagesViewController+Keyboard.swift */; }; 382C794221705D2000F4FAF5 /* HorizontalEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C794121705D2000F4FAF5 /* HorizontalEdgeInsets.swift */; }; - 4C508649221C0BBA0043943C /* AccessoryPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C508648221C0BBA0043943C /* AccessoryPosition.swift */; }; 383B9EB121728BAD008AB91A /* SenderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383B9EB021728BAD008AB91A /* SenderType.swift */; }; + 388119462253EC30004B26AF /* TypingIndicatorCellSizeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388119452253EC30004B26AF /* TypingIndicatorCellSizeCalculator.swift */; }; 38A2230F221FB8A300D14DAF /* MessageInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A2230E221FB8A300D14DAF /* MessageInputBar.swift */; }; 38A223112223493500D14DAF /* InputBarAccessoryView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38A223102223493400D14DAF /* InputBarAccessoryView.framework */; }; - 38C2AE7C20D4878D00F8079E /* MessageInputBar.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38C2AE7B20D4878D00F8079E /* MessageInputBar.framework */; }; 38F8062F2173CD8F00CDB9DB /* MockUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8062D2173CD4300CDB9DB /* MockUser.swift */; }; + 38F8063221740D9E00CDB9DB /* TypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063021740D9D00CDB9DB /* TypingIndicator.swift */; }; + 38F8063321740D9E00CDB9DB /* BubbleCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063121740D9D00CDB9DB /* BubbleCircle.swift */; }; + 38F8063521740DAD00CDB9DB /* TypingBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063421740DAD00CDB9DB /* TypingBubble.swift */; }; + 38F8063721740DD500CDB9DB /* TypingIndicatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063621740DD500CDB9DB /* TypingIndicatorCell.swift */; }; + 4C508649221C0BBA0043943C /* AccessoryPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C508648221C0BBA0043943C /* AccessoryPosition.swift */; }; 5073C1152175BE750040EAD5 /* AudioItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C1142175BE750040EAD5 /* AudioItem.swift */; }; 5073C1192175BE960040EAD5 /* AudioMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C1182175BE950040EAD5 /* AudioMessageCell.swift */; }; 5073C11D2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */; }; 5073C1232175C1980040EAD5 /* sound1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5073C1222175C1980040EAD5 /* sound1.m4a */; }; - 38F8063221740D9E00CDB9DB /* TypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063021740D9D00CDB9DB /* TypingIndicator.swift */; }; - 38F8063321740D9E00CDB9DB /* BubbleCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063121740D9D00CDB9DB /* BubbleCircle.swift */; }; - 38F8063521740DAD00CDB9DB /* TypingBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063421740DAD00CDB9DB /* TypingBubble.swift */; }; - 38F8063721740DD500CDB9DB /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8063621740DD500CDB9DB /* TypingIndicatorView.swift */; }; 88916B2D1CF0DF2F00469F91 /* MessageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88916B221CF0DF2F00469F91 /* MessageKit.framework */; }; 8962AC8A1F87AB7D0030B058 /* MessagesCollectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8962AC831F87AB230030B058 /* MessagesCollectionViewTests.swift */; }; 8962AC8C1F87AB7D0030B058 /* AvatarViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8962AC851F87AB230030B058 /* AvatarViewTests.swift */; }; @@ -144,19 +144,20 @@ 1FF377AB20087DA2004FD648 /* MessagesViewController+Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesViewController+Keyboard.swift"; sourceTree = ""; }; 382C794121705D2000F4FAF5 /* HorizontalEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalEdgeInsets.swift; sourceTree = ""; }; 383B9EB021728BAD008AB91A /* SenderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SenderType.swift; sourceTree = ""; }; + 388119452253EC30004B26AF /* TypingIndicatorCellSizeCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCellSizeCalculator.swift; sourceTree = ""; }; 38A2230E221FB8A300D14DAF /* MessageInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputBar.swift; sourceTree = ""; }; 38A223102223493400D14DAF /* InputBarAccessoryView.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = InputBarAccessoryView.framework; path = Carthage/Build/iOS/InputBarAccessoryView.framework; sourceTree = ""; }; 38C2AE7B20D4878D00F8079E /* MessageInputBar.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageInputBar.framework; path = Carthage/Build/iOS/MessageInputBar.framework; sourceTree = ""; }; - 4C508648221C0BBA0043943C /* AccessoryPosition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessoryPosition.swift; sourceTree = ""; }; 38F8062D2173CD4300CDB9DB /* MockUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUser.swift; sourceTree = ""; }; + 38F8063021740D9D00CDB9DB /* TypingIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicator.swift; sourceTree = ""; }; + 38F8063121740D9D00CDB9DB /* BubbleCircle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BubbleCircle.swift; sourceTree = ""; }; + 38F8063421740DAD00CDB9DB /* TypingBubble.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingBubble.swift; sourceTree = ""; }; + 38F8063621740DD500CDB9DB /* TypingIndicatorCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorCell.swift; sourceTree = ""; }; + 4C508648221C0BBA0043943C /* AccessoryPosition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessoryPosition.swift; sourceTree = ""; }; 5073C1142175BE750040EAD5 /* AudioItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioItem.swift; sourceTree = ""; }; 5073C1182175BE950040EAD5 /* AudioMessageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioMessageCell.swift; sourceTree = ""; }; 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioMessageSizeCalculator.swift; sourceTree = ""; }; 5073C1222175C1980040EAD5 /* sound1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sound1.m4a; sourceTree = ""; }; - 38F8063021740D9D00CDB9DB /* TypingIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicator.swift; sourceTree = ""; }; - 38F8063121740D9D00CDB9DB /* BubbleCircle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BubbleCircle.swift; sourceTree = ""; }; - 38F8063421740DAD00CDB9DB /* TypingBubble.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingBubble.swift; sourceTree = ""; }; - 38F8063621740DD500CDB9DB /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; 88916B221CF0DF2F00469F91 /* MessageKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MessageKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 88916B2C1CF0DF2F00469F91 /* MessageKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MessageKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8962AC741F87AB230030B058 /* MessageKitDateFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageKitDateFormatterTests.swift; sourceTree = ""; }; @@ -249,6 +250,7 @@ B7A03F7A1F866B85006AEF79 /* MessageCollectionViewCell.swift */, 1F6C040B206A2891007BDE44 /* MessageContentCell.swift */, B7A03F361F866946006AEF79 /* TextMessageCell.swift */, + 38F8063621740DD500CDB9DB /* TypingIndicatorCell.swift */, ); path = Cells; sourceTree = ""; @@ -256,7 +258,6 @@ 2EB618F11F846899007FBA0E /* Headers & Footers */ = { isa = PBXGroup; children = ( - 38F8063621740DD500CDB9DB /* TypingIndicatorView.swift */, 1F6C040D206A2AF4007BDE44 /* MessageReusableView.swift */, ); path = "Headers & Footers"; @@ -475,6 +476,7 @@ 1FE783A5206629C2007FA024 /* LocationMessageSizeCalculator.swift */, 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */, 0EF0888B206F7E83007F2F58 /* CellSizeCalculator.swift */, + 388119452253EC30004B26AF /* TypingIndicatorCellSizeCalculator.swift */, ); path = Layout; sourceTree = ""; @@ -630,6 +632,7 @@ 5073C11D2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift in Sources */, 1FF377A420087C82004FD648 /* MessageKitError.swift in Sources */, 1F6C040E206A2AF4007BDE44 /* MessageReusableView.swift in Sources */, + 388119462253EC30004B26AF /* TypingIndicatorCellSizeCalculator.swift in Sources */, B7A03F4B1F86694F006AEF79 /* MessageContainerView.swift in Sources */, B7A03F281F866895006AEF79 /* LocationMessageSnapshotOptions.swift in Sources */, B7A03F6C1F8669EB006AEF79 /* UIView+Extensions.swift in Sources */, @@ -640,7 +643,7 @@ B7A03F461F86694F006AEF79 /* AvatarView.swift in Sources */, 1FCA6D30201C1CC900BC3480 /* UIEdgeInsets+Extensions.swift in Sources */, B7A03F3D1F866946006AEF79 /* MediaMessageCell.swift in Sources */, - 38F8063721740DD500CDB9DB /* TypingIndicatorView.swift in Sources */, + 38F8063721740DD500CDB9DB /* TypingIndicatorCell.swift in Sources */, 1FE783A220662905007FA024 /* TextMessageSizeCalculator.swift in Sources */, B7A03F2E1F866895006AEF79 /* MessageKind.swift in Sources */, B7A03F7B1F866B85006AEF79 /* MessageCollectionViewCell.swift in Sources */, diff --git a/Sources/Controllers/MessagesViewController.swift b/Sources/Controllers/MessagesViewController.swift index 2851d529..f9336f19 100644 --- a/Sources/Controllers/MessagesViewController.swift +++ b/Sources/Controllers/MessagesViewController.swift @@ -70,6 +70,10 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { } } + public var isTypingIndicatorHidden: Bool { + return messagesCollectionView.isTypingIndicatorHidden + } + public var selectedIndexPathForMenu: IndexPath? private var isFirstLayout: Bool = true @@ -183,7 +187,46 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { /// when `animated` is `FALSE` /// - completion: A completion block to execute after the insertion/deletion open func setTypingIndicatorViewHidden(_ isHidden: Bool, animated: Bool, whilePerforming updates: (() -> Void)? = nil, completion: ((Bool) -> Void)? = nil) { - messagesCollectionView.setTypingIndicatorViewHidden(isHidden, animated: animated, whilePerforming: updates, completion: completion) + + guard isTypingIndicatorHidden != isHidden else { + completion?(false) + return + } + + let section = messagesCollectionView.numberOfSections + messagesCollectionView.setTypingIndicatorViewHidden(isHidden) + + if animated { + messagesCollectionView.performBatchUpdates({ [weak self] in + self?.performUpdatesForTypingIndicatorVisability(at: section) + updates?() + }, completion: completion) + } else { + performUpdatesForTypingIndicatorVisability(at: section) + updates?() + completion?(true) + } + } + + /// Performs a delete or insert on the `MessagesCollectionView` on the provided section + /// + /// - Parameter section: The index to modify + private func performUpdatesForTypingIndicatorVisability(at section: Int) { + if isTypingIndicatorHidden { + messagesCollectionView.deleteSections([section - 1]) + } else { + messagesCollectionView.insertSections([section]) + } + } + + /// A method that by default checks if the section is the last in the + /// `messagesCollectionView` and that `isTypingIndicatorViewHidden` + /// is FALSE + /// + /// - Parameter section + /// - Returns: A Boolean indicating if the TypingIndicator should be presented at the given section + public func isSectionReservedForTypingIndicator(_ section: Int) -> Bool { + return !messagesCollectionView.isTypingIndicatorHidden && section == self.numberOfSections(in: messagesCollectionView) - 1 } // MARK: - UICollectionViewDataSource @@ -192,18 +235,27 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { guard let collectionView = collectionView as? MessagesCollectionView else { fatalError(MessageKitError.notMessagesCollectionView) } - return collectionView.messagesDataSource?.numberOfSections(in: collectionView) ?? 0 + let sections = collectionView.messagesDataSource?.numberOfSections(in: collectionView) ?? 0 + return collectionView.isTypingIndicatorHidden ? sections : sections + 1 } open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { guard let collectionView = collectionView as? MessagesCollectionView else { fatalError(MessageKitError.notMessagesCollectionView) } + if isSectionReservedForTypingIndicator(section) { + return 1 + } return collectionView.messagesDataSource?.numberOfItems(inSection: section, in: collectionView) ?? 0 } - /// Note: - /// If you override this method, remember to call MessagesDataSource's customCell(for:at:in:) for MessageKind.custom messages, if necessary + /// Notes: + /// - If you override this method, remember to call MessagesDataSource's customCell(for:at:in:) + /// for MessageKind.custom messages, if necessary. + /// + /// - If you are using the typing indicator you will need to ensure that the section is not + /// reserved for it with `isSectionReservedForTypingIndicator` defined in + /// `MessagesCollectionViewFlowLayout` open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let messagesCollectionView = collectionView as? MessagesCollectionView else { @@ -214,6 +266,10 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { fatalError(MessageKitError.nilMessagesDataSource) } + if isSectionReservedForTypingIndicator(indexPath.section) { + return messagesDataSource.typingIndicator(at: indexPath, in: messagesCollectionView) + } + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) switch message.kind { @@ -253,8 +309,6 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { return displayDelegate.messageHeaderView(for: indexPath, in: messagesCollectionView) case UICollectionView.elementKindSectionFooter: return displayDelegate.messageFooterView(for: indexPath, in: messagesCollectionView) - case MessagesCollectionView.elementKindTypingIndicator: - return displayDelegate.typingIndicatorView(for: indexPath, in: messagesCollectionView) default: fatalError(MessageKitError.unrecognizedSectionKind) } @@ -275,9 +329,17 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { guard let layoutDelegate = messagesCollectionView.messagesLayoutDelegate else { fatalError(MessageKitError.nilMessagesLayoutDelegate) } + if isSectionReservedForTypingIndicator(section) { + return .zero + } return layoutDelegate.headerViewSize(for: section, in: messagesCollectionView) } + open func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard let cell = cell as? TypingIndicatorCell else { return } + cell.typingBubble.startAnimating() + } + open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { guard let messagesCollectionView = collectionView as? MessagesCollectionView else { fatalError(MessageKitError.notMessagesCollectionView) @@ -285,11 +347,19 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { guard let layoutDelegate = messagesCollectionView.messagesLayoutDelegate else { fatalError(MessageKitError.nilMessagesLayoutDelegate) } + if isSectionReservedForTypingIndicator(section) { + return .zero + } return layoutDelegate.footerViewSize(for: section, in: messagesCollectionView) } open func collectionView(_ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath) -> Bool { guard let messagesDataSource = messagesCollectionView.messagesDataSource else { return false } + + if isSectionReservedForTypingIndicator(indexPath.section) { + return false + } + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) switch message.kind { @@ -302,6 +372,9 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { } open func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool { + if isSectionReservedForTypingIndicator(indexPath.section) { + return false + } return (action == NSSelectorFromString("copy:")) } diff --git a/Sources/Layout/MessagesCollectionViewFlowLayout.swift b/Sources/Layout/MessagesCollectionViewFlowLayout.swift index b1a1a03a..fc35fc12 100644 --- a/Sources/Layout/MessagesCollectionViewFlowLayout.swift +++ b/Sources/Layout/MessagesCollectionViewFlowLayout.swift @@ -29,22 +29,6 @@ import AVFoundation /// framework provided `MessageCollectionViewCell` subclasses. open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { - /// There is a known issue where the layout invalidation - /// causes a fatal crash when setting the typing indicator - /// view to hidden. The cause has been isolated to - /// `UICollectionViewFlowLayoutInvalidationContext` which - /// causes an `IndexPath` with 0 indices to be passed into - /// `layoutAttributesForSupplementaryView` when accessing - /// `.section`. The current work around is to not use - /// `invalidateLayout(with: context)` for the case of - /// setting the typing indicator to hidden but rather - /// `invalidateLayout()`. This however is not efficent - /// and thus will not be the default behaviour. Instead, - /// if you experience the crash set this value to TRUE. - /// - /// The default value is FALSE - public var invalidateLayoutOnTypingIndicatorHidden: Bool = false - open override class var layoutAttributesClass: AnyClass { return MessagesCollectionViewLayoutAttributes.self } @@ -78,18 +62,6 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { return collectionView.frame.width - sectionInset.left - sectionInset.right } - open override var collectionViewContentSize: CGSize { - let size = super.collectionViewContentSize - - guard !isTypingIndicatorViewHidden, let delegate = messagesCollectionView.messagesLayoutDelegate else { return size } - let typingIndicatorSize = delegate.typingIndicatorViewSize(in: messagesCollectionView) - let inset = delegate.typingIndicatorViewTopInset(in: messagesCollectionView) + 5 - return CGSize( - width: size.width, - height: size.height + typingIndicatorSize.height + inset - ) - } - public private(set) var isTypingIndicatorViewHidden: Bool = true // MARK: - Initializers @@ -122,45 +94,31 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { // MARK: - Typing Indicator API - /// Sets the typing indicator sate by inserting/deleting the `TypingIndicatorView` + /// Notifies the layout that the typing indicator will change state /// /// - Parameters: /// - isHidden: A Boolean value that is to be the new state of the typing indicator - /// - animated: A Boolean value determining if the insertion is to be animated - /// - updates: A block of code that will be executed during `performBatchUpdates` - /// when `animated` is `TRUE` or before the `completion` block executes - /// when `animated` is `FALSE` - /// - completion: A completion block to execute after the insertion/deletion - open func setTypingIndicatorViewHidden(_ isHidden: Bool, animated: Bool, whilePerforming updates: (() -> Void)? = nil, completion: ((Bool) -> Void)? = nil) { - - guard isTypingIndicatorViewHidden != isHidden, messagesCollectionView.numberOfSections > 0 else { - completion?(false) - return - } + open func setTypingIndicatorViewHidden(_ isHidden: Bool) { isTypingIndicatorViewHidden = isHidden + } - if animated { - messagesCollectionView.performBatchUpdates({ [weak self] in - self?.invalidateLayoutForTypingIndicatorChange() - updates?() - }, completion: completion) - } else { - invalidateLayoutForTypingIndicatorChange() - updates?() - completion?(true) - } + /// A method that by default checks if the section is the last in the + /// `messagesCollectionView` and that `isTypingIndicatorViewHidden` + /// is FALSE + /// + /// - Parameter section + /// - Returns: A Boolean indicating if the TypingIndicator should be presented at the given section + open func isSectionReservedForTypingIndicator(_ section: Int) -> Bool { + return !isTypingIndicatorViewHidden && section == messagesCollectionView.numberOfSections - 1 } // MARK: - Attributes open override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - guard var attributesArray = super.layoutAttributesForElements(in: rect) as? [MessagesCollectionViewLayoutAttributes] else { + guard let attributesArray = super.layoutAttributesForElements(in: rect) as? [MessagesCollectionViewLayoutAttributes] else { return nil } for attributes in attributesArray where attributes.representedElementCategory == .cell { - if let supplementaryAttributes = layoutAttributesForSupplementaryView(ofKind: MessagesCollectionView.elementKindTypingIndicator, at: attributes.indexPath) as? MessagesCollectionViewLayoutAttributes { - attributesArray.append(supplementaryAttributes) - } let cellSizeCalculator = cellSizeCalculatorForItem(at: attributes.indexPath) cellSizeCalculator.configure(attributes: attributes) } @@ -178,55 +136,6 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { return attributes } - open override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - switch elementKind { - case MessagesCollectionView.elementKindTypingIndicator: - - guard shouldDisplayTypingIndicatorView(at: indexPath) else { return nil } - guard let delegate = messagesCollectionView.messagesLayoutDelegate else { return nil } - let size = delegate.typingIndicatorViewSize(in: messagesCollectionView) - guard size != .zero else { return nil } - let inset = delegate.typingIndicatorViewTopInset(in: messagesCollectionView) - let attributes = MessagesCollectionViewLayoutAttributes(forSupplementaryViewOfKind: elementKind, with: indexPath) - - if let itemAttributes = layoutAttributesForItem(at: indexPath) { - attributes.frame = CGRect(x: itemAttributes.frame.origin.x, - y: itemAttributes.frame.maxY + inset, - width: size.width, - height: size.height) - } - return attributes - default: - return super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) - } - } - - public func shouldDisplayTypingIndicatorView(at indexPath: IndexPath) -> Bool { - guard indexPath.count > 0 else { - fatalError("`indexPath` contained 0 indices, set `invalidateLayoutOnTypingIndicatorHidden` to `TRUE`") - } - let isLastIndexPath = indexPath.section == messagesCollectionView.numberOfSections - 1 - return isLastIndexPath && !isTypingIndicatorViewHidden - } - - private func indexPathForTypingIndicatorView() -> IndexPath { - let section = messagesCollectionView.numberOfSections - 2 - return IndexPath(item: 0, section: max(section, 0)) - } - - private func invalidateLayoutForTypingIndicatorChange() { - if !isTypingIndicatorViewHidden || !invalidateLayoutOnTypingIndicatorHidden { - let ctx = UICollectionViewFlowLayoutInvalidationContext() - ctx.invalidateSupplementaryElements( - ofKind: MessagesCollectionView.elementKindTypingIndicator, - at: [indexPathForTypingIndicatorView()] - ) - invalidateLayout(with: ctx) - } else { - invalidateLayout() - } - } - // MARK: - Layout Invalidation open override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { @@ -245,20 +154,6 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { invalidateLayout() } - open override func indexPathsToInsertForSupplementaryView(ofKind elementKind: String) -> [IndexPath] { - guard elementKind == MessagesCollectionView.elementKindTypingIndicator else { - return super.indexPathsToInsertForSupplementaryView(ofKind: elementKind) - } - return [indexPathForTypingIndicatorView()] - } - - open override func indexPathsToDeleteForSupplementaryView(ofKind elementKind: String) -> [IndexPath] { - guard elementKind == MessagesCollectionView.elementKindTypingIndicator else { - return super.indexPathsToDeleteForSupplementaryView(ofKind: elementKind) - } - return [indexPathForTypingIndicatorView()] - } - // MARK: - Cell Sizing lazy open var textMessageSizeCalculator = TextMessageSizeCalculator(layout: self) @@ -272,10 +167,17 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { lazy open var videoMessageSizeCalculator = MediaMessageSizeCalculator(layout: self) lazy open var locationMessageSizeCalculator = LocationMessageSizeCalculator(layout: self) lazy open var audioMessageSizeCalculator = AudioMessageSizeCalculator(layout: self) + lazy open var typingIndicatorSizeCalculator = TypingCellSizeCalculator(layout: self) - /// - Note: - /// If you override this method, remember to call MessageLayoutDelegate's customCellSizeCalculator(for:at:in:) method for MessageKind.custom messages, if necessary + /// Note: + /// - If you override this method, remember to call MessageLayoutDelegate's + /// customCellSizeCalculator(for:at:in:) method for MessageKind.custom messages, if necessary + /// - If you are using the typing indicator be sure to return the `typingIndicatorSizeCalculator` + /// when the section is reserved for it, indicated by `isSectionReservedForTypingIndicator` open func cellSizeCalculatorForItem(at indexPath: IndexPath) -> CellSizeCalculator { + if isSectionReservedForTypingIndicator(indexPath.section) { + return typingIndicatorSizeCalculator + } let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) switch message.kind { case .text: diff --git a/Sources/Layout/TypingIndicatorCellSizeCalculator.swift b/Sources/Layout/TypingIndicatorCellSizeCalculator.swift new file mode 100644 index 00000000..3e77663f --- /dev/null +++ b/Sources/Layout/TypingIndicatorCellSizeCalculator.swift @@ -0,0 +1,43 @@ +/* + MIT License + + Copyright (c) 2017-2019 MessageKit + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ + +import UIKit + +open class TypingCellSizeCalculator: CellSizeCalculator { + + open var height: CGFloat = 62 + + public init(layout: MessagesCollectionViewFlowLayout? = nil) { + super.init() + self.layout = layout + } + + open override func sizeForItem(at indexPath: IndexPath) -> CGSize { + guard let layout = layout else { return .zero } + let collectionViewWidth = layout.collectionView?.bounds.width ?? 0 + let contentInset = layout.collectionView?.contentInset ?? .zero + let inset = layout.sectionInset.horizontal + contentInset.horizontal + return CGSize(width: collectionViewWidth - inset, height: height) + } +} diff --git a/Sources/Protocols/MessagesDataSource.swift b/Sources/Protocols/MessagesDataSource.swift index a725459d..40e7edc0 100644 --- a/Sources/Protocols/MessagesDataSource.swift +++ b/Sources/Protocols/MessagesDataSource.swift @@ -113,6 +113,14 @@ public protocol MessagesDataSource: AnyObject { /// - Note: /// This method will call fatalError() on default. You must override this method if you are using MessageKind.custom messages. func customCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell + + /// Typing indicator cell used when the indicator is set to be shown + /// + /// - Parameters: + /// - indexPath: The index path to dequeue the cell at + /// - messagesCollectionView: The `MessagesCollectionView` the cell is to be rendered in + /// - Returns: A `UICollectionViewCell` that indicates a user is typing + func typingIndicator(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell } public extension MessagesDataSource { @@ -144,4 +152,8 @@ public extension MessagesDataSource { func customCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell { fatalError(MessageKitError.customDataUnresolvedCell) } + + func typingIndicator(at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell { + return messagesCollectionView.dequeueReusableCell(TypingIndicatorCell.self, for: indexPath) + } } diff --git a/Sources/Protocols/MessagesDisplayDelegate.swift b/Sources/Protocols/MessagesDisplayDelegate.swift index 5cdcb2f4..29d43e80 100644 --- a/Sources/Protocols/MessagesDisplayDelegate.swift +++ b/Sources/Protocols/MessagesDisplayDelegate.swift @@ -71,13 +71,6 @@ public protocol MessagesDisplayDelegate: AnyObject { /// - indexPath: The `IndexPath` of the footer. /// - messagesCollectionView: The `MessagesCollectionView` in which this footer will be displayed. func messageFooterView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView - - /// The section footer to use for a given `IndexPath`. - /// - /// - Parameters: - /// - indexPath: The `IndexPath` of the footer. - /// - messagesCollectionView: The `MessagesCollectionView` in which this footer will be displayed. - func typingIndicatorView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView /// Used to configure the `AvatarView`‘s image in a `MessageContentCell` class. /// @@ -248,12 +241,6 @@ public extension MessagesDisplayDelegate { func messageFooterView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView { return messagesCollectionView.dequeueReusableFooterView(MessageReusableView.self, for: indexPath) } - - func typingIndicatorView(for indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageReusableView { - let view = messagesCollectionView.dequeueReusableTypingIndicatorView(TypingIndicatorView.self, for: indexPath) - view.typingBubble.startAnimating() - return view - } func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) { avatarView.initials = "?" diff --git a/Sources/Views/Headers & Footers/TypingIndicatorView.swift b/Sources/Views/Cells/TypingIndicatorCell.swift similarity index 86% rename from Sources/Views/Headers & Footers/TypingIndicatorView.swift rename to Sources/Views/Cells/TypingIndicatorCell.swift index a6a4c3bd..7e8c5d94 100644 --- a/Sources/Views/Headers & Footers/TypingIndicatorView.swift +++ b/Sources/Views/Cells/TypingIndicatorCell.swift @@ -24,10 +24,12 @@ import UIKit -/// A subclass of `MessageReusableView` used to display the typing indicator. -open class TypingIndicatorView: MessageReusableView { +/// A subclass of `MessageCollectionViewCell` used to display the typing indicator. +open class TypingIndicatorCell: MessageCollectionViewCell { // MARK: - Subviews + + public var insets = UIEdgeInsets(top: 15, left: 0, bottom: 0, right: 0) public let typingBubble = TypingBubble() @@ -44,7 +46,6 @@ open class TypingIndicatorView: MessageReusableView { } open func setupSubviews() { - autoresizingMask = [.flexibleWidth, .flexibleHeight] addSubview(typingBubble) } @@ -52,7 +53,6 @@ open class TypingIndicatorView: MessageReusableView { super.prepareForReuse() if typingBubble.isAnimating { typingBubble.stopAnimating() - typingBubble.startAnimating() } } @@ -60,7 +60,7 @@ open class TypingIndicatorView: MessageReusableView { open override func layoutSubviews() { super.layoutSubviews() - typingBubble.frame = bounds + typingBubble.frame = bounds.inset(by: insets) } } diff --git a/Sources/Views/MessagesCollectionView.swift b/Sources/Views/MessagesCollectionView.swift index 3cb68247..bb68687d 100644 --- a/Sources/Views/MessagesCollectionView.swift +++ b/Sources/Views/MessagesCollectionView.swift @@ -28,8 +28,6 @@ open class MessagesCollectionView: UICollectionView { // MARK: - Properties - public static var elementKindTypingIndicator = "TypingIndicatorElementKind" - open weak var messagesDataSource: MessagesDataSource? open weak var messagesDisplayDelegate: MessagesDisplayDelegate? @@ -38,6 +36,10 @@ open class MessagesCollectionView: UICollectionView { open weak var messageCellDelegate: MessageCellDelegate? + open var isTypingIndicatorHidden: Bool { + return messagesCollectionViewFlowLayout.isTypingIndicatorViewHidden + } + private var indexPathForLastItem: IndexPath? { let lastSection = numberOfSections - 1 guard lastSection >= 0, numberOfItems(inSection: lastSection) > 0 else { return nil } @@ -75,9 +77,9 @@ open class MessagesCollectionView: UICollectionView { register(MediaMessageCell.self) register(LocationMessageCell.self) register(AudioMessageCell.self) + register(TypingIndicatorCell.self) register(MessageReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader) register(MessageReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter) - register(TypingIndicatorView.self, forSupplementaryViewOfKind: MessagesCollectionView.elementKindTypingIndicator) } private func setupGestureRecognizers() { @@ -124,17 +126,22 @@ open class MessagesCollectionView: UICollectionView { // MARK: - Typing Indicator API - /// Sets the typing indicator sate by inserting/deleting the `TypingIndicatorView` + /// Notifies the layout that the typing indicator will change state /// /// - Parameters: /// - isHidden: A Boolean value that is to be the new state of the typing indicator - /// - animated: A Boolean value determining if the insertion is to be animated - /// - updates: A block of code that will be executed during `performBatchUpdates` - /// when `animated` is `TRUE` or before the `completion` block executes - /// when `animated` is `FALSE` - /// - completion: A completion block to execute after the insertion/deletion - open func setTypingIndicatorViewHidden(_ isHidden: Bool, animated: Bool, whilePerforming updates: (() -> Void)? = nil, completion: ((Bool) -> Void)? = nil) { - messagesCollectionViewFlowLayout.setTypingIndicatorViewHidden(isHidden, animated: animated, whilePerforming: updates, completion: completion) + open func setTypingIndicatorViewHidden(_ isHidden: Bool) { + messagesCollectionViewFlowLayout.setTypingIndicatorViewHidden(isHidden) + } + + /// A method that by default checks if the section is the last in the + /// `messagesCollectionView` and that `isTypingIndicatorViewHidden` + /// is FALSE + /// + /// - Parameter section + /// - Returns: A Boolean indicating if the TypingIndicator should be presented at the given section + public func isSectionReservedForTypingIndicator(_ section: Int) -> Bool { + return messagesCollectionViewFlowLayout.isSectionReservedForTypingIndicator(section) } // MARK: View Register/Dequeue @@ -184,13 +191,4 @@ open class MessagesCollectionView: UICollectionView { return viewType } - /// Generically dequeues a typing indicator of the correct type allowing you to avoid scattering your code with guard-let-else-fatal - public func dequeueReusableTypingIndicatorView(_ viewClass: T.Type, for indexPath: IndexPath) -> T { - let view = dequeueReusableSupplementaryView(ofKind: MessagesCollectionView.elementKindTypingIndicator, withReuseIdentifier: String(describing: T.self), for: indexPath) - guard let viewType = view as? T else { - fatalError("Unable to dequeue \(String(describing: viewClass)) with reuseId of \(String(describing: T.self))") - } - return viewType - } - }