diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5a59cd..f1552499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,45 @@ The changelog for `MessageKit`. Also see the [releases](https://github.com/Messa ## Upcoming release +## [[Prerelease] 0.13.0](https://github.com/MessageKit/MessageKit/releases/tag/0.13.0) + ### Fixed +- Fixed message rendering when `MessagesViewController` sliding back. +[#454](https://github.com/MessageKit/MessageKit/pull/454) by [@zhongwuzw](https://github.com/zhongwuzw). + +- Fixed `iPhoneX` `MessageInputBar` transparent bottom area when `keyboardDismissMode` is `interactive`. +[#425](https://github.com/MessageKit/MessageKit/pull/425) by [@zhongwuzw](https://github.com/zhongwuzw). + - Fixed wrong `contentInset` calculation when `navigationBar` is `hidden` [#444](https://github.com/MessageKit/MessageKit/pull/444) by [@zhongwuzw](https://github.com/zhongwuzw). +### Added + +- Added `configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)` method in `MessagesDisplayDelegate` `protocol` to configure `avatarView`. +[#416](https://github.com/MessageKit/MessageKit/pull/416) by [@zhongwuzw](https://github.com/zhongwuzw). + +- Added copy support for image, text, and emoji messages. +[#418](https://github.com/MessageKit/MessageKit/pull/418) by [@zhongwuzw](https://github.com/zhongwuzw). + +- Added `UIImage` paste support to the `InputTextView`. Images can easily be accessed using the `InputTextView.images` property. +See the example project for an updated use case. +[#423](https://github.com/MessageKit/MessageKit/pull/423) by [@nathantannar4](https://github.com/nathantannar4). + +### Removed + +- **Breaking Change** Removed `avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)` method of `MessagesDataSource`, use `configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView)` instead. +[#416](https://github.com/MessageKit/MessageKit/pull/416) by [@zhongwuzw](https://github.com/zhongwuzw). + +### Changed + +- **Breaking Change** Moved the `handleTapGesture(_ gesture: UIGestureRecognizer)` method from `MessagesCollectionViewCell` to `MessagesCollectionView`. +[#417](https://github.com/MessageKit/MessageKit/pull/417) by [@zhongwuzw](https://github.com/zhongwuzw). + +- **Breaking Change** Changed `AvatarView` from type `UIView` to type `UIImageView`. + [#417](https://github.com/MessageKit/MessageKit/pull/417) by [@zhongwuzw](https://github.com/zhongwuzw). + + ## [[Prerelease] 0.12.1](https://github.com/MessageKit/MessageKit/releases/tag/0.12.1) ### Fixed diff --git a/Example/ChatExample.xcodeproj/project.pbxproj b/Example/ChatExample.xcodeproj/project.pbxproj index 557d3e75..863a18e6 100644 --- a/Example/ChatExample.xcodeproj/project.pbxproj +++ b/Example/ChatExample.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ B096438B1F288D47004D0129 /* MockMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B096438A1F288D47004D0129 /* MockMessage.swift */; }; C1DF6DF39F66906000EC76CF /* Pods_ChatExampleTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 56F0AC85B38034EC92CCBC7D /* Pods_ChatExampleTests.framework */; }; C7CA53A1B85256A5097E7DC7 /* Pods_ChatExample.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B316705C4717C3B4C916D62 /* Pods_ChatExample.framework */; }; + CAB36EA12007A573009995ED /* TableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB36EA02007A573009995ED /* TableViewCells.swift */; }; + CAB36EA32007B1B7009995ED /* Settings+UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB36EA22007B1B7009995ED /* Settings+UserDefaults.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -79,6 +81,8 @@ B0DD3C951C9D064B5E6D6644 /* Pods-ChatExampleUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatExampleUITests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-ChatExampleUITests/Pods-ChatExampleUITests.debug.xcconfig"; sourceTree = ""; }; B2F1C412A96DE613A0AC31F8 /* Pods-ChatExampleUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatExampleUITests.release.xcconfig"; path = "Pods/Target Support Files/Pods-ChatExampleUITests/Pods-ChatExampleUITests.release.xcconfig"; sourceTree = ""; }; BFE5859D088A740A7D43E1B1 /* Pods-ChatExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ChatExampleTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-ChatExampleTests/Pods-ChatExampleTests.release.xcconfig"; sourceTree = ""; }; + CAB36EA02007A573009995ED /* TableViewCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCells.swift; sourceTree = ""; }; + CAB36EA22007B1B7009995ED /* Settings+UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Settings+UserDefaults.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -157,6 +161,8 @@ 882B5E791CF7D53600B6E160 /* Assets.xcassets */, 882B5E7F1CF7D53600B6E160 /* Info.plist */, 882B5E7A1CF7D53600B6E160 /* LaunchScreen.storyboard */, + CAB36EA02007A573009995ED /* TableViewCells.swift */, + CAB36EA22007B1B7009995ED /* Settings+UserDefaults.swift */, ); path = Sources; sourceTree = ""; @@ -485,6 +491,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CAB36EA12007A573009995ED /* TableViewCells.swift in Sources */, + CAB36EA32007B1B7009995ED /* Settings+UserDefaults.swift in Sources */, 882B5E871CF7D53600B6E160 /* SettingsViewController.swift in Sources */, 37D3EAC41F390E5F00DD6A55 /* SampleData.swift in Sources */, B096438B1F288D47004D0129 /* MockMessage.swift in Sources */, diff --git a/Example/Sources/ConversationViewController.swift b/Example/Sources/ConversationViewController.swift index c7da3533..e7b7910b 100644 --- a/Example/Sources/ConversationViewController.swift +++ b/Example/Sources/ConversationViewController.swift @@ -37,8 +37,10 @@ class ConversationViewController: MessagesViewController { override func viewDidLoad() { super.viewDidLoad() + let messagesToFetch = UserDefaults.standard.mockMessagesCount() + DispatchQueue.global(qos: .userInitiated).async { - SampleData.shared.getMessages(count: 10) { messages in + SampleData.shared.getMessages(count: messagesToFetch) { messages in DispatchQueue.main.async { self.messageList = messages self.messagesCollectionView.reloadData() @@ -58,17 +60,17 @@ class ConversationViewController: MessagesViewController { maintainPositionOnKeyboardFrameChanged = true // default false messagesCollectionView.addSubview(refreshControl) - refreshControl.addTarget(self, action: #selector(loadMoreMessages), for: .valueChanged) + refreshControl.addTarget(self, action: #selector(ConversationViewController.loadMoreMessages), for: .valueChanged) navigationItem.rightBarButtonItems = [ UIBarButtonItem(image: UIImage(named: "ic_keyboard"), style: .plain, target: self, - action: #selector(handleKeyboardButton)), + action: #selector(ConversationViewController.handleKeyboardButton)), UIBarButtonItem(image: UIImage(named: "ic_typing"), style: .plain, target: self, - action: #selector(handleTyping)) + action: #selector(ConversationViewController.handleTyping)) ] } @@ -267,10 +269,6 @@ extension ConversationViewController: MessagesDataSource { return messageList[indexPath.section] } - func avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> Avatar { - return SampleData.shared.getAvatarFor(sender: message.sender) - } - func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { let name = message.sender.displayName return NSAttributedString(string: name, attributes: [NSAttributedStringKey.font: UIFont.preferredFont(forTextStyle: .caption1)]) @@ -322,6 +320,11 @@ extension ConversationViewController: MessagesDisplayDelegate { // let configurationClosure = { (view: MessageContainerView) in} // return .custom(configurationClosure) } + + func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) { + let avatar = SampleData.shared.getAvatarFor(sender: message.sender) + avatarView.set(avatar: avatar) + } // MARK: - Location Messages @@ -343,6 +346,11 @@ extension ConversationViewController: MessagesDisplayDelegate { }, completion: nil) } } + + func snapshotOptionsForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LocationMessageSnapshotOptions { + + return LocationMessageSnapshotOptions() + } } // MARK: - MessagesLayoutDelegate @@ -439,12 +447,29 @@ extension ConversationViewController: MessageLabelDelegate { extension ConversationViewController: MessageInputBarDelegate { func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) { - let attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 8), .foregroundColor: UIColor.blue]) - let id = UUID().uuidString - let message = MockMessage(attributedText: attributedText, sender: currentSender(), messageId: id, date: Date()) - messageList.append(message) + + // Each NSTextAttachment that contains an image will count as one empty character in the text: String + + for component in inputBar.inputTextView.components { + + if let image = component as? UIImage { + + let imageMessage = MockMessage(image: image, sender: currentSender(), messageId: UUID().uuidString, date: Date()) + messageList.append(imageMessage) + messagesCollectionView.insertSections([messageList.count - 1]) + + } else if let text = component as? String { + + let attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 15), .foregroundColor: UIColor.blue]) + + let message = MockMessage(attributedText: attributedText, sender: currentSender(), messageId: UUID().uuidString, date: Date()) + messageList.append(message) + messagesCollectionView.insertSections([messageList.count - 1]) + } + + } + inputBar.inputTextView.text = String() - messagesCollectionView.insertSections([messageList.count - 1]) messagesCollectionView.scrollToBottom() } diff --git a/Example/Sources/Settings+UserDefaults.swift b/Example/Sources/Settings+UserDefaults.swift new file mode 100644 index 00000000..620a0241 --- /dev/null +++ b/Example/Sources/Settings+UserDefaults.swift @@ -0,0 +1,44 @@ +/* + MIT License + + Copyright (c) 2017 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 Foundation + +extension UserDefaults { + + static let messagesKey = "mockMessages" + + // MARK: - Mock Messages + + func setMockMessages(count: Int) { + set(count, forKey: "mockMessages") + synchronize() + } + + func mockMessagesCount() -> Int { + if let value = object(forKey: "mockMessages") as? Int { + return value + } + return 20 + } +} diff --git a/Example/Sources/SettingsViewController.swift b/Example/Sources/SettingsViewController.swift index 211b36a9..61bb813d 100644 --- a/Example/Sources/SettingsViewController.swift +++ b/Example/Sources/SettingsViewController.swift @@ -27,8 +27,108 @@ import MessageKit final class SettingsViewController: UITableViewController { + // MARK: - Properties + + var selectedMockMessagesCount: Int = 20 + + // MARK: - Picker + + var messagesPicker = UIPickerView() + + @objc func onDoneWithPickerView() { + let selectedMessagesCount = messagesPicker.selectedRow(inComponent: 0) + UserDefaults.standard.setMockMessages(count: selectedMessagesCount) + view.endEditing(false) + tableView.reloadData() + } + + @objc func dismissPickerView() { + view.endEditing(false) + } + + private func configurePickerView() { + messagesPicker.dataSource = self + messagesPicker.delegate = self + messagesPicker.backgroundColor = .white + } + + // MARK: - Toolbar + + var messagesToolbar = UIToolbar() + + private func configureToolbar() { + let doneButton = UIBarButtonItem(title: "Done", style: .plain, target: self, action: #selector(onDoneWithPickerView)) + let spaceButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(dismissPickerView)) + messagesToolbar.items = [cancelButton, spaceButton, doneButton] + messagesToolbar.sizeToFit() + } + + // MARK: - View lifecycle + override func viewDidLoad() { super.viewDidLoad() + tableView.register(TextFieldTableViewCell.self, forCellReuseIdentifier: TextFieldTableViewCell.identifier) + + configurePickerView() + configureToolbar() + } + + // MARK: - TableViewDelegate & TableViewDataSource + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + return indexPath.row == 0 ? configureTextFieldTableViewCell(at: indexPath) : UITableViewCell() + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let cell = tableView.cellForRow(at: indexPath) + + cell?.contentView.subviews.forEach { + if $0 is UITextField { + $0.becomeFirstResponder() + } + } + } + + // MARK: - Helper + + private func configureTextFieldTableViewCell(at indexPath: IndexPath) -> TextFieldTableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TextFieldTableViewCell.identifier, for: indexPath) as! TextFieldTableViewCell + cell.mainLabel.text = "Mock messages count:" + + let messagesCount = UserDefaults.standard.mockMessagesCount() + cell.textField.text = "\(messagesCount)" + + cell.textField.inputView = messagesPicker + cell.textField.inputAccessoryView = messagesToolbar + + return cell + } +} + +// MARK: - UIPickerViewDelegate, UIPickerViewDataSource +extension SettingsViewController: UIPickerViewDelegate, UIPickerViewDataSource { + + func numberOfComponents(in pickerView: UIPickerView) -> Int { + return 1 + } + + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { + return 100 + } + + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { + return "\(row)" + } + + func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) { + selectedMockMessagesCount = row } - } diff --git a/Example/Sources/TableViewCells.swift b/Example/Sources/TableViewCells.swift new file mode 100644 index 00000000..b3e114e5 --- /dev/null +++ b/Example/Sources/TableViewCells.swift @@ -0,0 +1,62 @@ +/* + MIT License + + Copyright (c) 2017 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 + +class TextFieldTableViewCell: UITableViewCell { + + static let identifier = "TextFieldTableViewCellIdentifier" + + var mainLabel = UILabel() + var textField = UITextField() + + // MARK: - View lifecycle + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + mainLabel.translatesAutoresizingMaskIntoConstraints = false + textField.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(mainLabel) + contentView.addSubview(textField) + + NSLayoutConstraint.activate([ + mainLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + mainLabel.widthAnchor.constraint(equalToConstant: 200), + mainLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + textField.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + textField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + textField.widthAnchor.constraint(equalToConstant: 50) + ]) + + textField.textAlignment = .right + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/MessageKit.podspec b/MessageKit.podspec index bc1174dc..440cef6b 100644 --- a/MessageKit.podspec +++ b/MessageKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'MessageKit' - s.version = '0.12.1' + s.version = '0.13.0' s.license = { :type => "MIT", :file => "LICENSE.md" } s.summary = 'An elegant messages UI library for iOS.' diff --git a/MessageKit.xcodeproj/project.pbxproj b/MessageKit.xcodeproj/project.pbxproj index 4c270ff0..e175a155 100644 --- a/MessageKit.xcodeproj/project.pbxproj +++ b/MessageKit.xcodeproj/project.pbxproj @@ -18,6 +18,11 @@ 1F7FC8C61FD26F33006CC979 /* Quick.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F7FC8C51FD26F33006CC979 /* Quick.framework */; }; 1F7FC8C81FD26F49006CC979 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1F7FC8C71FD26F49006CC979 /* Nimble.framework */; }; 1F82D1431FB1B75B00B81A88 /* AvatarPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F82D1421FB1B75B00B81A88 /* AvatarPosition.swift */; }; + 1FF377A420087C82004FD648 /* MessageKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF377A320087C82004FD648 /* MessageKitError.swift */; }; + 1FF377A620087D20004FD648 /* MessagesViewController+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF377A520087D20004FD648 /* MessagesViewController+DataSource.swift */; }; + 1FF377A820087D56004FD648 /* MessagesViewController+Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF377A720087D56004FD648 /* MessagesViewController+Delegate.swift */; }; + 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 */; }; 38C57C791F9AE3E50043CC03 /* SeparatorLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C57C781F9AE3E50043CC03 /* SeparatorLine.swift */; }; 38C57C7C1F9AE4890043CC03 /* InputStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C57C7B1F9AE4870043CC03 /* InputStackView.swift */; }; 88916B2D1CF0DF2F00469F91 /* MessageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88916B221CF0DF2F00469F91 /* MessageKit.framework */; }; @@ -115,6 +120,11 @@ 1F7FC8C71FD26F49006CC979 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = Carthage/Build/iOS/Nimble.framework; sourceTree = ""; }; 1F7FC8CB1FD2700B006CC979 /* MessagesViewControllerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewControllerSpec.swift; sourceTree = ""; }; 1F82D1421FB1B75B00B81A88 /* AvatarPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarPosition.swift; sourceTree = ""; }; + 1FF377A320087C82004FD648 /* MessageKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageKitError.swift; sourceTree = ""; }; + 1FF377A520087D20004FD648 /* MessagesViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesViewController+DataSource.swift"; sourceTree = ""; }; + 1FF377A720087D56004FD648 /* MessagesViewController+Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesViewController+Delegate.swift"; sourceTree = ""; }; + 1FF377A920087D78004FD648 /* MessagesViewController+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesViewController+Menu.swift"; sourceTree = ""; }; + 1FF377AB20087DA2004FD648 /* MessagesViewController+Keyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessagesViewController+Keyboard.swift"; sourceTree = ""; }; 38C57C781F9AE3E50043CC03 /* SeparatorLine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeparatorLine.swift; sourceTree = ""; }; 38C57C7B1F9AE4870043CC03 /* InputStackView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InputStackView.swift; sourceTree = ""; }; 88916B221CF0DF2F00469F91 /* MessageKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MessageKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -373,6 +383,7 @@ B7A03F221F866895006AEF79 /* LabelAlignment.swift */, B7A03F1D1F866895006AEF79 /* LocationMessageSnapshotOptions.swift */, B7A03F231F866895006AEF79 /* MessageData.swift */, + 1FF377A320087C82004FD648 /* MessageKitError.swift */, B7A03F1B1F866895006AEF79 /* MessageKitDateFormatter.swift */, B7A03F1F1F866895006AEF79 /* MessageStyle.swift */, B7A03F1A1F866895006AEF79 /* NSConstraintLayoutSet.swift */, @@ -418,6 +429,10 @@ isa = PBXGroup; children = ( B7A03F4E1F86697C006AEF79 /* MessagesViewController.swift */, + 1FF377A520087D20004FD648 /* MessagesViewController+DataSource.swift */, + 1FF377A720087D56004FD648 /* MessagesViewController+Delegate.swift */, + 1FF377A920087D78004FD648 /* MessagesViewController+Menu.swift */, + 1FF377AB20087DA2004FD648 /* MessagesViewController+Keyboard.swift */, ); path = Controllers; sourceTree = ""; @@ -571,12 +586,14 @@ buildActionMask = 2147483647; files = ( B7A03F3C1F866946006AEF79 /* LocationMessageCell.swift in Sources */, + 1FF377AA20087D78004FD648 /* MessagesViewController+Menu.swift in Sources */, 38C57C7C1F9AE4890043CC03 /* InputStackView.swift in Sources */, B7A03F5B1F8669CA006AEF79 /* MessageType.swift in Sources */, B7A03F6F1F8669EB006AEF79 /* String+Extensions.swift in Sources */, B7A03F491F86694F006AEF79 /* InputBarItem.swift in Sources */, B7A03F601F8669CA006AEF79 /* MessagesDisplayDelegate.swift in Sources */, B7A03F5C1F8669CA006AEF79 /* MessageCellDelegate.swift in Sources */, + 1FF377A420087C82004FD648 /* MessageKitError.swift in Sources */, B7A03F4A1F86694F006AEF79 /* MessageInputBar.swift in Sources */, B006FA021F99DE2100509C46 /* MessageIntermediateLayoutAttributes.swift in Sources */, B7A03F4B1F86694F006AEF79 /* MessageContainerView.swift in Sources */, @@ -610,14 +627,17 @@ 0EE91E661FDEC888005420A2 /* CGRect+Extensions.swift in Sources */, B7A03F181F86682C006AEF79 /* MessagesCollectionViewFlowLayout.swift in Sources */, B7A03F2A1F866895006AEF79 /* MessageStyle.swift in Sources */, + 1FF377A620087D20004FD648 /* MessagesViewController+DataSource.swift in Sources */, B7A03F4D1F86694F006AEF79 /* MessagesCollectionView.swift in Sources */, B7A03F351F866940006AEF79 /* MessageHeaderView.swift in Sources */, B7A03F731F866A06006AEF79 /* MessageKit+Availability.swift in Sources */, B7A03F2D1F866895006AEF79 /* LabelAlignment.swift in Sources */, 38C57C791F9AE3E50043CC03 /* SeparatorLine.swift in Sources */, B7A03F2C1F866895006AEF79 /* DetectorType.swift in Sources */, + 1FF377A820087D56004FD648 /* MessagesViewController+Delegate.swift in Sources */, B7A03F271F866895006AEF79 /* Avatar.swift in Sources */, 1F82D1431FB1B75B00B81A88 /* AvatarPosition.swift in Sources */, + 1FF377AC20087DA2004FD648 /* MessagesViewController+Keyboard.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -627,7 +647,6 @@ files = ( 8962AC8C1F87AB7D0030B058 /* AvatarViewTests.swift in Sources */, 1F066E141FD90BB700E11013 /* MessageLabelSpec.swift in Sources */, - 8962AC971F87AB860030B058 /* DetectorTypeTests.swift in Sources */, 8962AC941F87AB860030B058 /* MessageKitDateFormatterTests.swift in Sources */, 8962AC8E1F87AB7D0030B058 /* InputTextViewTests.swift in Sources */, 8962AC911F87AB860030B058 /* MessagesViewControllerTests.swift in Sources */, @@ -637,7 +656,6 @@ 8962AC8F1F87AB7D0030B058 /* MessageInputBarTests.swift in Sources */, 1F066E131FD90BB600E11013 /* MessagesViewControllerSpec.swift in Sources */, 1F066E1D1FDA3C1700E11013 /* SenderSpec.swift in Sources */, - 1F7FC8CC1FD2700B006CC979 /* MessagesViewControllerSpec.swift in Sources */, 8962AC8A1F87AB7D0030B058 /* MessagesCollectionViewTests.swift in Sources */, 8962AC8D1F87AB7D0030B058 /* MessageCollectionViewCellTests.swift in Sources */, 8962AC991F87AB860030B058 /* MessagesDisplayDelegateTests.swift in Sources */, diff --git a/Sources/Controllers/MessagesViewController+DataSource.swift b/Sources/Controllers/MessagesViewController+DataSource.swift new file mode 100644 index 00000000..68abe2e0 --- /dev/null +++ b/Sources/Controllers/MessagesViewController+DataSource.swift @@ -0,0 +1,99 @@ +/* + MIT License + + Copyright (c) 2017-2018 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 + +extension MessagesViewController: UICollectionViewDataSource { + + open func numberOfSections(in collectionView: UICollectionView) -> Int { + guard let collectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + // Each message is its own section + return collectionView.messagesDataSource?.numberOfMessages(in: collectionView) ?? 0 + } + + open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let collectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + let messageCount = collectionView.messagesDataSource?.numberOfMessages(in: collectionView) ?? 0 + // There will only ever be 1 message per section + return messageCount > 0 ? 1 : 0 + } + + open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + guard let messagesCollectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + + guard let messagesDataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) + + switch message.data { + case .text, .attributedText, .emoji: + let cell = messagesCollectionView.dequeueReusableCell(TextMessageCell.self, for: indexPath) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell + case .photo, .video: + let cell = messagesCollectionView.dequeueReusableCell(MediaMessageCell.self, for: indexPath) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell + case .location: + let cell = messagesCollectionView.dequeueReusableCell(LocationMessageCell.self, for: indexPath) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell + } + } + + open func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + guard let messagesCollectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + + guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { + fatalError(MessageKitError.nilMessagesDisplayDelegate) + } + + let message = dataSource.messageForItem(at: indexPath, in: messagesCollectionView) + + switch kind { + case UICollectionElementKindSectionHeader: + return displayDelegate.messageHeaderView(for: message, at: indexPath, in: messagesCollectionView) + case UICollectionElementKindSectionFooter: + return displayDelegate.messageFooterView(for: message, at: indexPath, in: messagesCollectionView) + default: + fatalError(MessageKitError.unrecognizedSectionKind) + } + } +} diff --git a/Sources/Controllers/MessagesViewController+Delegate.swift b/Sources/Controllers/MessagesViewController+Delegate.swift new file mode 100644 index 00000000..cd0d27a1 --- /dev/null +++ b/Sources/Controllers/MessagesViewController+Delegate.swift @@ -0,0 +1,102 @@ +/* + MIT License + + Copyright (c) 2017-2018 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 + +extension MessagesViewController: UICollectionViewDelegateFlowLayout { + + open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + guard let messagesFlowLayout = collectionViewLayout as? MessagesCollectionViewFlowLayout else { return .zero } + return messagesFlowLayout.sizeForItem(at: indexPath) + } + + open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + + guard let messagesCollectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + guard let layoutDelegate = messagesCollectionView.messagesLayoutDelegate else { + fatalError(MessageKitError.nilMessagesLayoutDeleagte) + } + // Could pose a problem if subclass behaviors allows more than one item per section + let indexPath = IndexPath(item: 0, section: section) + let message = dataSource.messageForItem(at: indexPath, in: messagesCollectionView) + return layoutDelegate.headerViewSize(for: message, at: indexPath, in: messagesCollectionView) + } + + open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + guard let messagesCollectionView = collectionView as? MessagesCollectionView else { + fatalError(MessageKitError.notMessagesCollectionView) + } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + guard let layoutDelegate = messagesCollectionView.messagesLayoutDelegate else { + fatalError(MessageKitError.nilMessagesLayoutDeleagte) + } + // Could pose a problem if subclass behaviors allows more than one item per section + let indexPath = IndexPath(item: 0, section: section) + let message = dataSource.messageForItem(at: indexPath, in: messagesCollectionView) + return layoutDelegate.footerViewSize(for: message, at: indexPath, in: messagesCollectionView) + } + + open func collectionView(_ collectionView: UICollectionView, shouldShowMenuForItemAt indexPath: IndexPath) -> Bool { + guard let messagesDataSource = messagesCollectionView.messagesDataSource else { return false } + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) + + switch message.data { + case .text, .attributedText, .emoji, .photo: + selectedIndexPathForMenu = indexPath + return true + default: + return false + } + } + + open func collectionView(_ collectionView: UICollectionView, canPerformAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) -> Bool { + return (action == NSSelectorFromString("copy:")) + } + + open func collectionView(_ collectionView: UICollectionView, performAction action: Selector, forItemAt indexPath: IndexPath, withSender sender: Any?) { + guard let messagesDataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + let pasteBoard = UIPasteboard.general + let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) + + switch message.data { + case .text(let text), .emoji(let text): + pasteBoard.string = text + case .attributedText(let attributedText): + pasteBoard.string = attributedText.string + case .photo(let image): + pasteBoard.image = image + default: + break + } + } +} diff --git a/Sources/Controllers/MessagesViewController+Keyboard.swift b/Sources/Controllers/MessagesViewController+Keyboard.swift new file mode 100644 index 00000000..a3535a56 --- /dev/null +++ b/Sources/Controllers/MessagesViewController+Keyboard.swift @@ -0,0 +1,106 @@ +/* + MIT License + + Copyright (c) 2017-2018 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 Foundation + +extension MessagesViewController { + + // MARK: - Register / Unregister Observers + + func addKeyboardObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(MessagesViewController.handleKeyboardDidChangeState(_:)), name: .UIKeyboardWillChangeFrame, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(MessagesViewController.handleTextViewDidBeginEditing(_:)), name: .UITextViewTextDidBeginEditing, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(MessagesViewController.adjustScrollViewInset), name: .UIDeviceOrientationDidChange, object: nil) + } + + func removeKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: .UIKeyboardWillChangeFrame, object: nil) + NotificationCenter.default.removeObserver(self, name: .UITextViewTextDidBeginEditing, object: nil) + NotificationCenter.default.removeObserver(self, name: .UIDeviceOrientationDidChange, object: nil) + } + + // MARK: - Notification Handlers + + @objc + private func handleTextViewDidBeginEditing(_ notification: Notification) { + if scrollsToBottomOnKeybordBeginsEditing { + guard let inputTextView = notification.object as? InputTextView, inputTextView === messageInputBar.inputTextView else { return } + messagesCollectionView.scrollToBottom(animated: true) + } + } + + @objc + private func handleKeyboardDidChangeState(_ notification: Notification) { + guard let keyboardEndFrame = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? CGRect else { return } + + if (keyboardEndFrame.origin.y + keyboardEndFrame.size.height) > UIScreen.main.bounds.height { + // Hardware keyboard is found + messageCollectionViewBottomInset = view.frame.size.height - keyboardEndFrame.origin.y - iPhoneXBottomInset + } else { + //Software keyboard is found + let afterBottomInset = keyboardEndFrame.height > keyboardOffsetFrame.height ? (keyboardEndFrame.height - iPhoneXBottomInset) : keyboardOffsetFrame.height + let differenceOfBottomInset = afterBottomInset - messageCollectionViewBottomInset + let contentOffset = CGPoint(x: messagesCollectionView.contentOffset.x, y: messagesCollectionView.contentOffset.y + differenceOfBottomInset) + + if maintainPositionOnKeyboardFrameChanged { + messagesCollectionView.setContentOffset(contentOffset, animated: false) + } + + messageCollectionViewBottomInset = afterBottomInset + } + } + + @objc + func adjustScrollViewInset() { + if #available(iOS 11.0, *) { + // No need to add to the top contentInset + } else { + let navigationBarInset = navigationController?.navigationBar.frame.height ?? 0 + let statusBarInset: CGFloat = UIApplication.shared.isStatusBarHidden ? 0 : 20 + let topInset = navigationBarInset + statusBarInset + messagesCollectionView.contentInset.top = topInset + messagesCollectionView.scrollIndicatorInsets.top = topInset + } + } + + // MARK: - Helpers + + var keyboardOffsetFrame: CGRect { + guard let inputFrame = inputAccessoryView?.frame else { return .zero } + return CGRect(origin: inputFrame.origin, size: CGSize(width: inputFrame.width, height: inputFrame.height - iPhoneXBottomInset)) + } + + /// On the iPhone X the inputAccessoryView is anchored to the layoutMarginesGuide.bottom anchor + /// so the frame of the inputAccessoryView is larger than the required offset + /// for the MessagesCollectionView. + /// + /// - Returns: The safeAreaInsets.bottom if its an iPhoneX, else 0 + private var iPhoneXBottomInset: CGFloat { + if #available(iOS 11.0, *) { + guard UIScreen.main.nativeBounds.height == 2436 else { return 0 } + return view.safeAreaInsets.bottom + } + return 0 + } +} diff --git a/Sources/Controllers/MessagesViewController+Menu.swift b/Sources/Controllers/MessagesViewController+Menu.swift new file mode 100644 index 00000000..a1fa9de6 --- /dev/null +++ b/Sources/Controllers/MessagesViewController+Menu.swift @@ -0,0 +1,101 @@ +/* + MIT License + + Copyright (c) 2017-2018 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 Foundation + +extension MessagesViewController { + + // MARK: - Register / Unregister Observers + + /// Add observer for `UIMenuControllerWillShowMenu` notification + func addMenuControllerObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(MessagesViewController.menuControllerWillShow(_:)), name: .UIMenuControllerWillShowMenu, object: nil) + } + + /// Remove observer for `UIMenuControllerWillShowMenu` notification + func removeMenuControllerObservers() { + NotificationCenter.default.removeObserver(self, name: .UIMenuControllerWillShowMenu, object: nil) + } + + // MARK: - Notification Handlers + + /// Show menuController and set target rect to selected bubble + @objc + private func menuControllerWillShow(_ notification: Notification) { + + guard let currentMenuController = notification.object as? UIMenuController, + let selectedIndexPath = selectedIndexPathForMenu else { return } + + NotificationCenter.default.removeObserver(self, name: .UIMenuControllerWillShowMenu, object: nil) + defer { + NotificationCenter.default.addObserver(self, + selector: #selector(MessagesViewController.menuControllerWillShow(_:)), + name: .UIMenuControllerWillShowMenu, object: nil) + selectedIndexPathForMenu = nil + } + + currentMenuController.setMenuVisible(false, animated: false) + + guard let selectedCell = messagesCollectionView.cellForItem(at: selectedIndexPath) as? MessageCollectionViewCell else { return } + let selectedCellMessageBubbleFrame = selectedCell.convert(selectedCell.messageContainerView.frame, to: view) + + var messageInputBarFrame: CGRect = .zero + if let messageInputBarSuperview = messageInputBar.superview { + messageInputBarFrame = view.convert(messageInputBar.frame, from: messageInputBarSuperview) + } + + var topNavigationBarFrame: CGRect = navigationBarFrame + if navigationBarFrame != .zero, let navigationBarSuperview = navigationController?.navigationBar.superview { + topNavigationBarFrame = view.convert(navigationController!.navigationBar.frame, from: navigationBarSuperview) + } + + let menuHeight = currentMenuController.menuFrame.height + + let selectedCellMessageBubblePlusMenuFrame = CGRect(selectedCellMessageBubbleFrame.origin.x, selectedCellMessageBubbleFrame.origin.y - menuHeight, selectedCellMessageBubbleFrame.size.width, selectedCellMessageBubbleFrame.size.height + 2 * menuHeight) + + var targetRect: CGRect = selectedCellMessageBubbleFrame + currentMenuController.arrowDirection = .default + + /// Message bubble intersects with navigationBar and keyboard + if selectedCellMessageBubblePlusMenuFrame.intersects(topNavigationBarFrame) && selectedCellMessageBubblePlusMenuFrame.intersects(messageInputBarFrame) { + let centerY = (selectedCellMessageBubblePlusMenuFrame.intersection(messageInputBarFrame).minY + selectedCellMessageBubblePlusMenuFrame.intersection(topNavigationBarFrame).maxY) / 2 + targetRect = CGRect(selectedCellMessageBubblePlusMenuFrame.midX, centerY, 1, 1) + } /// Message bubble only intersects with navigationBar + else if selectedCellMessageBubblePlusMenuFrame.intersects(topNavigationBarFrame) { + currentMenuController.arrowDirection = .up + } + + currentMenuController.setTargetRect(targetRect, in: view) + currentMenuController.setMenuVisible(true, animated: true) + } + + // MARK: - Helpers + + private var navigationBarFrame: CGRect { + guard let navigationController = navigationController, !navigationController.navigationBar.isHidden else { + return .zero + } + return navigationController.navigationBar.frame + } +} diff --git a/Sources/Controllers/MessagesViewController.swift b/Sources/Controllers/MessagesViewController.swift index cc211d44..ed0728ac 100644 --- a/Sources/Controllers/MessagesViewController.swift +++ b/Sources/Controllers/MessagesViewController.swift @@ -1,4 +1,3 @@ - /* MIT License @@ -58,11 +57,14 @@ open class MessagesViewController: UIViewController { open override var shouldAutorotate: Bool { return false } - + /// A Boolean value used to determine if `viewDidLayoutSubviews()` has been called. private var isFirstLayout: Bool = true - private var messageCollectionViewBottomInset: CGFloat = 0 { + /// Indicated selected indexPath when handle menu action + var selectedIndexPathForMenu: IndexPath? + + var messageCollectionViewBottomInset: CGFloat = 0 { didSet { messagesCollectionView.contentInset.bottom = messageCollectionViewBottomInset messagesCollectionView.scrollIndicatorInsets.bottom = messageCollectionViewBottomInset @@ -73,18 +75,12 @@ open class MessagesViewController: UIViewController { open override func viewDidLoad() { super.viewDidLoad() - - extendedLayoutIncludesOpaqueBars = true - automaticallyAdjustsScrollViewInsets = false - view.backgroundColor = .white - messagesCollectionView.keyboardDismissMode = .interactive - messagesCollectionView.alwaysBounceVertical = true - + setupDefaults() setupSubviews() setupConstraints() registerReusableViews() setupDelegates() - + addMenuControllerObservers() } open override func viewDidLayoutSubviews() { @@ -101,19 +97,33 @@ open class MessagesViewController: UIViewController { deinit { removeKeyboardObservers() + removeMenuControllerObservers() } // MARK: - Methods [Private] + /// Sets the default values for the MessagesViewController + private func setupDefaults() { + extendedLayoutIncludesOpaqueBars = true + automaticallyAdjustsScrollViewInsets = false + view.backgroundColor = .white + messagesCollectionView.keyboardDismissMode = .interactive + messagesCollectionView.alwaysBounceVertical = true + } + /// Sets the delegate and dataSource of the messagesCollectionView property. private func setupDelegates() { messagesCollectionView.delegate = self messagesCollectionView.dataSource = self } + /// Adds the messagesCollectionView to the controllers root view. + private func setupSubviews() { + view.addSubview(messagesCollectionView) + } + /// Registers all cells and supplementary views of the messagesCollectionView property. private func registerReusableViews() { - messagesCollectionView.register(TextMessageCell.self) messagesCollectionView.register(MediaMessageCell.self) messagesCollectionView.register(LocationMessageCell.self) @@ -121,12 +131,6 @@ open class MessagesViewController: UIViewController { messagesCollectionView.register(MessageFooterView.self, forSupplementaryViewOfKind: UICollectionElementKindSectionFooter) messagesCollectionView.register(MessageHeaderView.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader) messagesCollectionView.register(MessageDateHeaderView.self, forSupplementaryViewOfKind: UICollectionElementKindSectionHeader) - - } - - /// Adds the messagesCollectionView to the controllers root view. - private func setupSubviews() { - view.addSubview(messagesCollectionView) } /// Sets the constraints of the `MessagesCollectionView`. @@ -145,187 +149,4 @@ open class MessagesViewController: UIViewController { NSLayoutConstraint.activate([top, bottom, trailing, leading]) } } - - @objc - private func adjustScrollViewInset() { - if #available(iOS 11.0, *) { - // No need to add to the top contentInset - } else { - var navigationBarInset: CGFloat = 0 - if let navigationController = navigationController, !navigationController.isNavigationBarHidden { - navigationBarInset = navigationController.navigationBar.frame.height - } - let statusBarInset: CGFloat = UIApplication.shared.isStatusBarHidden ? 0 : 20 - let topInset = navigationBarInset + statusBarInset - messagesCollectionView.contentInset.top = topInset - messagesCollectionView.scrollIndicatorInsets.top = topInset - } - } -} - -// MARK: - UICollectionViewDelegate & UICollectionViewDelegateFlowLayout Conformance - -extension MessagesViewController: UICollectionViewDelegateFlowLayout { - - open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - guard let messagesFlowLayout = collectionViewLayout as? MessagesCollectionViewFlowLayout else { return .zero } - return messagesFlowLayout.sizeForItem(at: indexPath) - } - - open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - guard let messagesCollectionView = collectionView as? MessagesCollectionView else { return .zero } - guard let messagesDataSource = messagesCollectionView.messagesDataSource else { return .zero } - guard let messagesLayoutDelegate = messagesCollectionView.messagesLayoutDelegate else { return .zero } - // Could pose a problem if subclass behaviors allows more than one item per section - let indexPath = IndexPath(item: 0, section: section) - let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) - return messagesLayoutDelegate.headerViewSize(for: message, at: indexPath, in: messagesCollectionView) - } - - open func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { - guard let messagesCollectionView = collectionView as? MessagesCollectionView else { return .zero } - guard let messagesDataSource = messagesCollectionView.messagesDataSource else { return .zero } - guard let messagesLayoutDelegate = messagesCollectionView.messagesLayoutDelegate else { return .zero } - // Could pose a problem if subclass behaviors allows more than one item per section - let indexPath = IndexPath(item: 0, section: section) - let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) - return messagesLayoutDelegate.footerViewSize(for: message, at: indexPath, in: messagesCollectionView) - } - -} - -// MARK: - UICollectionViewDataSource Conformance - -extension MessagesViewController: UICollectionViewDataSource { - - open func numberOfSections(in collectionView: UICollectionView) -> Int { - guard let collectionView = collectionView as? MessagesCollectionView else { return 0 } - - // Each message is its own section - return collectionView.messagesDataSource?.numberOfMessages(in: collectionView) ?? 0 - } - - open func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - guard let collectionView = collectionView as? MessagesCollectionView else { return 0 } - - let messageCount = collectionView.messagesDataSource?.numberOfMessages(in: collectionView) ?? 0 - // There will only ever be 1 message per section - return messageCount > 0 ? 1 : 0 - - } - - open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - - guard let messagesCollectionView = collectionView as? MessagesCollectionView else { - fatalError("Managed collectionView: \(collectionView.debugDescription) is not a MessagesCollectionView.") - } - - guard let messagesDataSource = messagesCollectionView.messagesDataSource else { - fatalError("MessagesDataSource has not been set.") - } - - let message = messagesDataSource.messageForItem(at: indexPath, in: messagesCollectionView) - - switch message.data { - case .text, .attributedText, .emoji: - let cell = messagesCollectionView.dequeueReusableCell(TextMessageCell.self, for: indexPath) - cell.configure(with: message, at: indexPath, and: messagesCollectionView) - return cell - case .photo, .video: - let cell = messagesCollectionView.dequeueReusableCell(MediaMessageCell.self, for: indexPath) - cell.configure(with: message, at: indexPath, and: messagesCollectionView) - return cell - case .location: - let cell = messagesCollectionView.dequeueReusableCell(LocationMessageCell.self, for: indexPath) - cell.configure(with: message, at: indexPath, and: messagesCollectionView) - return cell - } - } - - open func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { - - guard let messagesCollectionView = collectionView as? MessagesCollectionView else { - fatalError("Managed collectionView: \(collectionView.debugDescription) is not a MessagesCollectionView.") - } - - guard let dataSource = messagesCollectionView.messagesDataSource else { - fatalError("MessagesDataSource has not been set.") - } - - guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { - fatalError("MessagesDisplayDelegate has not been set.") - } - - let message = dataSource.messageForItem(at: indexPath, in: messagesCollectionView) - - switch kind { - case UICollectionElementKindSectionHeader: - return displayDelegate.messageHeaderView(for: message, at: indexPath, in: messagesCollectionView) - case UICollectionElementKindSectionFooter: - return displayDelegate.messageFooterView(for: message, at: indexPath, in: messagesCollectionView) - default: - fatalError("Unrecognized element of kind: \(kind)") - } - } -} - -// MARK: - Keyboard Handling - -fileprivate extension MessagesViewController { - - func addKeyboardObservers() { - NotificationCenter.default.addObserver(self, selector: #selector(handleKeyboardDidChangeState), name: .UIKeyboardWillChangeFrame, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(handleTextViewDidBeginEditing), name: .UITextViewTextDidBeginEditing, object: nil) - } - - func removeKeyboardObservers() { - NotificationCenter.default.removeObserver(self, name: .UIKeyboardWillChangeFrame, object: nil) - NotificationCenter.default.removeObserver(self, name: .UITextViewTextDidBeginEditing, object: nil) - } - - @objc - func handleTextViewDidBeginEditing(_ notification: Notification) { - if scrollsToBottomOnKeybordBeginsEditing { - guard let inputTextView = notification.object as? InputTextView, inputTextView === messageInputBar.inputTextView else { return } - messagesCollectionView.scrollToBottom(animated: true) - } - } - - @objc - func handleKeyboardDidChangeState(_ notification: Notification) { - guard let keyboardEndFrame = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? CGRect else { return } - - if (keyboardEndFrame.origin.y + keyboardEndFrame.size.height) > UIScreen.main.bounds.height { - // Hardware keyboard is found - messageCollectionViewBottomInset = view.frame.size.height - keyboardEndFrame.origin.y - iPhoneXBottomInset - } else { - //Software keyboard is found - let afterBottomInset = keyboardEndFrame.height > keyboardOffsetFrame.height ? (keyboardEndFrame.height - iPhoneXBottomInset) : keyboardOffsetFrame.height - let differenceOfBottomInset = afterBottomInset - messageCollectionViewBottomInset - let contentOffset = CGPoint(x: messagesCollectionView.contentOffset.x, y: messagesCollectionView.contentOffset.y + differenceOfBottomInset) - - if maintainPositionOnKeyboardFrameChanged { - messagesCollectionView.setContentOffset(contentOffset, animated: false) - } - - messageCollectionViewBottomInset = afterBottomInset - } - } - - fileprivate var keyboardOffsetFrame: CGRect { - guard let inputFrame = inputAccessoryView?.frame else { return .zero } - return CGRect(origin: inputFrame.origin, size: CGSize(width: inputFrame.width, height: inputFrame.height - iPhoneXBottomInset)) - } - - /// On the iPhone X the inputAccessoryView is anchored to the layoutMarginesGuide.bottom anchor so the frame of the inputAccessoryView - /// is larger than the required offset for the MessagesCollectionView - /// - /// - Returns: The safeAreaInsets.bottom if its an iPhoneX, else 0 - fileprivate var iPhoneXBottomInset: CGFloat { - if #available(iOS 11.0, *) { - guard UIScreen.main.nativeBounds.height == 2436 else { return 0 } - return view.safeAreaInsets.bottom - } - return 0 - } } diff --git a/Sources/Extensions/Bundle+Extensions.swift b/Sources/Extensions/Bundle+Extensions.swift index 4d35954b..9d6f641e 100644 --- a/Sources/Extensions/Bundle+Extensions.swift +++ b/Sources/Extensions/Bundle+Extensions.swift @@ -30,11 +30,11 @@ extension Bundle { let podBundle = Bundle(for: MessagesViewController.self) guard let resourceBundleUrl = podBundle.url(forResource: "MessageKitAssets", withExtension: "bundle") else { - fatalError("MessageKit: Could not create path to the assets bundle") + fatalError(MessageKitError.couldNotCreateAssetsPath) } guard let resourceBundle = Bundle(url: resourceBundleUrl) else { - fatalError("MessageKit: Could not load the assets bundle") + fatalError(MessageKitError.couldNotLoadAssetsBundle) } return resourceBundle diff --git a/Sources/Extensions/CGRect+Extensions.swift b/Sources/Extensions/CGRect+Extensions.swift index c8c733d9..3b68368c 100644 --- a/Sources/Extensions/CGRect+Extensions.swift +++ b/Sources/Extensions/CGRect+Extensions.swift @@ -31,4 +31,3 @@ extension CGRect { } } - diff --git a/Sources/Layout/MessageIntermediateLayoutAttributes.swift b/Sources/Layout/MessageIntermediateLayoutAttributes.swift index 0115d7eb..700bdf98 100644 --- a/Sources/Layout/MessageIntermediateLayoutAttributes.swift +++ b/Sources/Layout/MessageIntermediateLayoutAttributes.swift @@ -52,7 +52,7 @@ final class MessageIntermediateLayoutAttributes { case .cellTrailing: origin.x = cellFrame.width - avatarSize.width case .natural: - fatalError("AvatarPosition Horizontal.natural needs to be resolved.") + fatalError(MessageKitError.avatarPositionUnresolved) } switch avatarPosition.vertical { @@ -91,7 +91,7 @@ final class MessageIntermediateLayoutAttributes { case .cellTrailing: origin.x = cellFrame.width - avatarSize.width - messageContainerSize.width - messageContainerPadding.right case .natural: - fatalError("AvatarPosition Horizontal.natural needs to be resolved.") + fatalError(MessageKitError.avatarPositionUnresolved) } return CGRect(origin: origin, size: messageContainerSize) diff --git a/Sources/Layout/MessagesCollectionViewFlowLayout.swift b/Sources/Layout/MessagesCollectionViewFlowLayout.swift index 81b9ad49..f7c0aa97 100644 --- a/Sources/Layout/MessagesCollectionViewFlowLayout.swift +++ b/Sources/Layout/MessagesCollectionViewFlowLayout.swift @@ -66,7 +66,7 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { /// Convenience property for accessing the layout object's `MessagesCollectionView`. fileprivate var messagesCollectionView: MessagesCollectionView { guard let messagesCollectionView = collectionView as? MessagesCollectionView else { - fatalError("MessagesCollectionViewFlowLayout is being used on a foreign type.") + fatalError(MessageKitError.layoutUsedOnForeignType) } return messagesCollectionView } @@ -74,7 +74,7 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { /// Convenience property for unwrapping the `MessagesCollectionView`'s `MessagesDataSource`. fileprivate var messagesDataSource: MessagesDataSource { guard let messagesDataSource = messagesCollectionView.messagesDataSource else { - fatalError("MessagesDataSource has not been set.") + fatalError(MessageKitError.nilMessagesDataSource) } return messagesDataSource } @@ -82,7 +82,7 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { /// Convenience property for unwrapping the `MessagesCollectionView`'s `MessagesLayoutDelegate`. fileprivate var messagesLayoutDelegate: MessagesLayoutDelegate { guard let messagesLayoutDelegate = messagesCollectionView.messagesLayoutDelegate else { - fatalError("MessagesLayoutDeleagte has not been set.") + fatalError(MessageKitError.nilMessagesLayoutDeleagte) } return messagesLayoutDelegate } @@ -104,7 +104,7 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { sectionInset = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) - NotificationCenter.default.addObserver(self, selector: #selector(handleOrientationChange), name: .UIDeviceOrientationDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(MessagesCollectionViewFlowLayout.handleOrientationChange(_:)), name: .UIDeviceOrientationDidChange, object: nil) } required public init?(coder aDecoder: NSCoder) { @@ -502,7 +502,7 @@ private extension MessagesCollectionViewFlowLayout { return itemWidth - avatarWidth - attributes.messageContainerPadding.right - attributes.bottomLabelHorizontalPadding case (_, .natural): - fatalError("AvatarPosition Horizontal.natural needs to be resolved.") + fatalError(MessageKitError.avatarPositionUnresolved) } } @@ -575,7 +575,7 @@ private extension MessagesCollectionViewFlowLayout { return itemWidth - avatarWidth - attributes.messageContainerPadding.right - attributes.topLabelHorizontalPadding case (_, .natural): - fatalError("AvatarPosition Horizontal.natural needs to be resolved.") + fatalError(MessageKitError.avatarPositionUnresolved) } } diff --git a/Sources/Models/LocationMessageSnapshotOptions.swift b/Sources/Models/LocationMessageSnapshotOptions.swift index 0052504b..21628fcb 100644 --- a/Sources/Models/LocationMessageSnapshotOptions.swift +++ b/Sources/Models/LocationMessageSnapshotOptions.swift @@ -27,24 +27,38 @@ import MapKit /// An object grouping the settings used by the `MKMapSnapshotter` through the `LocationMessageDisplayDelegate`. public struct LocationMessageSnapshotOptions { + /// Initialize LocationMessageSnapshotOptions with given parameters + /// + /// - Parameters: + /// - showsBuildings: A Boolean value indicating whether the snapshot image should display buildings. + /// - showsPointsOfInterest: A Boolean value indicating whether the snapshot image should display points of interest. + /// - span: The span of the snapshot. + /// - scale: The scale of the snapshot. + public init(showsBuildings: Bool = false, showsPointsOfInterest: Bool = false, span: MKCoordinateSpan = MKCoordinateSpan(latitudeDelta: 0, longitudeDelta: 0), scale: CGFloat = UIScreen.main.scale) { + self.showsBuildings = showsBuildings + self.showsPointsOfInterest = showsPointsOfInterest + self.span = span + self.scale = scale + } + /// A Boolean value indicating whether the snapshot image should display buildings. /// /// The default value of this property is `false`. - var showsBuildings = false - + public var showsBuildings: Bool + /// A Boolean value indicating whether the snapshot image should display points of interest. /// /// The default value of this property is `false`. - var showsPointsOfInterest = false - + public var showsPointsOfInterest: Bool + /// The span of the snapshot. /// /// The default value of this property uses a width of `0` and height of `0`. - var span: MKCoordinateSpan = MKCoordinateSpan(latitudeDelta: 0, longitudeDelta: 0) - + public var span: MKCoordinateSpan + /// The scale of the snapshot. /// /// The default value of this property uses the `UIScreen.main.scale`. - var scale: CGFloat = UIScreen.main.scale + public var scale: CGFloat } diff --git a/Sources/Models/MessageKitError.swift b/Sources/Models/MessageKitError.swift new file mode 100644 index 00000000..f26797c4 --- /dev/null +++ b/Sources/Models/MessageKitError.swift @@ -0,0 +1,36 @@ +/* + MIT License + + Copyright (c) 2017 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. + */ + +enum MessageKitError { + static let avatarPositionUnresolved = "AvatarPosition Horizontal.natural needs to be resolved." + static let nilMessagesDataSource = "MessagesDataSource has not been set." + static let nilMessagesDisplayDelegate = "MessagesDisplayDelegate has not been set." + static let nilMessagesLayoutDeleagte = "MessagesLayoutDelegate has not been set." + static let notMessagesCollectionView = "The collectionView is not a MessagesCollectionView." + static let layoutUsedOnForeignType = "MessagesCollectionViewFlowLayout is being used on a foreign type." + static let unrecognizedSectionKind = "Received unrecognized element kind:" + static let unrecognizedCheckingResult = "Received an unrecognized NSTextCheckingResult.CheckingType" + static let couldNotLoadAssetsBundle = "MessageKit: Could not load the assets bundle" + static let couldNotCreateAssetsPath = "MessageKit: Could not create path to the assets bundle." +} diff --git a/Sources/Models/Sender.swift b/Sources/Models/Sender.swift index 1b703584..8fc5e944 100644 --- a/Sources/Models/Sender.swift +++ b/Sources/Models/Sender.swift @@ -50,7 +50,7 @@ public struct Sender { extension Sender: Equatable { /// Two senders are considered equal if they have the same id. - static public func == (left: Sender, right: Sender) -> Bool { + public static func == (left: Sender, right: Sender) -> Bool { return left.id == right.id } diff --git a/Sources/Protocols/MessagesDataSource.swift b/Sources/Protocols/MessagesDataSource.swift index 29b84583..4d0f12f2 100644 --- a/Sources/Protocols/MessagesDataSource.swift +++ b/Sources/Protocols/MessagesDataSource.swift @@ -51,16 +51,6 @@ public protocol MessagesDataSource: AnyObject { /// - messagesCollectionView: The `MessagesCollectionView` in which the messages will be displayed. func numberOfMessages(in messagesCollectionView: MessagesCollectionView) -> Int - /// The `Avatar` information to be used by the `AvatarView`. - /// - /// - Parameters: - /// - message: The `MessageType` that will be displayed by this cell. - /// - indexPath: The `IndexPath` of the cell. - /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. - /// - /// The default value returned by this method is `Avatar()`. - func avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> Avatar - /// The attributed text to be used for cell's top label. /// /// - Parameters: @@ -89,10 +79,6 @@ public extension MessagesDataSource { return message.sender == currentSender() } - func avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> Avatar { - return Avatar() - } - func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { return nil } diff --git a/Sources/Protocols/MessagesDisplayDelegate.swift b/Sources/Protocols/MessagesDisplayDelegate.swift index b71ba6a6..e5620dc0 100644 --- a/Sources/Protocols/MessagesDisplayDelegate.swift +++ b/Sources/Protocols/MessagesDisplayDelegate.swift @@ -83,6 +83,17 @@ public protocol MessagesDisplayDelegate: AnyObject { /// /// The default value returned by this method is a `MessageFooterView`. func messageFooterView(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageFooterView + + /// Configure `AvatarView`‘s image. + /// + /// - Parameters: + /// - avatarView: The `AvatarView` of the cell. + /// - message: The `MessageType` that will be displayed by this cell. + /// - indexPath: The `IndexPath` of the cell. + /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. + /// + /// The default image configured by this method is `?`. + func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) // MARK: - Text Messages @@ -193,11 +204,17 @@ public extension MessagesDisplayDelegate { func messageFooterView(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageFooterView { return messagesCollectionView.dequeueReusableFooterView(MessageFooterView.self, for: indexPath) } + + func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) { + avatarView.initials = "?" + } // MARK: - Text Messages Defaults func textColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor { - guard let dataSource = messagesCollectionView.messagesDataSource else { return .darkText } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } return dataSource.isFromCurrentSender(message: message) ? .white : .darkText } diff --git a/Sources/Protocols/MessagesLayoutDelegate.swift b/Sources/Protocols/MessagesLayoutDelegate.swift index 6a8f3e81..f1be3042 100644 --- a/Sources/Protocols/MessagesLayoutDelegate.swift +++ b/Sources/Protocols/MessagesLayoutDelegate.swift @@ -192,7 +192,9 @@ public extension MessagesLayoutDelegate { // MARK: - All Messages Defaults func messagePadding(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIEdgeInsets { - guard let dataSource = messagesCollectionView.messagesDataSource else { return .zero } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } if dataSource.isFromCurrentSender(message: message) { return UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 4) } else { @@ -201,12 +203,16 @@ public extension MessagesLayoutDelegate { } func cellTopLabelAlignment(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LabelAlignment { - guard let dataSource = messagesCollectionView.messagesDataSource else { return .cellCenter(.zero) } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } return dataSource.isFromCurrentSender(message: message) ? .messageTrailing(.zero) : .messageLeading(.zero) } func cellBottomLabelAlignment(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LabelAlignment { - guard let dataSource = messagesCollectionView.messagesDataSource else { return .cellCenter(.zero) } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } return dataSource.isFromCurrentSender(message: message) ? .messageLeading(.zero) : .messageTrailing(.zero) } @@ -219,7 +225,9 @@ public extension MessagesLayoutDelegate { } func headerViewSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize { - guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { return .zero } + guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { + fatalError(MessageKitError.nilMessagesDisplayDelegate) + } let shouldDisplay = displayDelegate.shouldDisplayHeader(for: message, at: indexPath, in: messagesCollectionView) return shouldDisplay ? CGSize(width: messagesCollectionView.bounds.width, height: 12) : .zero } @@ -235,7 +243,9 @@ public extension MessagesLayoutDelegate { // MARK: - Text Messages Defaults func messageLabelInset(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIEdgeInsets { - guard let dataSource = messagesCollectionView.messagesDataSource else { return .zero } + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } if dataSource.isFromCurrentSender(message: message) { return UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 18) } else { @@ -264,5 +274,4 @@ public extension MessagesLayoutDelegate { func widthForLocation(message: MessageType, at indexPath: IndexPath, with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat { return maxWidth } - } diff --git a/Sources/Supporting/MessageKit+Availability.swift b/Sources/Supporting/MessageKit+Availability.swift index 573c15d4..31fee58f 100644 --- a/Sources/Supporting/MessageKit+Availability.swift +++ b/Sources/Supporting/MessageKit+Availability.swift @@ -62,6 +62,8 @@ extension MessagesLayoutDelegate { } +// MARK: - MessagesCollectionViewFlowLayout + extension MessagesCollectionViewFlowLayout { /// A Boolean value that determines if the `AvatarView` is always on the leading @@ -88,6 +90,25 @@ extension MessagesCollectionViewFlowLayout { } +// MARK: - MessagesDataSource + +extension MessagesDataSource { + /// The `Avatar` information to be used by the `AvatarView`. + /// + /// - Parameters: + /// - message: The `MessageType` that will be displayed by this cell. + /// - indexPath: The `IndexPath` of the cell. + /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. + /// + /// The default value returned by this method is `Avatar()`. + @available(*, deprecated: 0.12.1, message: "Removed in MessageKit 0.12.1.") + func avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> Avatar { + fatalError("Fatal Error: avatar(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) is no longer supported") + } +} + +// MARK: - MessagesViewController + extension MessagesViewController { /// A Boolean value that determines whether the `MessagesCollectionView` scrolls to the /// bottom on the view's first layout. diff --git a/Sources/Views/AvatarView.swift b/Sources/Views/AvatarView.swift index b7feed9f..be28e739 100644 --- a/Sources/Views/AvatarView.swift +++ b/Sources/Views/AvatarView.swift @@ -24,27 +24,25 @@ import Foundation -open class AvatarView: UIView { +open class AvatarView: UIImageView { // MARK: - Properties - - open var avatar: Avatar = Avatar() - - open var imageView = UIImageView() - - public var image: UIImage? { - return imageView.image + + open var initials: String? { + didSet { + setImageFrom(initials: initials) + } } open var placeholderFont: UIFont = UIFont.preferredFont(forTextStyle: .caption1) { didSet { - set(avatar: avatar) + setImageFrom(initials: initials) } } open var placeholderTextColor: UIColor = .white { didSet { - set(avatar: avatar) + setImageFrom(initials: initials) } } @@ -61,14 +59,12 @@ open class AvatarView: UIView { // MARK: - Overridden Properties open override var frame: CGRect { didSet { - imageView.frame = bounds setCorner(radius: self.radius) } } open override var bounds: CGRect { didSet { - imageView.frame = bounds setCorner(radius: self.radius) } } @@ -82,6 +78,11 @@ open class AvatarView: UIView { convenience public init() { self.init(frame: .zero) } + + private func setImageFrom(initials: String?) { + guard let initials = initials else { return } + image = getImageFrom(initials: initials) + } private func getImageFrom(initials: String) -> UIImage { let width = frame.width @@ -160,17 +161,17 @@ open class AvatarView: UIView { contentMode = .scaleAspectFill layer.masksToBounds = true clipsToBounds = true - imageView.contentMode = .scaleAspectFill - imageView.frame = frame - addSubview(imageView) - imageView.image = avatar.image ?? getImageFrom(initials: avatar.initials) setCorner(radius: nil) } // MARK: - Open setters - + open func set(avatar: Avatar) { - imageView.image = avatar.image ?? getImageFrom(initials: avatar.initials) + if let image = avatar.image { + self.image = image + } else { + initials = avatar.initials + } } open func setCorner(radius: CGFloat?) { diff --git a/Sources/Views/Cells/LocationMessageCell.swift b/Sources/Views/Cells/LocationMessageCell.swift index eb8334cc..ad4aad7a 100644 --- a/Sources/Views/Cells/LocationMessageCell.swift +++ b/Sources/Views/Cells/LocationMessageCell.swift @@ -58,7 +58,7 @@ open class LocationMessageCell: MessageCollectionViewCell { open override func configure(with message: MessageType, at indexPath: IndexPath, and messagesCollectionView: MessagesCollectionView) { super.configure(with: message, at: indexPath, and: messagesCollectionView) guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { - fatalError("MessagesDisplayDelegate is not set.") + fatalError(MessageKitError.nilMessagesDisplayDelegate) } let options = displayDelegate.snapshotOptionsForLocation(message: message, at: indexPath, in: messagesCollectionView) let annotationView = displayDelegate.annotationViewForLocation(message: message, at: indexPath, in: messagesCollectionView) @@ -107,7 +107,5 @@ open class LocationMessageCell: MessageCollectionViewCell { self.imageView.image = composedImage animationBlock?(self.imageView) } - - } } diff --git a/Sources/Views/Cells/MessageCollectionViewCell.swift b/Sources/Views/Cells/MessageCollectionViewCell.swift index 1e11f3b7..347bbcd1 100644 --- a/Sources/Views/Cells/MessageCollectionViewCell.swift +++ b/Sources/Views/Cells/MessageCollectionViewCell.swift @@ -53,11 +53,10 @@ open class MessageCollectionViewCell: UICollectionViewCell, CollectionViewReusab open weak var delegate: MessageCellDelegate? - override public init(frame: CGRect) { + public override init(frame: CGRect) { super.init(frame: frame) contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight] setupSubviews() - setupGestureRecognizers() } required public init?(coder aDecoder: NSCoder) { @@ -93,39 +92,31 @@ open class MessageCollectionViewCell: UICollectionViewCell, CollectionViewReusab open func configure(with message: MessageType, at indexPath: IndexPath, and messagesCollectionView: MessagesCollectionView) { guard let dataSource = messagesCollectionView.messagesDataSource else { - fatalError("MessagesDataSource is not set.") + fatalError(MessageKitError.nilMessagesDataSource) } guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { - fatalError("MessagesDisplayDelegate is not set.") + fatalError(MessageKitError.nilMessagesDisplayDelegate) } delegate = messagesCollectionView.messageCellDelegate let messageColor = displayDelegate.backgroundColor(for: message, at: indexPath, in: messagesCollectionView) let messageStyle = displayDelegate.messageStyle(for: message, at: indexPath, in: messagesCollectionView) + + displayDelegate.configureAvatarView(avatarView, for: message, at: indexPath, in: messagesCollectionView) messageContainerView.backgroundColor = messageColor messageContainerView.style = messageStyle - let avatar = dataSource.avatar(for: message, at: indexPath, in: messagesCollectionView) let topText = dataSource.cellTopLabelAttributedText(for: message, at: indexPath) let bottomText = dataSource.cellBottomLabelAttributedText(for: message, at: indexPath) - avatarView.set(avatar: avatar) cellTopLabel.attributedText = topText cellBottomLabel.attributedText = bottomText } - func setupGestureRecognizers() { - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) - contentView.addGestureRecognizer(tapGesture) - } - /// Handle tap gesture on contentView and its subviews like messageContainerView, cellTopLabel, cellBottomLabel, avatarView .... - @objc open func handleTapGesture(_ gesture: UIGestureRecognizer) { - guard gesture.state == .ended else { return } - let touchLocation = gesture.location(in: self) switch true { @@ -141,6 +132,13 @@ open class MessageCollectionViewCell: UICollectionViewCell, CollectionViewReusab break } } + + /// Handle long press gesture, return true when gestureRecognizer's touch point in `messageContainerView`'s frame + open override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + let touchPoint = gestureRecognizer.location(in: self) + guard gestureRecognizer.isKind(of: UILongPressGestureRecognizer.self) else { return false } + return messageContainerView.frame.contains(touchPoint) + } /// Handle `ContentView`'s tap gesture, return false when `ContentView` doesn't needs to handle gesture open func cellContentView(canHandle touchPoint: CGPoint) -> Bool { diff --git a/Sources/Views/Cells/TextMessageCell.swift b/Sources/Views/Cells/TextMessageCell.swift index 339338fa..242d713e 100644 --- a/Sources/Views/Cells/TextMessageCell.swift +++ b/Sources/Views/Cells/TextMessageCell.swift @@ -45,6 +45,7 @@ open class TextMessageCell: MessageCollectionViewCell { if let attributes = layoutAttributes as? MessagesCollectionViewLayoutAttributes { messageLabel.textInsets = attributes.messageLabelInsets messageLabel.font = attributes.messageLabelFont + messageLabel.frame = messageContainerView.bounds } } @@ -57,18 +58,13 @@ open class TextMessageCell: MessageCollectionViewCell { open override func setupSubviews() { super.setupSubviews() messageContainerView.addSubview(messageLabel) - setupConstraints() - } - - open func setupConstraints() { - messageLabel.fillSuperview() } open override func configure(with message: MessageType, at indexPath: IndexPath, and messagesCollectionView: MessagesCollectionView) { super.configure(with: message, at: indexPath, and: messagesCollectionView) guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { - fatalError("MessagesDisplayDelegate not set.") + fatalError(MessageKitError.nilMessagesDisplayDelegate) } let textColor = displayDelegate.textColor(for: message, at: indexPath, in: messagesCollectionView) diff --git a/Sources/Views/InputTextView.swift b/Sources/Views/InputTextView.swift index 7e7762b7..c3ea0b8a 100644 --- a/Sources/Views/InputTextView.swift +++ b/Sources/Views/InputTextView.swift @@ -49,6 +49,17 @@ open class InputTextView: UITextView { } } + /// The images that are currently stored as NSTextAttachment's + open var images: [UIImage] { + return parseForAttachedImages() + } + + open var components: [Any] { + return parseForComponents() + } + + open var isImagePasteEnabled: Bool = true + /// A UILabel that holds the InputTextView's placeholder text open let placeholderLabel: UILabel = { let label = UILabel() @@ -148,7 +159,9 @@ open class InputTextView: UITextView { layer.cornerRadius = 5.0 layer.borderWidth = 1.25 layer.borderColor = UIColor.lightGray.cgColor + allowsEditingTextAttributes = false setupPlaceholderLabel() + setupObservers() } /// Adds the placeholderLabel to the view and sets up its initial constraints @@ -168,6 +181,14 @@ open class InputTextView: UITextView { placeholderLabelConstraintSet?.activate() } + /// Adds the required notification observers + private func setupObservers() { + + NotificationCenter.default.addObserver(self, + selector: #selector(InputTextView.redrawTextAttachments), + name: .UIDeviceOrientationDidChange, object: nil) + } + /// Updates the placeholderLabels constraint constants to match the placeholderLabelInsets private func updateConstraintsForPlaceholderLabel() { @@ -176,4 +197,143 @@ open class InputTextView: UITextView { placeholderLabelConstraintSet?.left?.constant = placeholderLabelInsets.left placeholderLabelConstraintSet?.right?.constant = -placeholderLabelInsets.right } + + // MARK: - Image Paste Support + + open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + + if action == NSSelectorFromString("paste:") && UIPasteboard.general.image != nil { + return isImagePasteEnabled + } + return super.canPerformAction(action, withSender: sender) + } + + open override func paste(_ sender: Any?) { + + guard let image = UIPasteboard.general.image else { + return super.paste(sender) + } + pasteImageInTextContainer(with: image) + } + + /// Addes a new UIImage to the NSTextContainer as an NSTextAttachment + /// + /// - Parameter image: The image to add + private func pasteImageInTextContainer(with image: UIImage) { + + // Add the new image as an NSTextAttachment + let attributedImageString = NSAttributedString(attachment: textAttachment(using: image)) + + let isEmpty = attributedText.length == 0 + + // Add a new line character before the image, this is what iMessage does + let newAttributedStingComponent = isEmpty ? NSMutableAttributedString(string: "") : NSMutableAttributedString(string: "\n") + newAttributedStingComponent.append(attributedImageString) + + // Add a new line character after the image, this is what iMessage does + newAttributedStingComponent.append(NSAttributedString(string: "\n")) + + // The attributes that should be applied to the new NSAttributedString to match the current attributes + let attributes: [NSAttributedStringKey: Any] = [ + NSAttributedStringKey.font: font ?? UIFont.preferredFont(forTextStyle: .body), + NSAttributedStringKey.foregroundColor: textColor ?? .black + ] + newAttributedStingComponent.addAttributes(attributes, range: NSRange(location: 0, length: newAttributedStingComponent.length)) + + textStorage.beginEditing() + // Paste over selected text + textStorage.replaceCharacters(in: selectedRange, with: newAttributedStingComponent) + textStorage.endEditing() + + // Advance the range to the selected range plus the number of characters added + let location = selectedRange.location + (isEmpty ? 2 : 3) + selectedRange = NSRange(location: location, length: 0) + + // Broadcast a notification to recievers such as the MessageInputBar which will handle resizing + NotificationCenter.default.post(name: .UITextViewTextDidChange, object: self) + } + + /// Returns an NSTextAttachment the provided image that will fit inside the NSTextContainer + /// + /// - Parameter image: The image to create an attachment with + /// - Returns: The formatted NSTextAttachment + private func textAttachment(using image: UIImage) -> NSTextAttachment { + + guard let cgImage = image.cgImage else { return NSTextAttachment() } + let scale = image.size.width / (frame.width - 2 * (textContainerInset.left + textContainerInset.right)) + let textAttachment = NSTextAttachment() + textAttachment.image = UIImage(cgImage: cgImage, scale: scale, orientation: .up) + return textAttachment + } + + /// Returns all images that exist as NSTextAttachment's + /// + /// - Returns: An array of type UIImage + private func parseForAttachedImages() -> [UIImage] { + + var images = [UIImage]() + let range = NSRange(location: 0, length: attributedText.length) + attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, range, _ -> Void in + + if let attachment = value as? NSTextAttachment { + if let image = attachment.image { + images.append(image) + } else if let image = attachment.image(forBounds: attachment.bounds, + textContainer: nil, + characterIndex: range.location) { + images.append(image) + } + } + }) + return images + } + + /// Returns an array of components (either a String or UIImage) that makes up the textContainer in + /// the order that they were typed + /// + /// - Returns: An array of objects guaranteed to be of UIImage or String + private func parseForComponents() -> [Any] { + + var components = [Any]() + let range = NSRange(location: 0, length: attributedText.length) + attributedText.enumerateAttributes(in: range, options: []) { (object, range, _) in + + if object.keys.contains(.attachment) { + if let attachment = object[.attachment] as? NSTextAttachment { + if let image = attachment.image { + components.append(image) + } else if let image = attachment.image(forBounds: attachment.bounds, + textContainer: nil, + characterIndex: range.location) { + components.append(image) + } + } + } else { + let stringValue = attributedText.attributedSubstring(from: range).string.trimmingCharacters(in: .whitespacesAndNewlines) + if !stringValue.isEmpty { + components.append(stringValue) + } + } + } + return components + } + + /// Redraws the NSTextAttachments in the NSTextContainer to fit the current bounds + @objc + private func redrawTextAttachments() { + + guard images.count > 0 else { return } + let range = NSRange(location: 0, length: attributedText.length) + attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, _, _ -> Void in + if let attachment = value as? NSTextAttachment, let image = attachment.image { + + // Calculates a new width/height ratio to fit the image in the current frame + let newWidth = frame.width - 2 * (textContainerInset.left + textContainerInset.right) + let ratio = image.size.height / image.size.width + attachment.bounds.size = CGSize(width: newWidth, height: ratio * newWidth) + } + }) + layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil) + } + } diff --git a/Sources/Views/MessageInputBar.swift b/Sources/Views/MessageInputBar.swift index 84b0735e..a4d4b2b1 100644 --- a/Sources/Views/MessageInputBar.swift +++ b/Sources/Views/MessageInputBar.swift @@ -266,6 +266,7 @@ open class MessageInputBar: UIView { private var bottomStackViewLayoutSet: NSLayoutConstraintSet? private var contentViewLayoutSet: NSLayoutConstraintSet? private var windowAnchor: NSLayoutConstraint? + private var backgroundViewBottomAnchor: NSLayoutConstraint? // MARK: - Initialization @@ -336,7 +337,9 @@ open class MessageInputBar: UIView { // The constraints within the MessageInputBar separatorLine.addConstraints(topAnchor, left: leftAnchor, right: rightAnchor, heightConstant: 1) - backgroundView.addConstraints(topStackView.bottomAnchor, left: leftAnchor, bottom: bottomAnchor, right: rightAnchor) + backgroundViewBottomAnchor = backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) + backgroundViewBottomAnchor?.isActive = true + backgroundView.addConstraints(topStackView.bottomAnchor, left: leftAnchor, right: rightAnchor) topStackViewLayoutSet = NSLayoutConstraintSet( top: topStackView.topAnchor.constraint(equalTo: topAnchor, constant: topStackViewPadding.top), @@ -408,6 +411,7 @@ open class MessageInputBar: UIView { windowAnchor?.constant = -padding.bottom windowAnchor?.priority = UILayoutPriority(rawValue: 750) windowAnchor?.isActive = true + backgroundViewBottomAnchor?.constant = 34 } } } @@ -666,7 +670,7 @@ open class MessageInputBar: UIView { open func textViewDidChange() { let trimmedText = inputTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - sendButton.isEnabled = !trimmedText.isEmpty + sendButton.isEnabled = !trimmedText.isEmpty || inputTextView.images.count > 0 inputTextView.placeholderLabel.isHidden = !inputTextView.text.isEmpty items.forEach { $0.textViewDidChangeAction(with: inputTextView) } diff --git a/Sources/Views/MessageLabel.swift b/Sources/Views/MessageLabel.swift index d8c05d5c..c33a20be 100644 --- a/Sources/Views/MessageLabel.swift +++ b/Sources/Views/MessageLabel.swift @@ -283,7 +283,7 @@ open class MessageLabel: UILabel { case .link: return urlAttributes default: - fatalError("Received an unrecognized NSTextCheckingResult.CheckingType") + fatalError(MessageKitError.unrecognizedCheckingResult) } } @@ -412,7 +412,7 @@ open class MessageLabel: UILabel { } -fileprivate enum MessageTextCheckingType { +private enum MessageTextCheckingType { case addressComponents([NSTextCheckingKey: String]?) case date(Date?) case phoneNumber(String?) diff --git a/Sources/Views/MessagesCollectionView.swift b/Sources/Views/MessagesCollectionView.swift index 245d4b50..44a503d8 100644 --- a/Sources/Views/MessagesCollectionView.swift +++ b/Sources/Views/MessagesCollectionView.swift @@ -51,6 +51,7 @@ open class MessagesCollectionView: UICollectionView { public override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) backgroundColor = .white + setupGestureRecognizers() } required public init?(coder aDecoder: NSCoder) { @@ -62,6 +63,23 @@ open class MessagesCollectionView: UICollectionView { } // MARK: - Methods + + func setupGestureRecognizers() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + tapGesture.delaysTouchesBegan = true + addGestureRecognizer(tapGesture) + } + + @objc + open func handleTapGesture(_ gesture: UIGestureRecognizer) { + guard gesture.state == .ended else { return } + + let touchLocation = gesture.location(in: self) + guard let indexPath = indexPathForItem(at: touchLocation) else { return } + + let cell = cellForItem(at: indexPath) as? MessageCollectionViewCell + cell?.handleTapGesture(gesture) + } public func scrollToBottom(animated: Bool = false) { let collectionViewContentHeight = collectionViewLayout.collectionViewContentSize.height diff --git a/Tests/ControllersTest/MessagesViewControllerTests.swift b/Tests/ControllersTest/MessagesViewControllerTests.swift index e9ebe121..302fd999 100644 --- a/Tests/ControllersTest/MessagesViewControllerTests.swift +++ b/Tests/ControllersTest/MessagesViewControllerTests.swift @@ -205,4 +205,8 @@ private class MockLayoutDelegate: MessagesLayoutDelegate, MessagesDisplayDelegat func heightForMedia(message: MessageType, at indexPath: IndexPath, with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat { return 10.0 } + + func snapshotOptionsForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LocationMessageSnapshotOptions { + return LocationMessageSnapshotOptions() + } } diff --git a/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift b/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift index 59fdf355..89a575dd 100644 --- a/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift +++ b/Tests/ProtocolsTests/MessagesDisplayDelegateTests.swift @@ -85,12 +85,6 @@ class MessagesDisplayDelegateTests: XCTestCase { XCTAssertEqual(backgroundColor, .clear) } - func testAvatarDefaultState() { - XCTAssertNotNil(sut.dataProvider.avatar(for: sut.dataProvider.messages[0], - at: IndexPath(item: 0, section: 0), - in: sut.messagesCollectionView).initials) - } - func testCellTopLabelDefaultState() { XCTAssertNil(sut.dataProvider.cellTopLabelAttributedText(for: sut.dataProvider.messages[0], at: IndexPath(item: 0, section: 0))) @@ -218,7 +212,8 @@ class TextMessageDisplayDelegateTests: XCTestCase { } func testTextColorWithoutDataSource_returnsDarkTextForDefault() { - sut.messagesCollectionView.messagesDataSource = nil + let dataSource = sut.makeDataSource() + sut.messagesCollectionView.messagesDataSource = dataSource let textColor = sut.textColor(for: sut.dataProvider.messages[1], at: IndexPath(item: 0, section: 0), in: sut.messagesCollectionView) @@ -255,7 +250,7 @@ private class MockMessagesViewController: MessagesViewController, MessagesDispla } - private func makeDataSource() -> MockMessagesDataSource { + fileprivate func makeDataSource() -> MockMessagesDataSource { let dataSource = MockMessagesDataSource() dataSource.messages.append(MockMessage(text: "Text 1", sender: dataSource.senders[0], @@ -267,4 +262,7 @@ private class MockMessagesViewController: MessagesViewController, MessagesDispla return dataSource } + func snapshotOptionsForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LocationMessageSnapshotOptions { + return LocationMessageSnapshotOptions() + } } diff --git a/Tests/ViewsTests/AvatarViewTests.swift b/Tests/ViewsTests/AvatarViewTests.swift index 1dbc52f0..a6e9a00e 100644 --- a/Tests/ViewsTests/AvatarViewTests.swift +++ b/Tests/ViewsTests/AvatarViewTests.swift @@ -41,7 +41,6 @@ class AvatarViewTests: XCTestCase { } func testNoParams() { - XCTAssertEqual(avatarView.avatar.initials, "?") XCTAssertEqual(avatarView.layer.cornerRadius, 15.0) XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) } @@ -57,6 +56,7 @@ class AvatarViewTests: XCTestCase { func testInitialsOnly() { let avatar = Avatar(initials: "DL") avatarView.set(avatar: avatar) + XCTAssertEqual(avatarView.initials, avatar.initials) XCTAssertEqual(avatar.initials, "DL") XCTAssertEqual(avatarView.layer.cornerRadius, 15.0) XCTAssertEqual(avatarView.backgroundColor, UIColor.gray) diff --git a/Tests/ViewsTests/MessageCollectionViewCellTests.swift b/Tests/ViewsTests/MessageCollectionViewCellTests.swift index 1973779c..9771c47f 100644 --- a/Tests/ViewsTests/MessageCollectionViewCellTests.swift +++ b/Tests/ViewsTests/MessageCollectionViewCellTests.swift @@ -76,6 +76,11 @@ class MessageCollectionViewCellTests: XCTestCase { extension MessageCollectionViewCellTests { - fileprivate class MockMessagesDisplayDelegate: MessagesDisplayDelegate { } + fileprivate class MockMessagesDisplayDelegate: MessagesDisplayDelegate { + + func snapshotOptionsForLocation(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> LocationMessageSnapshotOptions { + return LocationMessageSnapshotOptions() + } + } }