diff --git a/Assets/MessageKitAssets.bundle/Images/pause.png b/Assets/MessageKitAssets.bundle/Images/pause.png new file mode 100644 index 00000000..487a308f Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/pause.png differ diff --git a/Assets/MessageKitAssets.bundle/Images/pause@2x.png b/Assets/MessageKitAssets.bundle/Images/pause@2x.png new file mode 100644 index 00000000..8f5a3c2f Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/pause@2x.png differ diff --git a/Assets/MessageKitAssets.bundle/Images/pause@3x.png b/Assets/MessageKitAssets.bundle/Images/pause@3x.png new file mode 100644 index 00000000..a15cadda Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/pause@3x.png differ diff --git a/Assets/MessageKitAssets.bundle/Images/play.png b/Assets/MessageKitAssets.bundle/Images/play.png new file mode 100644 index 00000000..9ae69327 Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/play.png differ diff --git a/Assets/MessageKitAssets.bundle/Images/play@2x.png b/Assets/MessageKitAssets.bundle/Images/play@2x.png new file mode 100644 index 00000000..c769e68f Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/play@2x.png differ diff --git a/Assets/MessageKitAssets.bundle/Images/play@3x.png b/Assets/MessageKitAssets.bundle/Images/play@3x.png new file mode 100644 index 00000000..53fb82a0 Binary files /dev/null and b/Assets/MessageKitAssets.bundle/Images/play@3x.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index ed696c9d..6a8d4c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,52 @@ The changelog for `MessageKit`. Also see the [releases](https://github.com/Messa -------------------------------------- + +## Upcoming Release + +### Changed + +- **Breaking Change** Add support for audio messages [#892](https://github.com/MessageKit/MessageKit/pull/892) by [@moldovaniosif](https://github.com/moldovaniosif). Added new protocols `AudioControllerDelegate`, `AudioItem` a new cell `AudioMessageCell` and a new controller `BasicAudioController`. + +- **Breaking Change** Moved `handleTapGesture` method to `MessageCollectionViewCell` [#950](https://github.com/MessageKit/MessageKit/pull/950) by [@nathantannar4](https://github.com/nathantannar4) + +- **Breaking Change** Renamed function `layoutBottomLabel(with:)` to `layoutMessageBottomLabel(with:)` in `MessageContentCell` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +### Added + +- **Breaking Change** Added `avatarLeadingTrailingPadding` as a property of `CellSizeCalculator` and `MessagesCollectionViewLayoutAttributes` to inset the `AvatarView` layout [#944](https://github.com/MessageKit/MessageKit/pull/944) by [@nathantannar4](https://github.com/nathantannar4) + +- **Breaking Change** Added `didTapBackground(in:)` function to `MessageCellDelegate` protocol. +[#922](https://github.com/MessageKit/MessageKit/pull/922) by [@kpennacchia](https://github.com/kpennacchia) + +- **Breaking Change** Added `didTapCellBottomLabel(in:)` function to `MessageCellDelegate` protocol. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- **Breaking Change** Added `cellBottomLabelAttributedText(for:, at:)` function to `MessagesDataSource` protocol. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- **Breaking Change** Added `cellBottomLabelHeight(for:, at:, in messagesCollectionView:)` function to `MessagesLayoutDelegate` protocol. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `cellBottomLabel` to `MessageContentCell`. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `layoutCellBottomLabel(with:)` function to `MessageContentCell` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `setMessageIncomingCellBottomLabelAlignment(_:)` and `setMessageOutgoingCellBottomLabelAlignment(_:)` functions to `MessagesCollectionViewFlowLayout` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `cellBottomLabelAlignment` and `cellBottomLabelSize` variables to `MessagesCollectionViewLayoutAttributes` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `incomingCellBottomLabelAlignment` and `outgoingCellBottomLabelAlignment` variables to `MessageSizeCalculator` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + +- Added `cellBottomLabelSize(for:, at:)` and `cellBottomLabelAlignment(for:)` functions to `MessageSizeCalculator` class. +[#920](https://github.com/MessageKit/MessageKit/pull/920) by [@maxxx777](https://github.com/maxxx777) + ## [2.0.0](https://github.com/MessageKit/MessageKit/releases/tag/2.0.0) ### Changed diff --git a/Cartfile.resolved b/Cartfile.resolved index 80359d80..6f050b16 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,3 +1,3 @@ github "MessageKit/MessageInputBar" "0.4.0" -github "Quick/Nimble" "v7.3.1" +github "Quick/Nimble" "v7.3.2" github "Quick/Quick" "v1.3.2" diff --git a/Example/ChatExample.xcodeproj/project.pbxproj b/Example/ChatExample.xcodeproj/project.pbxproj index 5074c186..9e37a166 100644 --- a/Example/ChatExample.xcodeproj/project.pbxproj +++ b/Example/ChatExample.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ 385C2946211FF38F0010B4BA /* LaunchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C293F211FF38F0010B4BA /* LaunchViewController.swift */; }; 385C2947211FF38F0010B4BA /* BasicExampleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C2940211FF38F0010B4BA /* BasicExampleViewController.swift */; }; 385C2948211FF38F0010B4BA /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 385C2941211FF38F0010B4BA /* ChatViewController.swift */; }; + 50739F9621C5090A008CA369 /* BasicAudioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50739F9521C5090A008CA369 /* BasicAudioController.swift */; }; + 5074EF4E2163555900D82952 /* sound2.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5074EF4C2163555900D82952 /* sound2.m4a */; }; + 5074EF4F2163555900D82952 /* sound1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5074EF4D2163555900D82952 /* sound1.m4a */; }; 882B5E811CF7D53600B6E160 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 882B5E781CF7D53600B6E160 /* AppDelegate.swift */; }; 882B5E821CF7D53600B6E160 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 882B5E791CF7D53600B6E160 /* Assets.xcassets */; }; 882B5E831CF7D53600B6E160 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 882B5E7A1CF7D53600B6E160 /* LaunchScreen.storyboard */; }; @@ -87,6 +90,9 @@ 385C2940211FF38F0010B4BA /* BasicExampleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasicExampleViewController.swift; sourceTree = ""; }; 385C2941211FF38F0010B4BA /* ChatViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; 3B316705C4717C3B4C916D62 /* Pods_ChatExample.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ChatExample.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50739F9521C5090A008CA369 /* BasicAudioController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasicAudioController.swift; sourceTree = ""; }; + 5074EF4C2163555900D82952 /* sound2.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sound2.m4a; sourceTree = ""; }; + 5074EF4D2163555900D82952 /* sound1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sound1.m4a; sourceTree = ""; }; 56F0AC85B38034EC92CCBC7D /* Pods_ChatExampleTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ChatExampleTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 882B5E331CF7D4B900B6E160 /* ChatExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 882B5E491CF7D4B900B6E160 /* ChatExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ChatExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -193,6 +199,23 @@ path = "View Controllers"; sourceTree = ""; }; + 50739F9421C5075D008CA369 /* AudioController */ = { + isa = PBXGroup; + children = ( + 50739F9521C5090A008CA369 /* BasicAudioController.swift */, + ); + path = AudioController; + sourceTree = ""; + }; + 5074EF4B2163554900D82952 /* Sounds */ = { + isa = PBXGroup; + children = ( + 5074EF4D2163555900D82952 /* sound1.m4a */, + 5074EF4C2163555900D82952 /* sound2.m4a */, + ); + path = Sounds; + sourceTree = ""; + }; 7523553CCB4B87460CE4DED6 /* Pods */ = { isa = PBXGroup; children = ( @@ -232,11 +255,13 @@ isa = PBXGroup; children = ( 882B5E781CF7D53600B6E160 /* AppDelegate.swift */, + 50739F9421C5075D008CA369 /* AudioController */, 385C2949211FF3930010B4BA /* View Controllers */, 385C293A211FF3800010B4BA /* Data Generation */, 385C292E211FF3540010B4BA /* Layout */, 385C2929211FF33D0010B4BA /* Models */, 385C2924211FF3310010B4BA /* Views */, + 5074EF4B2163554900D82952 /* Sounds */, 882B5E791CF7D53600B6E160 /* Assets.xcassets */, 882B5E7F1CF7D53600B6E160 /* Info.plist */, 882B5E7A1CF7D53600B6E160 /* LaunchScreen.storyboard */, @@ -386,7 +411,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5074EF4F2163555900D82952 /* sound1.m4a in Resources */, 882B5E821CF7D53600B6E160 /* Assets.xcassets in Resources */, + 5074EF4E2163555900D82952 /* sound2.m4a in Resources */, 882B5E831CF7D53600B6E160 /* LaunchScreen.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -508,6 +535,7 @@ 385C2928211FF33B0010B4BA /* MockMessage.swift in Sources */, 385C2923211FF32E0010B4BA /* TableViewCells.swift in Sources */, 385C2942211FF38F0010B4BA /* AdvancedExampleViewController.swift in Sources */, + 50739F9621C5090A008CA369 /* BasicAudioController.swift in Sources */, 385C2937211FF37B0010B4BA /* SampleData.swift in Sources */, 385C2931211FF3630010B4BA /* UIColor+Extensions.swift in Sources */, 385C2932211FF3630010B4BA /* UIViewController+Extensions.swift in Sources */, diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 6d8fef54..f272a510 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -2,7 +2,7 @@ PODS: - MessageInputBar (0.4.1): - MessageInputBar/Core (= 0.4.1) - MessageInputBar/Core (0.4.1) - - MessageKit (1.0.0): + - MessageKit (2.0.0): - MessageInputBar/Core DEPENDENCIES: @@ -23,7 +23,7 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: MessageInputBar: e81c7535347f1f7b923de7080409a535a004b6e4 - MessageKit: 2bbd13dd6a7c06f42f2d13ed8871d1fe5383b477 + MessageKit: e86b06ad2eb2d4ddea3ba29c4d9d13edf78843d1 PODFILE CHECKSUM: 04c1a805e1997e83bacab1a34787e71e9fe4432b diff --git a/Example/Sources/AudioController/BasicAudioController.swift b/Example/Sources/AudioController/BasicAudioController.swift new file mode 100644 index 00000000..4ff00d2b --- /dev/null +++ b/Example/Sources/AudioController/BasicAudioController.swift @@ -0,0 +1,214 @@ +/* + 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 +import AVFoundation +import MessageKit + +/// The `PlayerState` indicates the current audio controller state +public enum PlayerState { + + /// The audio controller is currently playing a sound + case playing + + /// The audio controller is currently in pause state + case pause + + /// The audio controller is not playing any sound and audioPlayer is nil + case stopped +} + +/// The `BasicAudioController` update UI for current audio cell that is playing a sound +/// and also creates and manage an `AVAudioPlayer` states, play, pause and stop. +open class BasicAudioController: NSObject, AVAudioPlayerDelegate { + + /// The `AVAudioPlayer` that is playing the sound + open var audioPlayer: AVAudioPlayer? + + /// The `AudioMessageCell` that is currently playing sound + open weak var playingCell: AudioMessageCell? + + /// The `MessageType` that is currently playing sound + open var playingMessage: MessageType? + + /// Specify if current audio controller state: playing, in pause or none + open private(set) var state: PlayerState = .stopped + + // The `MessagesCollectionView` where the playing cell exist + public weak var messageCollectionView: MessagesCollectionView? + + /// The `Timer` that update playing progress + internal var progressTimer: Timer? + + // MARK: - Init Methods + + public init(messageCollectionView: MessagesCollectionView) { + self.messageCollectionView = messageCollectionView + super.init() + } + + // MARK: - Methods + + /// Used to configure the audio cell UI: + /// 1. play button selected state; + /// 2. progresssView progress; + /// 3. durationLabel text; + /// + /// - Parameters: + /// - cell: The `AudioMessageCell` that needs to be configure. + /// - message: The `MessageType` that configures the cell. + /// + /// - Note: + /// This protocol method is called by MessageKit every time an audio cell needs to be configure + open func configureAudioCell(_ cell: AudioMessageCell, message: MessageType) { + if playingMessage?.messageId == message.messageId, let collectionView = messageCollectionView, let player = audioPlayer { + playingCell = cell + cell.progressView.progress = (player.duration == 0) ? 0 : Float(player.currentTime/player.duration) + cell.playButton.isSelected = (player.isPlaying == true) ? true : false + guard let displayDelegate = collectionView.messagesDisplayDelegate else { + fatalError("MessagesDisplayDelegate has not been set.") + } + cell.durationLabel.text = displayDelegate.audioProgressTextFormat(Float(player.currentTime), for: cell, in: collectionView) + } + } + + /// Used to start play audio sound + /// + /// - Parameters: + /// - message: The `MessageType` that contain the audio item to be played. + /// - audioCell: The `AudioMessageCell` that needs to be updated while audio is playing. + open func playSound(for message: MessageType, in audioCell: AudioMessageCell) { + switch message.kind { + case .audio(let item): + playingCell = audioCell + playingMessage = message + guard let player = try? AVAudioPlayer(contentsOf: item.url) else { + print("Failed to create audio player for URL: \(item.url)") + return + } + audioPlayer = player + audioPlayer?.prepareToPlay() + audioPlayer?.delegate = self + audioPlayer?.play() + state = .playing + audioCell.playButton.isSelected = true // show pause button on audio cell + startProgressTimer() + audioCell.delegate?.didStartAudio(in: audioCell) + default: + print("BasicAudioPlayer failed play sound becasue given message kind is not Audio") + } + } + + /// Used to pause the audio sound + /// + /// - Parameters: + /// - message: The `MessageType` that contain the audio item to be pause. + /// - audioCell: The `AudioMessageCell` that needs to be updated by the pause action. + open func pauseSound(for message: MessageType, in audioCell: AudioMessageCell) { + audioPlayer?.pause() + state = .pause + audioCell.playButton.isSelected = false // show play button on audio cell + progressTimer?.invalidate() + if let cell = playingCell { + cell.delegate?.didPauseAudio(in: cell) + } + } + + /// Stops any ongoing audio playing if exists + open func stopAnyOngoingPlaying() { + guard let player = audioPlayer, let collectionView = messageCollectionView else { return } // If the audio player is nil then we don't need to go through the stopping logic + player.stop() + state = .stopped + if let cell = playingCell { + cell.progressView.progress = 0.0 + cell.playButton.isSelected = false + guard let displayDelegate = collectionView.messagesDisplayDelegate else { + fatalError("MessagesDisplayDelegate has not been set.") + } + cell.durationLabel.text = displayDelegate.audioProgressTextFormat(Float(player.duration), for: cell, in: collectionView) + cell.delegate?.didStopAudio(in: cell) + } + progressTimer?.invalidate() + progressTimer = nil + audioPlayer = nil + playingMessage = nil + playingCell = nil + } + + /// Resume a currently pause audio sound + open func resumeSound() { + guard let player = audioPlayer, let cell = playingCell else { + stopAnyOngoingPlaying() + return + } + player.prepareToPlay() + player.play() + state = .playing + startProgressTimer() + cell.playButton.isSelected = true // show pause button on audio cell + cell.delegate?.didStartAudio(in: cell) + } + + // MARK: - Fire Methods + @objc private func didFireProgressTimer(_ timer: Timer) { + guard let player = audioPlayer, let collectionView = messageCollectionView, let cell = playingCell else { + return + } + // check if can update playing cell + if let playingCellIndexPath = collectionView.indexPath(for: cell) { + // 1. get the current message that decorates the playing cell + // 2. check if current message is the same with playing message, if so then update the cell content + // Note: Those messages differ in the case of cell reuse + let currentMessage = collectionView.messagesDataSource?.messageForItem(at: playingCellIndexPath, in: collectionView) + if currentMessage != nil && currentMessage?.messageId == playingMessage?.messageId { + // messages are the same update cell content + cell.progressView.progress = (player.duration == 0) ? 0 : Float(player.currentTime/player.duration) + guard let displayDelegate = collectionView.messagesDisplayDelegate else { + fatalError("MessagesDisplayDelegate has not been set.") + } + cell.durationLabel.text = displayDelegate.audioProgressTextFormat(Float(player.currentTime), for: cell, in: collectionView) + } else { + // if the current message is not the same with playing message stop playing sound + stopAnyOngoingPlaying() + } + } + } + + // MARK: - Private Methods + private func startProgressTimer() { + progressTimer?.invalidate() + progressTimer = nil + progressTimer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(BasicAudioController.didFireProgressTimer(_:)), userInfo: nil, repeats: true) + } + + // MARK: - AVAudioPlayerDelegate + open func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + stopAnyOngoingPlaying() + } + + open func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + stopAnyOngoingPlaying() + } + +} diff --git a/Example/Sources/Data Generation/Lorem.swift b/Example/Sources/Data Generation/Lorem.swift index b36e22b4..61b7df48 100755 --- a/Example/Sources/Data Generation/Lorem.swift +++ b/Example/Sources/Data Generation/Lorem.swift @@ -108,7 +108,7 @@ public class Lorem { return "" } - let result: String = self.words(nbWords: variable ? nbWords.randomize(variation: 40) : nbWords) + let result: String = words(nbWords: variable ? nbWords.randomize(variation: 40) : nbWords) return result.firstCapitalized + "." } diff --git a/Example/Sources/Data Generation/SampleData.swift b/Example/Sources/Data Generation/SampleData.swift index 75f25036..18c561d3 100644 --- a/Example/Sources/Data Generation/SampleData.swift +++ b/Example/Sources/Data Generation/SampleData.swift @@ -24,6 +24,7 @@ import MessageKit import CoreLocation +import AVFoundation final internal class SampleData { @@ -36,11 +37,12 @@ final internal class SampleData { case AttributedText = 1 case Photo = 2 case Video = 3 - case Emoji = 4 - case Location = 5 - case Url = 6 - case Phone = 7 - case Custom = 8 + case Audio = 4 + case Emoji = 5 + case Location = 6 + case Url = 7 + case Phone = 8 + case Custom = 9 static func random() -> MessageTypes { // Update as new enumerations are added @@ -65,7 +67,6 @@ final internal class SampleData { var now = Date() let messageImages: [UIImage] = [#imageLiteral(resourceName: "img1"), #imageLiteral(resourceName: "img2")] - let emojis = [ "👍", "😂😂😂", @@ -85,7 +86,11 @@ final internal class SampleData { CLLocation(latitude: 35.3218, longitude: -127.4314), CLLocation(latitude: 39.3218, longitude: -113.3317) ] - + + let sounds: [URL] = [Bundle.main.url(forResource: "sound1", withExtension: "m4a")!, + Bundle.main.url(forResource: "sound2", withExtension: "m4a")! + ] + func attributedString(with text: String) -> NSAttributedString { let nsString = NSString(string: text) var mutableAttributedString = NSMutableAttributedString(string: text) @@ -169,6 +174,10 @@ final internal class SampleData { let randomNumberImage = Int(arc4random_uniform(UInt32(messageImages.count))) let image = messageImages[randomNumberImage] return MockMessage(thumbnail: image, sender: sender, messageId: uniqueID, date: date) + case .Audio: + let randomNumberSound = Int(arc4random_uniform(UInt32(sounds.count))) + let soundURL = sounds[randomNumberSound] + return MockMessage(audioURL: soundURL, sender: sender, messageId: uniqueID, date: date) case .Emoji: let randomNumberEmoji = Int(arc4random_uniform(UInt32(emojis.count))) return MockMessage(emoji: emojis[randomNumberEmoji], sender: sender, messageId: uniqueID, date: date) diff --git a/Example/Sources/Info.plist b/Example/Sources/Info.plist index aee82b18..c2d68101 100644 --- a/Example/Sources/Info.plist +++ b/Example/Sources/Info.plist @@ -28,6 +28,8 @@ armv7 + UIStatusBarStyle + UIStatusBarStyleLightContent UIStatusBarTintParameters UINavigationBar diff --git a/Example/Sources/Models/MockMessage.swift b/Example/Sources/Models/MockMessage.swift index 24aaf5bf..d4e5722a 100644 --- a/Example/Sources/Models/MockMessage.swift +++ b/Example/Sources/Models/MockMessage.swift @@ -25,6 +25,7 @@ import Foundation import CoreLocation import MessageKit +import AVFoundation private struct CoordinateItem: LocationItem { @@ -53,6 +54,22 @@ private struct ImageMediaItem: MediaItem { } +private struct MockAudiotem: AudioItem { + + var url: URL + var size: CGSize + var duration: Float + + init(url: URL) { + self.url = url + self.size = CGSize(width: 160, height: 35) + // compute duration + let audioAsset = AVURLAsset(url: url) + self.duration = Float(CMTimeGetSeconds(audioAsset.duration)) + } + +} + internal struct MockMessage: MessageType { var messageId: String @@ -102,4 +119,9 @@ internal struct MockMessage: MessageType { self.init(kind: .emoji(emoji), user: user, messageId: messageId, date: date) } + init(audioURL: URL, sender: Sender, messageId: String, date: Date) { + let audioItem = MockAudiotem(url: audioURL) + self.init(kind: .audio(audioItem), sender: sender, messageId: messageId, date: date) + } + } diff --git a/Example/Sources/Sounds/sound1.m4a b/Example/Sources/Sounds/sound1.m4a new file mode 100644 index 00000000..56b31a69 Binary files /dev/null and b/Example/Sources/Sounds/sound1.m4a differ diff --git a/Example/Sources/Sounds/sound2.m4a b/Example/Sources/Sounds/sound2.m4a new file mode 100644 index 00000000..ed80ce19 Binary files /dev/null and b/Example/Sources/Sounds/sound2.m4a differ diff --git a/Example/Sources/View Controllers/AdvancedExampleViewController.swift b/Example/Sources/View Controllers/AdvancedExampleViewController.swift index c5037afe..6e902fc2 100644 --- a/Example/Sources/View Controllers/AdvancedExampleViewController.swift +++ b/Example/Sources/View Controllers/AdvancedExampleViewController.swift @@ -360,6 +360,16 @@ extension AdvancedExampleViewController: MessagesDisplayDelegate { return LocationMessageSnapshotOptions(showsBuildings: true, showsPointsOfInterest: true, span: MKCoordinateSpan(latitudeDelta: 10, longitudeDelta: 10)) } + // MARK: - Audio Messages + + func audioTintColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor { + return self.isFromCurrentSender(message: message) ? .white : UIColor(red: 15/255, green: 135/255, blue: 255/255, alpha: 1.0) + } + + func configureAudioCell(_ cell: AudioMessageCell, message: MessageType) { + audioController.configureAudioCell(cell, message: message) // this is needed especily when the cell is reconfigure while is playing sound + } + } // MARK: - MessagesLayoutDelegate diff --git a/Example/Sources/View Controllers/BasicExampleViewController.swift b/Example/Sources/View Controllers/BasicExampleViewController.swift index 0d7fc717..b418d97f 100644 --- a/Example/Sources/View Controllers/BasicExampleViewController.swift +++ b/Example/Sources/View Controllers/BasicExampleViewController.swift @@ -96,7 +96,17 @@ extension BasicExampleViewController: MessagesDisplayDelegate { return LocationMessageSnapshotOptions(showsBuildings: true, showsPointsOfInterest: true, span: MKCoordinateSpan(latitudeDelta: 10, longitudeDelta: 10)) } + + // MARK: - Audio Messages + + func audioTintColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor { + return isFromCurrentSender(message: message) ? .white : UIColor(red: 15/255, green: 135/255, blue: 255/255, alpha: 1.0) + } + func configureAudioCell(_ cell: AudioMessageCell, message: MessageType) { + audioController.configureAudioCell(cell, message: message) // this is needed especily when the cell is reconfigure while is playing sound + } + } // MARK: - MessagesLayoutDelegate @@ -107,6 +117,10 @@ extension BasicExampleViewController: MessagesLayoutDelegate { return 18 } + func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat { + return 17 + } + func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat { return 20 } diff --git a/Example/Sources/View Controllers/ChatViewController.swift b/Example/Sources/View Controllers/ChatViewController.swift index 1426ea11..36c78c8e 100644 --- a/Example/Sources/View Controllers/ChatViewController.swift +++ b/Example/Sources/View Controllers/ChatViewController.swift @@ -33,6 +33,9 @@ class ChatViewController: MessagesViewController, MessagesDataSource { return .lightContent } + /// The `BasicAudioController` controll the AVAudioPlayer state (play, pause, stop) and udpate audio cell UI accordingly. + open lazy var audioController = BasicAudioController(messageCollectionView: messagesCollectionView) + var messageList: [MockMessage] = [] let refreshControl = UIRefreshControl() @@ -64,6 +67,7 @@ class ChatViewController: MessagesViewController, MessagesDataSource { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) MockSocket.shared.disconnect() + audioController.stopAnyOngoingPlaying() } func loadFirstMessages() { @@ -157,6 +161,11 @@ class ChatViewController: MessagesViewController, MessagesDataSource { return nil } + func cellBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { + + return NSAttributedString(string: "Read", attributes: [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 10), NSAttributedString.Key.foregroundColor: UIColor.darkGray]) + } + func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { let name = message.sender.displayName return NSAttributedString(string: name, attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .caption1)]) @@ -186,6 +195,10 @@ extension ChatViewController: MessageCellDelegate { print("Top cell label tapped") } + func didTapCellBottomLabel(in cell: MessageCollectionViewCell) { + print("Bottom cell label tapped") + } + func didTapMessageTopLabel(in cell: MessageCollectionViewCell) { print("Top message label tapped") } @@ -193,11 +206,48 @@ extension ChatViewController: MessageCellDelegate { func didTapMessageBottomLabel(in cell: MessageCollectionViewCell) { print("Bottom label tapped") } - + + func didTapPlayButton(in cell: AudioMessageCell) { + guard let indexPath = messagesCollectionView.indexPath(for: cell), + let message = messagesCollectionView.messagesDataSource?.messageForItem(at: indexPath, in: messagesCollectionView) else { + print("Failed to identify message when audio cell receive tap gesture") + return + } + guard audioController.state != .stopped else { + // There is no audio sound playing - prepare to start playing for given audio message + audioController.playSound(for: message, in: cell) + return + } + if audioController.playingMessage?.messageId == message.messageId { + // tap occur in the current cell that is playing audio sound + if audioController.state == .playing { + audioController.pauseSound(for: message, in: cell) + } else { + audioController.resumeSound() + } + } else { + // tap occur in a difference cell that the one is currently playing sound. First stop currently playing and start the sound for given message + audioController.stopAnyOngoingPlaying() + audioController.playSound(for: message, in: cell) + } + } + + func didStartAudio(in cell: AudioMessageCell) { + print("Did start playing audio sound") + } + + func didPauseAudio(in cell: AudioMessageCell) { + print("Did pause audio sound") + } + + func didStopAudio(in cell: AudioMessageCell) { + print("Did stop audio sound") + } + func didTapAccessoryView(in cell: MessageCollectionViewCell) { print("Accessory view tapped") } - + } // MARK: - MessageLabelDelegate @@ -247,5 +297,5 @@ extension ChatViewController: MessageInputBarDelegate { inputBar.inputTextView.text = String() messagesCollectionView.scrollToBottom(animated: true) } - + } diff --git a/Example/Sources/View Controllers/MessageContainerController.swift b/Example/Sources/View Controllers/MessageContainerController.swift index ec1ea24b..bf170616 100644 --- a/Example/Sources/View Controllers/MessageContainerController.swift +++ b/Example/Sources/View Controllers/MessageContainerController.swift @@ -57,7 +57,7 @@ final class MessageContainerController: UIViewController { /// Add the `ConversationViewController` as a child view controller conversationViewController.willMove(toParent: self) - self.addChild(conversationViewController) + addChild(conversationViewController) view.addSubview(conversationViewController.view) conversationViewController.didMove(toParent: self) diff --git a/Example/Sources/View Controllers/SettingsViewController.swift b/Example/Sources/View Controllers/SettingsViewController.swift index 882e0f9e..6aa88ccb 100644 --- a/Example/Sources/View Controllers/SettingsViewController.swift +++ b/Example/Sources/View Controllers/SettingsViewController.swift @@ -34,7 +34,7 @@ final internal class SettingsViewController: UITableViewController { return .lightContent } - let cells = ["Mock messages count", "Text Messages", "AttributedText Messages", "Photo Messages", "Video Messages", "Emoji Messages", "Location Messages", "Url Messages", "Phone Messages"] + let cells = ["Mock messages count", "Text Messages", "AttributedText Messages", "Photo Messages", "Video Messages", "Audio Messages", "Emoji Messages", "Location Messages", "Url Messages", "Phone Messages"] // MARK: - Picker diff --git a/MessageKit.xcodeproj/project.pbxproj b/MessageKit.xcodeproj/project.pbxproj index 9ab3e246..760bc1f9 100644 --- a/MessageKit.xcodeproj/project.pbxproj +++ b/MessageKit.xcodeproj/project.pbxproj @@ -37,6 +37,10 @@ 382C794221705D2000F4FAF5 /* HorizontalEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C794121705D2000F4FAF5 /* HorizontalEdgeInsets.swift */; }; 383B9EB121728BAD008AB91A /* SenderType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383B9EB021728BAD008AB91A /* SenderType.swift */; }; 38C2AE7C20D4878D00F8079E /* MessageInputBar.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38C2AE7B20D4878D00F8079E /* MessageInputBar.framework */; }; + 5073C1152175BE750040EAD5 /* AudioItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C1142175BE750040EAD5 /* AudioItem.swift */; }; + 5073C1192175BE960040EAD5 /* AudioMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C1182175BE950040EAD5 /* AudioMessageCell.swift */; }; + 5073C11D2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */; }; + 5073C1232175C1980040EAD5 /* sound1.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5073C1222175C1980040EAD5 /* sound1.m4a */; }; 38F8062F2173CD8F00CDB9DB /* MockUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F8062D2173CD4300CDB9DB /* MockUser.swift */; }; 88916B2D1CF0DF2F00469F91 /* MessageKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88916B221CF0DF2F00469F91 /* MessageKit.framework */; }; 8962AC8A1F87AB7D0030B058 /* MessagesCollectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8962AC831F87AB230030B058 /* MessagesCollectionViewTests.swift */; }; @@ -134,6 +138,10 @@ 382C794121705D2000F4FAF5 /* HorizontalEdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalEdgeInsets.swift; sourceTree = ""; }; 383B9EB021728BAD008AB91A /* SenderType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SenderType.swift; sourceTree = ""; }; 38C2AE7B20D4878D00F8079E /* MessageInputBar.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageInputBar.framework; path = Carthage/Build/iOS/MessageInputBar.framework; sourceTree = ""; }; + 5073C1142175BE750040EAD5 /* AudioItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioItem.swift; sourceTree = ""; }; + 5073C1182175BE950040EAD5 /* AudioMessageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioMessageCell.swift; sourceTree = ""; }; + 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioMessageSizeCalculator.swift; sourceTree = ""; }; + 5073C1222175C1980040EAD5 /* sound1.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = sound1.m4a; sourceTree = ""; }; 38F8062D2173CD4300CDB9DB /* MockUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUser.swift; sourceTree = ""; }; 88916B221CF0DF2F00469F91 /* MessageKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MessageKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 88916B2C1CF0DF2F00469F91 /* MessageKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MessageKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -222,6 +230,7 @@ children = ( B7A03F381F866946006AEF79 /* LocationMessageCell.swift */, B7A03F391F866946006AEF79 /* MediaMessageCell.swift */, + 5073C1182175BE950040EAD5 /* AudioMessageCell.swift */, B7A03F7A1F866B85006AEF79 /* MessageCollectionViewCell.swift */, 1F6C040B206A2891007BDE44 /* MessageContentCell.swift */, B7A03F361F866946006AEF79 /* TextMessageCell.swift */, @@ -237,6 +246,14 @@ path = "Headers & Footers"; sourceTree = ""; }; + 5073C1212175C1580040EAD5 /* Resources */ = { + isa = PBXGroup; + children = ( + 5073C1222175C1980040EAD5 /* sound1.m4a */, + ); + path = Resources; + sourceTree = ""; + }; 88916B181CF0DF2F00469F91 = { isa = PBXGroup; children = ( @@ -284,6 +301,7 @@ 8962AC801F87AB230030B058 /* ProtocolsTests */, 8962AC7E1F87AB230030B058 /* Supporting Files */, 8962AC821F87AB230030B058 /* ViewsTests */, + 5073C1212175C1580040EAD5 /* Resources */, ); path = Tests; sourceTree = SOURCE_ROOT; @@ -411,6 +429,7 @@ 383B9EB021728BAD008AB91A /* SenderType.swift */, 1FD5895F2064E08A004B5081 /* MediaItem.swift */, 1FD5896320660C1C004B5081 /* LocationItem.swift */, + 5073C1142175BE750040EAD5 /* AudioItem.swift */, ); path = Protocols; sourceTree = ""; @@ -434,6 +453,7 @@ 1FE783A120662905007FA024 /* TextMessageSizeCalculator.swift */, 1FE783A3206629A5007FA024 /* MediaMessageSizeCalculator.swift */, 1FE783A5206629C2007FA024 /* LocationMessageSizeCalculator.swift */, + 5073C11C2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift */, 0EF0888B206F7E83007F2F58 /* CellSizeCalculator.swift */, ); path = Layout; @@ -551,6 +571,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5073C1232175C1980040EAD5 /* sound1.m4a in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -584,6 +605,7 @@ B7A03F601F8669CA006AEF79 /* MessagesDisplayDelegate.swift in Sources */, 1FE783A8206633C0007FA024 /* InsetLabel.swift in Sources */, B7A03F5C1F8669CA006AEF79 /* MessageCellDelegate.swift in Sources */, + 5073C11D2175BEC60040EAD5 /* AudioMessageSizeCalculator.swift in Sources */, 1FF377A420087C82004FD648 /* MessageKitError.swift in Sources */, 1F6C040E206A2AF4007BDE44 /* MessageReusableView.swift in Sources */, B7A03F4B1F86694F006AEF79 /* MessageContainerView.swift in Sources */, @@ -602,6 +624,7 @@ B7A03F5E1F8669CA006AEF79 /* MessagesLayoutDelegate.swift in Sources */, B7A03F261F866895006AEF79 /* MessageKitDateFormatter.swift in Sources */, B7A03F6D1F8669EB006AEF79 /* Bundle+Extensions.swift in Sources */, + 5073C1192175BE960040EAD5 /* AudioMessageCell.swift in Sources */, 1FD589602064E08A004B5081 /* MediaItem.swift in Sources */, B7A03F611F8669CA006AEF79 /* MessagesDataSource.swift in Sources */, B7A03F6E1F8669EB006AEF79 /* NSAttributedString+Extensions.swift in Sources */, @@ -617,6 +640,7 @@ B7A03F2A1F866895006AEF79 /* MessageStyle.swift in Sources */, B7A03F4D1F86694F006AEF79 /* MessagesCollectionView.swift in Sources */, 0EF0888C206F7E83007F2F58 /* CellSizeCalculator.swift in Sources */, + 5073C1152175BE750040EAD5 /* AudioItem.swift in Sources */, 1FE783A4206629A5007FA024 /* MediaMessageSizeCalculator.swift in Sources */, B7A03F731F866A06006AEF79 /* MessageKit+Availability.swift in Sources */, 1FE7839E20662835007FA024 /* MessageSizeCalculator.swift in Sources */, diff --git a/Sources/Controllers/MessagesViewController.swift b/Sources/Controllers/MessagesViewController.swift index ff43ffeb..3d36c606 100644 --- a/Sources/Controllers/MessagesViewController.swift +++ b/Sources/Controllers/MessagesViewController.swift @@ -208,6 +208,10 @@ UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { let cell = messagesCollectionView.dequeueReusableCell(LocationMessageCell.self, for: indexPath) cell.configure(with: message, at: indexPath, and: messagesCollectionView) return cell + case .audio: + let cell = messagesCollectionView.dequeueReusableCell(AudioMessageCell.self, for: indexPath) + cell.configure(with: message, at: indexPath, and: messagesCollectionView) + return cell case .custom: return messagesDataSource.customCell(for: message, at: indexPath, in: messagesCollectionView) } diff --git a/Sources/Extensions/UIView+Extensions.swift b/Sources/Extensions/UIView+Extensions.swift index b38d39a5..81df7c35 100644 --- a/Sources/Extensions/UIView+Extensions.swift +++ b/Sources/Extensions/UIView+Extensions.swift @@ -65,7 +65,7 @@ extension UIView { } @discardableResult - internal func addConstraints(_ top: NSLayoutYAxisAnchor? = nil, left: NSLayoutXAxisAnchor? = nil, bottom: NSLayoutYAxisAnchor? = nil, right: NSLayoutXAxisAnchor? = nil, topConstant: CGFloat = 0, leftConstant: CGFloat = 0, bottomConstant: CGFloat = 0, rightConstant: CGFloat = 0, widthConstant: CGFloat = 0, heightConstant: CGFloat = 0) -> [NSLayoutConstraint] { + internal func addConstraints(_ top: NSLayoutYAxisAnchor? = nil, left: NSLayoutXAxisAnchor? = nil, bottom: NSLayoutYAxisAnchor? = nil, right: NSLayoutXAxisAnchor? = nil, centerY: NSLayoutYAxisAnchor? = nil, centerX: NSLayoutXAxisAnchor? = nil, topConstant: CGFloat = 0, leftConstant: CGFloat = 0, bottomConstant: CGFloat = 0, rightConstant: CGFloat = 0, centerYConstant: CGFloat = 0, centerXConstant: CGFloat = 0, widthConstant: CGFloat = 0, heightConstant: CGFloat = 0) -> [NSLayoutConstraint] { if self.superview == nil { return [] @@ -97,6 +97,18 @@ extension UIView { constraint.identifier = "right" constraints.append(constraint) } + + if let centerY = centerY { + let constraint = centerYAnchor.constraint(equalTo: centerY, constant: centerYConstant) + constraint.identifier = "centerY" + constraints.append(constraint) + } + + if let centerX = centerX { + let constraint = centerXAnchor.constraint(equalTo: centerX, constant: centerXConstant) + constraint.identifier = "centerX" + constraints.append(constraint) + } if widthConstant > 0 { let constraint = widthAnchor.constraint(equalToConstant: widthConstant) diff --git a/Sources/Layout/AudioMessageSizeCalculator.swift b/Sources/Layout/AudioMessageSizeCalculator.swift new file mode 100644 index 00000000..7fe646ed --- /dev/null +++ b/Sources/Layout/AudioMessageSizeCalculator.swift @@ -0,0 +1,43 @@ +/* + 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 + +open class AudioMessageSizeCalculator: MessageSizeCalculator { + + open override func messageContainerSize(for message: MessageType) -> CGSize { + switch message.kind { + case .audio(let item): + let maxWidth = messageContainerMaxWidth(for: message) + if maxWidth < item.size.width { + // Maintain the ratio if width is too great + let height = maxWidth * item.size.height / item.size.width + return CGSize(width: maxWidth, height: height) + } + return item.size + default: + fatalError("messageContainerSize received unhandled MessageDataType: \(message.kind)") + } + } +} diff --git a/Sources/Layout/MessageSizeCalculator.swift b/Sources/Layout/MessageSizeCalculator.swift index f83f826e..a49c932d 100644 --- a/Sources/Layout/MessageSizeCalculator.swift +++ b/Sources/Layout/MessageSizeCalculator.swift @@ -38,11 +38,16 @@ open class MessageSizeCalculator: CellSizeCalculator { public var incomingAvatarPosition = AvatarPosition(vertical: .cellBottom) public var outgoingAvatarPosition = AvatarPosition(vertical: .cellBottom) + public var avatarLeadingTrailingPadding: CGFloat = 0 + public var incomingMessagePadding = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 30) public var outgoingMessagePadding = UIEdgeInsets(top: 0, left: 30, bottom: 0, right: 4) public var incomingCellTopLabelAlignment = LabelAlignment(textAlignment: .center, textInsets: .zero) public var outgoingCellTopLabelAlignment = LabelAlignment(textAlignment: .center, textInsets: .zero) + + public var incomingCellBottomLabelAlignment = LabelAlignment(textAlignment: .left, textInsets: UIEdgeInsets(left: 42)) + public var outgoingCellBottomLabelAlignment = LabelAlignment(textAlignment: .right, textInsets: UIEdgeInsets(right: 42)) public var incomingMessageTopLabelAlignment = LabelAlignment(textAlignment: .left, textInsets: UIEdgeInsets(left: 42)) public var outgoingMessageTopLabelAlignment = LabelAlignment(textAlignment: .right, textInsets: UIEdgeInsets(right: 42)) @@ -65,10 +70,13 @@ open class MessageSizeCalculator: CellSizeCalculator { attributes.avatarSize = avatarSize(for: message) attributes.avatarPosition = avatarPosition(for: message) + attributes.avatarLeadingTrailingPadding = avatarLeadingTrailingPadding attributes.messageContainerPadding = messageContainerPadding(for: message) attributes.messageContainerSize = messageContainerSize(for: message) attributes.cellTopLabelSize = cellTopLabelSize(for: message, at: indexPath) + attributes.cellBottomLabelSize = cellBottomLabelSize(for: message, at: indexPath) + attributes.cellBottomLabelAlignment = cellBottomLabelAlignment(for: message) attributes.messageTopLabelSize = messageTopLabelSize(for: message, at: indexPath) attributes.messageTopLabelAlignment = messageTopLabelAlignment(for: message) @@ -89,6 +97,7 @@ open class MessageSizeCalculator: CellSizeCalculator { open func cellContentHeight(for message: MessageType, at indexPath: IndexPath) -> CGFloat { let messageContainerHeight = messageContainerSize(for: message).height + let cellBottomLabelHeight = cellBottomLabelSize(for: message, at: indexPath).height let messageBottomLabelHeight = messageBottomLabelSize(for: message, at: indexPath).height let cellTopLabelHeight = cellTopLabelSize(for: message, at: indexPath).height let messageTopLabelHeight = messageTopLabelSize(for: message, at: indexPath).height @@ -100,12 +109,13 @@ open class MessageSizeCalculator: CellSizeCalculator { switch avatarVerticalPosition { case .messageCenter: let totalLabelHeight: CGFloat = cellTopLabelHeight + messageTopLabelHeight - + messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + + messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + cellBottomLabelHeight let cellHeight = max(avatarHeight, totalLabelHeight) return max(cellHeight, accessoryViewHeight) case .messageBottom: var cellHeight: CGFloat = 0 cellHeight += messageBottomLabelHeight + cellHeight += cellBottomLabelHeight let labelsHeight = messageContainerHeight + messageVerticalPadding + cellTopLabelHeight + messageTopLabelHeight cellHeight += max(labelsHeight, avatarHeight) return max(cellHeight, accessoryViewHeight) @@ -113,18 +123,18 @@ open class MessageSizeCalculator: CellSizeCalculator { var cellHeight: CGFloat = 0 cellHeight += cellTopLabelHeight cellHeight += messageTopLabelHeight - let labelsHeight = messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + let labelsHeight = messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + cellBottomLabelHeight cellHeight += max(labelsHeight, avatarHeight) return max(cellHeight, accessoryViewHeight) case .messageLabelTop: var cellHeight: CGFloat = 0 cellHeight += cellTopLabelHeight - let messageLabelsHeight = messageContainerHeight + messageBottomLabelHeight + messageVerticalPadding + messageTopLabelHeight + let messageLabelsHeight = messageContainerHeight + messageBottomLabelHeight + messageVerticalPadding + messageTopLabelHeight + cellBottomLabelHeight cellHeight += max(messageLabelsHeight, avatarHeight) return max(cellHeight, accessoryViewHeight) case .cellTop, .cellBottom: let totalLabelHeight: CGFloat = cellTopLabelHeight + messageTopLabelHeight - + messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + + messageContainerHeight + messageVerticalPadding + messageBottomLabelHeight + cellBottomLabelHeight let cellHeight = max(avatarHeight, totalLabelHeight) return max(cellHeight, accessoryViewHeight) } @@ -181,8 +191,23 @@ open class MessageSizeCalculator: CellSizeCalculator { let isFromCurrentSender = dataSource.isFromCurrentSender(message: message) return isFromCurrentSender ? outgoingMessageTopLabelAlignment : incomingMessageTopLabelAlignment } + + // MARK: - Bottom cell Label + + open func cellBottomLabelSize(for message: MessageType, at indexPath: IndexPath) -> CGSize { + let layoutDelegate = messagesLayout.messagesLayoutDelegate + let collectionView = messagesLayout.messagesCollectionView + let height = layoutDelegate.cellBottomLabelHeight(for: message, at: indexPath, in: collectionView) + return CGSize(width: messagesLayout.itemWidth, height: height) + } + + open func cellBottomLabelAlignment(for message: MessageType) -> LabelAlignment { + let dataSource = messagesLayout.messagesDataSource + let isFromCurrentSender = dataSource.isFromCurrentSender(message: message) + return isFromCurrentSender ? outgoingCellBottomLabelAlignment : incomingCellBottomLabelAlignment + } - // MARK: - Bottom Label + // MARK: - Bottom Message Label open func messageBottomLabelSize(for message: MessageType, at indexPath: IndexPath) -> CGSize { let layoutDelegate = messagesLayout.messagesLayoutDelegate @@ -229,7 +254,7 @@ open class MessageSizeCalculator: CellSizeCalculator { let messagePadding = messageContainerPadding(for: message) let accessoryWidth = accessoryViewSize(for: message).width let accessoryPadding = accessoryViewPadding(for: message) - return messagesLayout.itemWidth - avatarWidth - messagePadding.horizontal - accessoryWidth - accessoryPadding.horizontal + return messagesLayout.itemWidth - avatarWidth - messagePadding.horizontal - accessoryWidth - accessoryPadding.horizontal - avatarLeadingTrailingPadding } // MARK: - Helpers diff --git a/Sources/Layout/MessagesCollectionViewFlowLayout.swift b/Sources/Layout/MessagesCollectionViewFlowLayout.swift index cd5fe5af..30a03542 100644 --- a/Sources/Layout/MessagesCollectionViewFlowLayout.swift +++ b/Sources/Layout/MessagesCollectionViewFlowLayout.swift @@ -146,6 +146,7 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { lazy open var photoMessageSizeCalculator = MediaMessageSizeCalculator(layout: self) lazy open var videoMessageSizeCalculator = MediaMessageSizeCalculator(layout: self) lazy open var locationMessageSizeCalculator = LocationMessageSizeCalculator(layout: self) + lazy open var audioMessageSizeCalculator = AudioMessageSizeCalculator(layout: self) /// - Note: /// If you override this method, remember to call MessageLayoutDelegate's customCellSizeCalculator(for:at:in:) method for MessageKind.custom messages, if necessary @@ -164,6 +165,8 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { return videoMessageSizeCalculator case .location: return locationMessageSizeCalculator + case .audio: + return audioMessageSizeCalculator case .custom: return messagesLayoutDelegate.customCellSizeCalculator(for: message, at: indexPath, in: messagesCollectionView) } @@ -193,6 +196,11 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { public func setMessageOutgoingAvatarPosition(_ newPosition: AvatarPosition) { messageSizeCalculators().forEach { $0.outgoingAvatarPosition = newPosition } } + + /// Set `avatarLeadingTrailingPadding` of all `MessageSizeCalculator`s + public func setAvatarLeadingTrailingPadding(_ newPadding: CGFloat) { + messageSizeCalculators().forEach { $0.avatarLeadingTrailingPadding = newPadding } + } /// Set `incomingMessagePadding` of all `MessageSizeCalculator`s public func setMessageIncomingMessagePadding(_ newPadding: UIEdgeInsets) { @@ -214,6 +222,16 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { messageSizeCalculators().forEach { $0.outgoingCellTopLabelAlignment = newAlignment } } + /// Set `incomingCellBottomLabelAlignment` of all `MessageSizeCalculator`s + public func setMessageIncomingCellBottomLabelAlignment(_ newAlignment: LabelAlignment) { + messageSizeCalculators().forEach { $0.incomingCellBottomLabelAlignment = newAlignment } + } + + /// Set `outgoingCellBottomLabelAlignment` of all `MessageSizeCalculator`s + public func setMessageOutgoingCellBottomLabelAlignment(_ newAlignment: LabelAlignment) { + messageSizeCalculators().forEach { $0.outgoingCellBottomLabelAlignment = newAlignment } + } + /// Set `incomingMessageTopLabelAlignment` of all `MessageSizeCalculator`s public func setMessageIncomingMessageTopLabelAlignment(_ newAlignment: LabelAlignment) { messageSizeCalculators().forEach { $0.incomingMessageTopLabelAlignment = newAlignment } @@ -256,7 +274,14 @@ open class MessagesCollectionViewFlowLayout: UICollectionViewFlowLayout { /// Get all `MessageSizeCalculator`s open func messageSizeCalculators() -> [MessageSizeCalculator] { - return [textMessageSizeCalculator, attributedTextMessageSizeCalculator, emojiMessageSizeCalculator, photoMessageSizeCalculator, videoMessageSizeCalculator, locationMessageSizeCalculator] + return [textMessageSizeCalculator, + attributedTextMessageSizeCalculator, + emojiMessageSizeCalculator, + photoMessageSizeCalculator, + videoMessageSizeCalculator, + locationMessageSizeCalculator, + audioMessageSizeCalculator + ] } } diff --git a/Sources/Layout/MessagesCollectionViewLayoutAttributes.swift b/Sources/Layout/MessagesCollectionViewLayoutAttributes.swift index e055ac57..14219630 100644 --- a/Sources/Layout/MessagesCollectionViewLayoutAttributes.swift +++ b/Sources/Layout/MessagesCollectionViewLayoutAttributes.swift @@ -31,6 +31,7 @@ open class MessagesCollectionViewLayoutAttributes: UICollectionViewLayoutAttribu public var avatarSize: CGSize = .zero public var avatarPosition = AvatarPosition(vertical: .cellBottom) + public var avatarLeadingTrailingPadding: CGFloat = 0 public var messageContainerSize: CGSize = .zero public var messageContainerPadding: UIEdgeInsets = .zero @@ -40,6 +41,9 @@ open class MessagesCollectionViewLayoutAttributes: UICollectionViewLayoutAttribu public var cellTopLabelAlignment = LabelAlignment(textAlignment: .center, textInsets: .zero) public var cellTopLabelSize: CGSize = .zero + public var cellBottomLabelAlignment = LabelAlignment(textAlignment: .center, textInsets: .zero) + public var cellBottomLabelSize: CGSize = .zero + public var messageTopLabelAlignment = LabelAlignment(textAlignment: .center, textInsets: .zero) public var messageTopLabelSize: CGSize = .zero @@ -56,12 +60,15 @@ open class MessagesCollectionViewLayoutAttributes: UICollectionViewLayoutAttribu let copy = super.copy(with: zone) as! MessagesCollectionViewLayoutAttributes copy.avatarSize = avatarSize copy.avatarPosition = avatarPosition + copy.avatarLeadingTrailingPadding = avatarLeadingTrailingPadding copy.messageContainerSize = messageContainerSize copy.messageContainerPadding = messageContainerPadding copy.messageLabelFont = messageLabelFont copy.messageLabelInsets = messageLabelInsets copy.cellTopLabelAlignment = cellTopLabelAlignment copy.cellTopLabelSize = cellTopLabelSize + copy.cellBottomLabelAlignment = cellBottomLabelAlignment + copy.cellBottomLabelSize = cellBottomLabelSize copy.messageTopLabelAlignment = messageTopLabelAlignment copy.messageTopLabelSize = messageTopLabelSize copy.messageBottomLabelAlignment = messageBottomLabelAlignment @@ -76,13 +83,16 @@ open class MessagesCollectionViewLayoutAttributes: UICollectionViewLayoutAttribu // MARK: - LEAVE this as is if let attributes = object as? MessagesCollectionViewLayoutAttributes { return super.isEqual(object) && attributes.avatarSize == avatarSize - && attributes.avatarPosition == attributes.avatarPosition + && attributes.avatarPosition == avatarPosition + && attributes.avatarLeadingTrailingPadding == avatarLeadingTrailingPadding && attributes.messageContainerSize == messageContainerSize && attributes.messageContainerPadding == messageContainerPadding && attributes.messageLabelFont == messageLabelFont && attributes.messageLabelInsets == messageLabelInsets && attributes.cellTopLabelAlignment == cellTopLabelAlignment && attributes.cellTopLabelSize == cellTopLabelSize + && attributes.cellBottomLabelAlignment == cellBottomLabelAlignment + && attributes.cellBottomLabelSize == cellBottomLabelSize && attributes.messageTopLabelAlignment == messageTopLabelAlignment && attributes.messageTopLabelSize == messageTopLabelSize && attributes.messageBottomLabelAlignment == messageBottomLabelAlignment diff --git a/Sources/Models/MessageKind.swift b/Sources/Models/MessageKind.swift index a29d76d5..0d351dce 100644 --- a/Sources/Models/MessageKind.swift +++ b/Sources/Models/MessageKind.swift @@ -51,6 +51,9 @@ public enum MessageKind { /// An emoji message. case emoji(String) + /// An audio message. + case audio(AudioItem) + /// A custom message. /// - Note: Using this case requires that you implement the following methods and handle this case: /// - MessagesDataSource: customCell(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UICollectionViewCell @@ -59,8 +62,6 @@ public enum MessageKind { // MARK: - Not supported yet -// case audio(Data) -// // case system(String) // // case placeholder diff --git a/Sources/Protocols/AudioItem.swift b/Sources/Protocols/AudioItem.swift new file mode 100644 index 00000000..54868adc --- /dev/null +++ b/Sources/Protocols/AudioItem.swift @@ -0,0 +1,39 @@ +/* + 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 class AVFoundation.AVAudioPlayer + +/// A protocol used to represent the data for an audio message. +public protocol AudioItem { + + /// The url where the audio file is located. + var url: URL { get } + + /// The audio file duration in seconds. + var duration: Float { get } + + /// The size of the audio item. + var size: CGSize { get } + +} diff --git a/Sources/Protocols/MessageCellDelegate.swift b/Sources/Protocols/MessageCellDelegate.swift index 4fbe9da9..d9f9a3ff 100644 --- a/Sources/Protocols/MessageCellDelegate.swift +++ b/Sources/Protocols/MessageCellDelegate.swift @@ -27,6 +27,17 @@ import Foundation /// A protocol used by `MessageContentCell` subclasses to detect taps in the cell's subviews. public protocol MessageCellDelegate: MessageLabelDelegate { + /// Triggered when a tap occurs in the background of the cell. + /// + /// - Parameters: + /// - cell: The cell where the tap occurred. + /// + /// - Note: + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didTapBackground(in cell: MessageCollectionViewCell) + /// Triggered when a tap occurs in the `MessageContainerView`. /// /// - Parameters: @@ -58,6 +69,16 @@ public protocol MessageCellDelegate: MessageLabelDelegate { /// method `messageForItem(at:indexPath:messagesCollectionView)`. func didTapCellTopLabel(in cell: MessageCollectionViewCell) + /// Triggered when a tap occurs in the cellBottomLabel. + /// + /// - Parameters: + /// - cell: The cell tap the touch occurred. + /// + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didTapCellBottomLabel(in cell: MessageCollectionViewCell) + /// Triggered when a tap occurs in the messageTopLabel. /// /// - Parameters: @@ -88,19 +109,72 @@ public protocol MessageCellDelegate: MessageLabelDelegate { /// method `messageForItem(at:indexPath:messagesCollectionView)`. func didTapAccessoryView(in cell: MessageCollectionViewCell) + /// Triggered when a tap occurs on the play button from audio cell. + /// + /// - Parameters: + /// - cell: The audio cell where the touch occurred. + /// + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didTapPlayButton(in cell: AudioMessageCell) + + /// Triggered when audio player start playing audio. + /// + /// - Parameters: + /// - cell: The cell where the audio sound is playing. + /// + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didStartAudio(in cell: AudioMessageCell) + + /// Triggered when audio player pause audio. + /// + /// - Parameters: + /// - cell: The cell where the audio sound is paused. + /// + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didPauseAudio(in cell: AudioMessageCell) + + /// Triggered when audio player stoped audio. + /// + /// - Parameters: + /// - cell: The cell where the audio sound is stoped. + /// + /// You can get a reference to the `MessageType` for the cell by using `UICollectionView`'s + /// `indexPath(for: cell)` method. Then using the returned `IndexPath` with the `MessagesDataSource` + /// method `messageForItem(at:indexPath:messagesCollectionView)`. + func didStopAudio(in cell: AudioMessageCell) + } public extension MessageCellDelegate { + func didTapBackground(in cell: MessageCollectionViewCell) {} + func didTapMessage(in cell: MessageCollectionViewCell) {} func didTapAvatar(in cell: MessageCollectionViewCell) {} func didTapCellTopLabel(in cell: MessageCollectionViewCell) {} + + func didTapCellBottomLabel(in cell: MessageCollectionViewCell) {} func didTapMessageTopLabel(in cell: MessageCollectionViewCell) {} + func didTapPlayButton(in cell: AudioMessageCell) {} + + func didStartAudio(in cell: AudioMessageCell) {} + + func didPauseAudio(in cell: AudioMessageCell) {} + + func didStopAudio(in cell: AudioMessageCell) {} + func didTapMessageBottomLabel(in cell: MessageCollectionViewCell) {} func didTapAccessoryView(in cell: MessageCollectionViewCell) {} + } diff --git a/Sources/Protocols/MessagesDataSource.swift b/Sources/Protocols/MessagesDataSource.swift index 5b57d663..1d02e330 100644 --- a/Sources/Protocols/MessagesDataSource.swift +++ b/Sources/Protocols/MessagesDataSource.swift @@ -73,6 +73,16 @@ public protocol MessagesDataSource: AnyObject { /// The default value returned by this method is `nil`. func cellTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? + /// The attributed text to be used for cell's bottom label. + /// + /// - 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 `nil`. + func cellBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? + /// The attributed text to be used for message bubble's top label. /// /// - Parameters: @@ -119,6 +129,10 @@ public extension MessagesDataSource { return nil } + func cellBottomLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { + return nil + } + func messageTopLabelAttributedText(for message: MessageType, at indexPath: IndexPath) -> NSAttributedString? { return nil } diff --git a/Sources/Protocols/MessagesDisplayDelegate.swift b/Sources/Protocols/MessagesDisplayDelegate.swift index 74698759..81c2e701 100644 --- a/Sources/Protocols/MessagesDisplayDelegate.swift +++ b/Sources/Protocols/MessagesDisplayDelegate.swift @@ -173,6 +173,46 @@ public protocol MessagesDisplayDelegate: AnyObject { /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. func configureMediaMessageImageView(_ imageView: UIImageView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) + // MARK: - Audio Message + + /// Used to configure the audio cell UI: + /// 1. play button selected state; + /// 2. progresssView progress; + /// 3. durationLabel text; + /// + /// - Parameters: + /// - cell: The `AudioMessageCell` that needs to be configure. + /// - message: The `MessageType` that configures the cell. + /// + /// - Note: + /// This protocol method is called by MessageKit every time an audio cell needs to be configure + func configureAudioCell(_ cell: AudioMessageCell, message: MessageType) + + /// Specifies the tint color of play button and progress bar for an `AudioMessageCell`. + /// + /// - Parameters: + /// - message: A `MessageType` with a `MessageKind` case of `.audio` to which the color will apply. + /// - indexPath: The `IndexPath` of the cell. + /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. + /// + /// - Note: + /// The default value returned by this method is UIColor.sendButtonBlue + func audioTintColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor + + /// Used to format the audio sound duration in a readable string + /// + /// - Parameters: + /// - duration: The audio sound duration is seconds. + /// - audioCell: The `AudioMessageCell` that ask for formated duration. + /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. + /// + /// - Note: + /// The default value is computed like fallow: + /// 1. return the time as 0:ss if duration is up to 59 seconds (e.g. 0:03 means 0 minutes and 3 seconds) + /// 2. return the time as m:ss if duration is greater than 59 and lower than 3600 (e.g. 12:23 means 12 mintues and 23 seconds) + /// 3. return the time as h:mm:ss for anything longer that 3600 seconds (e.g. 1:19:08 means 1 hour 19 minutes and 8 seconds) + func audioProgressTextFormat(_ duration: Float, for audioCell: AudioMessageCell, in messageCollectionView: MessagesCollectionView) -> String + } public extension MessagesDisplayDelegate { @@ -243,4 +283,32 @@ public extension MessagesDisplayDelegate { func configureMediaMessageImageView(_ imageView: UIImageView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) { } + + // MARK: - Audio Message Defaults + + func configureAudioCell(_ cell: AudioMessageCell, message: MessageType) { + + } + + func audioTintColor(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> UIColor { + return UIColor.sendButtonBlue + } + + func audioProgressTextFormat(_ duration: Float, for audioCell: AudioMessageCell, in messageCollectionView: MessagesCollectionView) -> String { + var retunValue = "0:00" + // print the time as 0:ss if duration is up to 59 seconds + // print the time as m:ss if duration is up to 59:59 seconds + // print the time as h:mm:ss for anything longer + if duration < 60 { + retunValue = String(format: "0:%.02d", Int(duration.rounded(.up))) + } else if duration < 3600 { + retunValue = String(format: "%.02d:%.02d", Int(duration/60), Int(duration) % 60) + } else { + let hours = Int(duration/3600) + let remainingMinutsInSeconds = Int(duration) - hours*3600 + retunValue = String(format: "%.02d:%.02d:%.02d", hours, Int(remainingMinutsInSeconds/60), Int(remainingMinutsInSeconds) % 60) + } + return retunValue + } + } diff --git a/Sources/Protocols/MessagesLayoutDelegate.swift b/Sources/Protocols/MessagesLayoutDelegate.swift index 4dd75743..55aaaf49 100644 --- a/Sources/Protocols/MessagesLayoutDelegate.swift +++ b/Sources/Protocols/MessagesLayoutDelegate.swift @@ -59,6 +59,17 @@ public protocol MessagesLayoutDelegate: AnyObject { /// The default value returned by this method is zero. func cellTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat + /// Specifies the height for the `MessageContentCell`'s bottom label. + /// + /// - Parameters: + /// - message: The `MessageType` that will be displayed for this cell. + /// - indexPath: The `IndexPath` of the cell. + /// - messagesCollectionView: The `MessagesCollectionView` in which this cell will be displayed. + /// + /// - Note: + /// The default value returned by this method is zero. + func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat + /// Specifies the height for the message bubble's top label. /// /// - Parameters: @@ -107,6 +118,10 @@ public extension MessagesLayoutDelegate { return 0 } + func cellBottomLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat { + return 0 + } + func messageTopLabelHeight(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGFloat { return 0 } diff --git a/Sources/Views/Cells/AudioMessageCell.swift b/Sources/Views/Cells/AudioMessageCell.swift new file mode 100644 index 00000000..3050ebfc --- /dev/null +++ b/Sources/Views/Cells/AudioMessageCell.swift @@ -0,0 +1,149 @@ +/* + 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 +import AVFoundation + +/// A subclass of `MessageContentCell` used to display video and audio messages. +open class AudioMessageCell: MessageContentCell { + + /// The `ImageName` enum holds the names od default iamges used to decorate play button + public enum ImageName: String { + case play + case pause + } + + /// The play button view to display on audio messages. + open lazy var playButton: UIButton = { + let playButton = UIButton(type: .custom) + let playImage = AudioMessageCell.getImageWithName(.play) + let pauseImage = AudioMessageCell.getImageWithName(.pause) + playButton.setImage(playImage?.withRenderingMode(.alwaysTemplate), for: .normal) + playButton.setImage(pauseImage?.withRenderingMode(.alwaysTemplate), for: .selected) + return playButton + }() + + /// The time duration lable to display on audio messages. + open lazy var durationLabel: UILabel = { + let durationLabel = UILabel(frame: CGRect.zero) + durationLabel.textAlignment = .right + durationLabel.font = UIFont.systemFont(ofSize: 14) + durationLabel.text = "0:00" + return durationLabel + }() + + open lazy var progressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .default) + progressView.progress = 0.0 + return progressView + }() + + // MARK: - Methods + + /// Responsible for setting up the constraints of the cell's subviews. + open func setupConstraints() { + playButton.constraint(equalTo: CGSize(width: 25, height: 25)) + playButton.addConstraints(left: messageContainerView.leftAnchor, centerY: messageContainerView.centerYAnchor, leftConstant: 5) + durationLabel.addConstraints(right: messageContainerView.rightAnchor, centerY: messageContainerView.centerYAnchor, rightConstant: 15) + progressView.addConstraints(left: playButton.rightAnchor, right: durationLabel.leftAnchor, centerY: messageContainerView.centerYAnchor, leftConstant: 5, rightConstant: 5) + } + + open override func setupSubviews() { + super.setupSubviews() + messageContainerView.addSubview(playButton) + messageContainerView.addSubview(durationLabel) + messageContainerView.addSubview(progressView) + setupConstraints() + } + + open override func prepareForReuse() { + super.prepareForReuse() + progressView.progress = 0 + playButton.isSelected = false + durationLabel.text = "0:00" + } + + open class func getImageWithName(_ imageName: ImageName) -> UIImage? { + let assetBundle = Bundle.messageKitAssetBundle() + let imagePath = assetBundle.path(forResource: imageName.rawValue, ofType: "png", inDirectory: "Images") + let image = UIImage(contentsOfFile: imagePath ?? "") + return image + } + + /// Handle tap gesture on contentView and its subviews. + open override func handleTapGesture(_ gesture: UIGestureRecognizer) { + let touchLocation = gesture.location(in: self) + // compute play button touch area, currently play button size is (25, 25) which is hardly touchable + // add 10 px around current button frame and test the touch against this new frame + let playButtonTouchArea = CGRect(playButton.frame.origin.x - 10.0, playButton.frame.origin.y - 10, playButton.frame.size.width + 20, playButton.frame.size.height + 20) + let translateTouchLocation = convert(touchLocation, to: messageContainerView) + if playButtonTouchArea.contains(translateTouchLocation) { + delegate?.didTapPlayButton(in: self) + } else { + super.handleTapGesture(gesture) + } + } + + // MARK: - Configure Cell + + open override func configure(with message: MessageType, at indexPath: IndexPath, and messagesCollectionView: MessagesCollectionView) { + super.configure(with: message, at: indexPath, and: messagesCollectionView) + configureCellApperance(with: message, indexPath: indexPath, messagesCollectionView: messagesCollectionView) + + guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { + fatalError(MessageKitError.nilMessagesDisplayDelegate) + } + // default implementation for decorate cell + guard case let .audio(audioItem) = message.kind else { fatalError("Failed decorate audio cell") } + durationLabel.text = displayDelegate.audioProgressTextFormat(audioItem.duration, for: self, in: messagesCollectionView) + progressView.progress = 0.0 + playButton.isSelected = false + // call configure delegate for fourther config + displayDelegate.configureAudioCell(self, message: message) + } + + private func configureCellApperance(with message: MessageType, indexPath: IndexPath, messagesCollectionView: MessagesCollectionView) { + // modify elements constrains based on message direction (incoming or outgoing) + guard let dataSource = messagesCollectionView.messagesDataSource else { + fatalError(MessageKitError.nilMessagesDataSource) + } + let playButtonLeftConstrain = messageContainerView.constraints.filter({ $0.identifier == "left" }).first + let durationLabelRightConstrain = messageContainerView.constraints.filter({ $0.identifier == "right" }).first + if dataSource.isFromCurrentSender(message: message) == false { // outgoing message + playButtonLeftConstrain?.constant = 12 + durationLabelRightConstrain?.constant = -8 + } else { // incoming message + playButtonLeftConstrain?.constant = 5 + durationLabelRightConstrain?.constant = -15 + } + guard let displayDelegate = messagesCollectionView.messagesDisplayDelegate else { + fatalError(MessageKitError.nilMessagesDisplayDelegate) + } + let tintColor = displayDelegate.audioTintColor(for: message, at: indexPath, in: messagesCollectionView) + playButton.imageView?.tintColor = tintColor + durationLabel.textColor = tintColor + progressView.tintColor = tintColor + } + +} diff --git a/Sources/Views/Cells/MessageCollectionViewCell.swift b/Sources/Views/Cells/MessageCollectionViewCell.swift index 08114cb5..f8929c21 100644 --- a/Sources/Views/Cells/MessageCollectionViewCell.swift +++ b/Sources/Views/Cells/MessageCollectionViewCell.swift @@ -37,4 +37,9 @@ open class MessageCollectionViewCell: UICollectionViewCell { super.init(coder: aDecoder) } + /// Handle tap gesture on contentView and its subviews. + open func handleTapGesture(_ gesture: UIGestureRecognizer) { + // Should be overridden + } + } diff --git a/Sources/Views/Cells/MessageContentCell.swift b/Sources/Views/Cells/MessageContentCell.swift index 3a194a68..19e59fb6 100644 --- a/Sources/Views/Cells/MessageContentCell.swift +++ b/Sources/Views/Cells/MessageContentCell.swift @@ -46,6 +46,14 @@ open class MessageContentCell: MessageCollectionViewCell { return label }() + /// The bottom label of the cell. + open var cellBottomLabel: InsetLabel = { + let label = InsetLabel() + label.numberOfLines = 0 + label.textAlignment = .center + return label + }() + /// The top label of the messageBubble. open var messageTopLabel: InsetLabel = { let label = InsetLabel() @@ -83,6 +91,7 @@ open class MessageContentCell: MessageCollectionViewCell { contentView.addSubview(cellTopLabel) contentView.addSubview(messageTopLabel) contentView.addSubview(messageBottomLabel) + contentView.addSubview(cellBottomLabel) contentView.addSubview(messageContainerView) contentView.addSubview(avatarView) } @@ -90,6 +99,7 @@ open class MessageContentCell: MessageCollectionViewCell { open override func prepareForReuse() { super.prepareForReuse() cellTopLabel.text = nil + cellBottomLabel.text = nil messageTopLabel.text = nil messageBottomLabel.text = nil } @@ -101,7 +111,8 @@ open class MessageContentCell: MessageCollectionViewCell { guard let attributes = layoutAttributes as? MessagesCollectionViewLayoutAttributes else { return } // Call this before other laying out other subviews layoutMessageContainerView(with: attributes) - layoutBottomLabel(with: attributes) + layoutMessageBottomLabel(with: attributes) + layoutCellBottomLabel(with: attributes) layoutCellTopLabel(with: attributes) layoutMessageTopLabel(with: attributes) layoutAvatarView(with: attributes) @@ -135,16 +146,18 @@ open class MessageContentCell: MessageCollectionViewCell { messageContainerView.style = messageStyle let topCellLabelText = dataSource.cellTopLabelAttributedText(for: message, at: indexPath) + let bottomCellLabelText = dataSource.cellBottomLabelAttributedText(for: message, at: indexPath) let topMessageLabelText = dataSource.messageTopLabelAttributedText(for: message, at: indexPath) - let bottomText = dataSource.messageBottomLabelAttributedText(for: message, at: indexPath) + let bottomMessageLabelText = dataSource.messageBottomLabelAttributedText(for: message, at: indexPath) cellTopLabel.attributedText = topCellLabelText + cellBottomLabel.attributedText = bottomCellLabelText messageTopLabel.attributedText = topMessageLabelText - messageBottomLabel.attributedText = bottomText + messageBottomLabel.attributedText = bottomMessageLabelText } /// Handle tap gesture on contentView and its subviews. - open func handleTapGesture(_ gesture: UIGestureRecognizer) { + open override func handleTapGesture(_ gesture: UIGestureRecognizer) { let touchLocation = gesture.location(in: self) switch true { @@ -154,6 +167,8 @@ open class MessageContentCell: MessageCollectionViewCell { delegate?.didTapAvatar(in: self) case cellTopLabel.frame.contains(touchLocation): delegate?.didTapCellTopLabel(in: self) + case cellBottomLabel.frame.contains(touchLocation): + delegate?.didTapCellBottomLabel(in: self) case messageTopLabel.frame.contains(touchLocation): delegate?.didTapMessageTopLabel(in: self) case messageBottomLabel.frame.contains(touchLocation): @@ -161,7 +176,7 @@ open class MessageContentCell: MessageCollectionViewCell { case accessoryView.frame.contains(touchLocation): delegate?.didTapAccessoryView(in: self) default: - break + delegate?.didTapBackground(in: self) } } @@ -183,12 +198,13 @@ open class MessageContentCell: MessageCollectionViewCell { /// - attributes: The `MessagesCollectionViewLayoutAttributes` for the cell. open func layoutAvatarView(with attributes: MessagesCollectionViewLayoutAttributes) { var origin: CGPoint = .zero + let padding = attributes.avatarLeadingTrailingPadding switch attributes.avatarPosition.horizontal { case .cellLeading: - break + origin.x = padding case .cellTrailing: - origin.x = attributes.frame.width - attributes.avatarSize.width + origin.x = attributes.frame.width - attributes.avatarSize.width - padding case .natural: fatalError(MessageKitError.avatarPositionUnresolved) } @@ -218,7 +234,7 @@ open class MessageContentCell: MessageCollectionViewCell { switch attributes.avatarPosition.vertical { case .messageBottom: - origin.y = attributes.size.height - attributes.messageContainerPadding.bottom - attributes.messageBottomLabelSize.height - attributes.messageContainerSize.height - attributes.messageContainerPadding.top + origin.y = attributes.size.height - attributes.messageContainerPadding.bottom - attributes.cellBottomLabelSize.height - attributes.messageBottomLabelSize.height - attributes.messageContainerSize.height - attributes.messageContainerPadding.top case .messageCenter: if attributes.avatarSize.height > attributes.messageContainerSize.height { let messageHeight = attributes.messageContainerSize.height + attributes.messageContainerPadding.vertical @@ -235,11 +251,12 @@ open class MessageContentCell: MessageCollectionViewCell { } } + let avatarPadding = attributes.avatarLeadingTrailingPadding switch attributes.avatarPosition.horizontal { case .cellLeading: - origin.x = attributes.avatarSize.width + attributes.messageContainerPadding.left + origin.x = attributes.avatarSize.width + attributes.messageContainerPadding.left + avatarPadding case .cellTrailing: - origin.x = attributes.frame.width - attributes.avatarSize.width - attributes.messageContainerSize.width - attributes.messageContainerPadding.right + origin.x = attributes.frame.width - attributes.avatarSize.width - attributes.messageContainerSize.width - attributes.messageContainerPadding.right - avatarPadding case .natural: fatalError(MessageKitError.avatarPositionUnresolved) } @@ -253,6 +270,18 @@ open class MessageContentCell: MessageCollectionViewCell { cellTopLabel.frame = CGRect(origin: .zero, size: attributes.cellTopLabelSize) } + /// Positions the cell's bottom label. + /// - attributes: The `MessagesCollectionViewLayoutAttributes` for the cell. + open func layoutCellBottomLabel(with attributes: MessagesCollectionViewLayoutAttributes) { + cellBottomLabel.textAlignment = attributes.cellBottomLabelAlignment.textAlignment + cellBottomLabel.textInsets = attributes.cellBottomLabelAlignment.textInsets + + let y = messageBottomLabel.frame.maxY + let origin = CGPoint(x: 0, y: y) + + cellBottomLabel.frame = CGRect(origin: origin, size: attributes.cellBottomLabelSize) + } + /// Positions the message bubble's top label. /// - attributes: The `MessagesCollectionViewLayoutAttributes` for the cell. open func layoutMessageTopLabel(with attributes: MessagesCollectionViewLayoutAttributes) { @@ -265,9 +294,9 @@ open class MessageContentCell: MessageCollectionViewCell { messageTopLabel.frame = CGRect(origin: origin, size: attributes.messageTopLabelSize) } - /// Positions the cell's bottom label. + /// Positions the message bubble's bottom label. /// - attributes: The `MessagesCollectionViewLayoutAttributes` for the cell. - open func layoutBottomLabel(with attributes: MessagesCollectionViewLayoutAttributes) { + open func layoutMessageBottomLabel(with attributes: MessagesCollectionViewLayoutAttributes) { messageBottomLabel.textAlignment = attributes.messageBottomLabelAlignment.textAlignment messageBottomLabel.textInsets = attributes.messageBottomLabelAlignment.textInsets diff --git a/Sources/Views/MessagesCollectionView.swift b/Sources/Views/MessagesCollectionView.swift index ee1e4cd3..da759b41 100644 --- a/Sources/Views/MessagesCollectionView.swift +++ b/Sources/Views/MessagesCollectionView.swift @@ -65,6 +65,7 @@ open class MessagesCollectionView: UICollectionView { register(TextMessageCell.self) register(MediaMessageCell.self) register(LocationMessageCell.self) + register(AudioMessageCell.self) register(MessageReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader) register(MessageReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter) } @@ -82,7 +83,7 @@ open class MessagesCollectionView: UICollectionView { let touchLocation = gesture.location(in: self) guard let indexPath = indexPathForItem(at: touchLocation) else { return } - let cell = cellForItem(at: indexPath) as? MessageContentCell + let cell = cellForItem(at: indexPath) as? MessageCollectionViewCell cell?.handleTapGesture(gesture) } diff --git a/Tests/ControllersTest/MessagesViewControllerTests.swift b/Tests/ControllersTest/MessagesViewControllerTests.swift index 8e7a7d78..9dbaf381 100644 --- a/Tests/ControllersTest/MessagesViewControllerTests.swift +++ b/Tests/ControllersTest/MessagesViewControllerTests.swift @@ -168,6 +168,23 @@ class MessagesViewControllerTests: XCTestCase { XCTAssertTrue(cell is LocationMessageCell) } + func testCellForItemWithAudioData_returnsAudioMessageCell() { + let messagesDataSource = MockMessagesDataSource() + sut.messagesCollectionView.messagesDataSource = messagesDataSource + messagesDataSource.messages.append(MockMessage(audioURL: URL.init(fileURLWithPath: ""), + duration: 4.0, + sender: messagesDataSource.senders[0], + messageId: "test_id")) + + sut.messagesCollectionView.reloadData() + + let cell = sut.messagesCollectionView.dataSource?.collectionView(sut.messagesCollectionView, + cellForItemAt: IndexPath(item: 0, section: 0)) + + XCTAssertNotNil(cell) + XCTAssertTrue(cell is AudioMessageCell) + } + // MARK: - Assistants private func makeMessages(for senders: [MockUser]) -> [MessageType] { diff --git a/Tests/Mocks/MockMessage.swift b/Tests/Mocks/MockMessage.swift index 7dc2d36c..5b4fcceb 100644 --- a/Tests/Mocks/MockMessage.swift +++ b/Tests/Mocks/MockMessage.swift @@ -24,6 +24,7 @@ import Foundation import CoreLocation +import AVFoundation @testable import MessageKit struct MockLocationItem: LocationItem { @@ -53,6 +54,21 @@ struct MockMediaItem: MediaItem { } +private struct MockAudiotem: AudioItem { + + var url: URL + var size: CGSize + var duration: Float + + init(url: URL, duration: Float) { + self.url = url + self.size = CGSize(width: 160, height: 35) + self.duration = duration + } + +} + + struct MockMessage: MessageType { var messageId: String @@ -97,4 +113,9 @@ struct MockMessage: MessageType { self.init(kind: .emoji(emoji), user: user, messageId: messageId) } + init(audioURL: URL, duration: Float, sender: Sender, messageId: String) { + let audioItem = MockAudiotem(url: audioURL, duration: duration) + self.init(kind: .audio(audioItem), sender: sender, messageId: messageId) + } + } diff --git a/Tests/Resources/sound1.m4a b/Tests/Resources/sound1.m4a new file mode 100644 index 00000000..56b31a69 Binary files /dev/null and b/Tests/Resources/sound1.m4a differ