/* The MIT License (MIT) Copyright (c) 2015-present Badoo Trading Limited. 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 public protocol ChatItemPresenterFactoryProtocol { func createChatItemPresenter(chatItem: ChatItemProtocol) -> ChatItemPresenterProtocol func configure(withCollectionView collectionView: UICollectionView) } public class BaseChatItemPresenterFactory : ChatItemPresenterFactoryProtocol { var presenterBuildersByType = [ChatItemType: [ChatItemPresenterBuilderProtocol]]() init(presenterBuildersByType: [ChatItemType: [ChatItemPresenterBuilderProtocol]]){ self.presenterBuildersByType = presenterBuildersByType } public func createChatItemPresenter(chatItem: ChatItemProtocol) -> ChatItemPresenterProtocol { for builder in self.presenterBuildersByType[chatItem.type] ?? [] { if builder.canHandleChatItem(chatItem) { return builder.createPresenterWithChatItem(chatItem) } } return DummyChatItemPresenter() } public func configure(withCollectionView collectionView: UICollectionView) { for presenterBuilder in self.presenterBuildersByType.flatMap({ $0.1 }) { presenterBuilder.presenterType.registerCells(collectionView) } DummyChatItemPresenter.registerCells(collectionView) } } public class BaseChatViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate { public typealias ChatItemCompanionCollection = ReadOnlyOrderedDictionary public struct Constants { var updatesAnimationDuration: NSTimeInterval = 0.33 var defaultContentInsets = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) var defaultScrollIndicatorInsets = UIEdgeInsetsZero var preferredMaxMessageCount: Int? = 500 // It not nil, will ask data source to reduce number of messages when limit is reached. @see ChatDataSourceDelegateProtocol var preferredMaxMessageCountAdjustment: Int = 400 // When the above happens, will ask to adjust with this value. It may be wise for this to be smaller to reduce number of adjustments var autoloadingFractionalThreshold: CGFloat = 0.05 // in [0, 1] } public var constants = Constants() public private(set) var collectionView: UICollectionView! public internal(set) var chatItemCompanionCollection: ChatItemCompanionCollection = ReadOnlyOrderedDictionary(items: []) public var chatDataSource: ChatDataSourceProtocol? { didSet { self.chatDataSource?.delegate = self self.enqueueModelUpdate(updateType: .Reload) } } deinit { self.collectionView.delegate = nil self.collectionView.dataSource = nil } override public func viewDidLoad() { super.viewDidLoad() self.addCollectionView() self.addInputViews() } public override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) self.keyboardTracker.startTracking() } public override func viewWillDisappear(animated: Bool) { super.viewWillDisappear(animated) self.keyboardTracker.stopTracking() } private func addCollectionView() { self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.createCollectionViewLayout) self.collectionView.contentInset = self.constants.defaultContentInsets self.collectionView.scrollIndicatorInsets = self.constants.defaultScrollIndicatorInsets self.collectionView.alwaysBounceVertical = true self.collectionView.backgroundColor = UIColor.clearColor() self.collectionView.keyboardDismissMode = .Interactive self.collectionView.showsVerticalScrollIndicator = true self.collectionView.showsHorizontalScrollIndicator = false self.collectionView.allowsSelection = false self.collectionView.translatesAutoresizingMaskIntoConstraints = false self.collectionView.autoresizingMask = .None self.view.addSubview(self.collectionView) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Top, relatedBy: .Equal, toItem: self.collectionView, attribute: .Top, multiplier: 1, constant: 0)) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Leading, relatedBy: .Equal, toItem: self.collectionView, attribute: .Leading, multiplier: 1, constant: 0)) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Bottom, relatedBy: .Equal, toItem: self.collectionView, attribute: .Bottom, multiplier: 1, constant: 0)) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Trailing, relatedBy: .Equal, toItem: self.collectionView, attribute: .Trailing, multiplier: 1, constant: 0)) self.collectionView.dataSource = self self.collectionView.delegate = self self.accessoryViewRevealer = AccessoryViewRevealer(collectionView: self.collectionView) self.presenterFactory = self.createPresenterFactory() self.presenterFactory.configure(withCollectionView: self.collectionView) } private var inputContainerBottomConstraint: NSLayoutConstraint! private func addInputViews() { self.inputContainer = UIView(frame: CGRect.zero) self.inputContainer.autoresizingMask = .None self.inputContainer.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(self.inputContainer) self.view.addConstraint(NSLayoutConstraint(item: self.inputContainer, attribute: .Top, relatedBy: .GreaterThanOrEqual, toItem: self.topLayoutGuide, attribute: .Bottom, multiplier: 1, constant: 0)) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Leading, relatedBy: .Equal, toItem: self.inputContainer, attribute: .Leading, multiplier: 1, constant: 0)) self.view.addConstraint(NSLayoutConstraint(item: self.view, attribute: .Trailing, relatedBy: .Equal, toItem: self.inputContainer, attribute: .Trailing, multiplier: 1, constant: 0)) self.inputContainerBottomConstraint = NSLayoutConstraint(item: self.view, attribute: .Bottom, relatedBy: .Equal, toItem: self.inputContainer, attribute: .Bottom, multiplier: 1, constant: 0) self.view.addConstraint(self.inputContainerBottomConstraint) let inputView = self.createChatInputView() self.inputContainer.addSubview(inputView) self.inputContainer.addConstraint(NSLayoutConstraint(item: self.inputContainer, attribute: .Top, relatedBy: .Equal, toItem: inputView, attribute: .Top, multiplier: 1, constant: 0)) self.inputContainer.addConstraint(NSLayoutConstraint(item: self.inputContainer, attribute: .Leading, relatedBy: .Equal, toItem: inputView, attribute: .Leading, multiplier: 1, constant: 0)) self.inputContainer.addConstraint(NSLayoutConstraint(item: self.inputContainer, attribute: .Bottom, relatedBy: .Equal, toItem: inputView, attribute: .Bottom, multiplier: 1, constant: 0)) self.inputContainer.addConstraint(NSLayoutConstraint(item: self.inputContainer, attribute: .Trailing, relatedBy: .Equal, toItem: inputView, attribute: .Trailing, multiplier: 1, constant: 0)) self.keyboardTracker = KeyboardTracker(viewController: self, inputContainer: self.inputContainer, inputContainerBottomContraint: self.inputContainerBottomConstraint, notificationCenter: self.notificationCenter) } var notificationCenter = NSNotificationCenter.defaultCenter() var keyboardTracker: KeyboardTracker! public override var inputAccessoryView: UIView { return self.keyboardTracker.trackingView } public var isFirstLayout: Bool = true override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() self.adjustCollectionViewInsets() self.keyboardTracker.layoutTrackingViewIfNeeded() if self.isFirstLayout { self.updateQueue.start() self.isFirstLayout = false } } private func adjustCollectionViewInsets() { let isInteracting = self.collectionView.panGestureRecognizer.numberOfTouches() > 0 let isBouncingAtTop = isInteracting && self.collectionView.contentOffset.y < -self.collectionView.contentInset.top if isBouncingAtTop { return } let inputHeightWithKeyboard = self.view.bounds.height - self.inputContainer.frame.minY let newInsetBottom = self.constants.defaultContentInsets.bottom + inputHeightWithKeyboard let insetBottomDiff = newInsetBottom - self.collectionView.contentInset.bottom let contentSize = self.collectionView.collectionViewLayout.collectionViewContentSize() let allContentFits = self.collectionView.bounds.height - newInsetBottom - (contentSize.height + self.collectionView.contentInset.top) >= 0 let currentDistanceToBottomInset = max(0, self.collectionView.bounds.height - self.collectionView.contentInset.bottom - (contentSize.height - self.collectionView.contentOffset.y)) let newContentOffsetY = self.collectionView.contentOffset.y + insetBottomDiff - currentDistanceToBottomInset self.collectionView.contentInset.bottom = newInsetBottom self.collectionView.scrollIndicatorInsets.bottom = self.constants.defaultScrollIndicatorInsets.bottom + inputHeightWithKeyboard let inputIsAtBottom = self.view.bounds.maxY - self.inputContainer.frame.maxY <= 0 if allContentFits { self.collectionView.contentOffset.y = -self.collectionView.contentInset.top } else if !isInteracting || inputIsAtBottom { self.collectionView.contentOffset.y = newContentOffsetY } self.workaroundContentInsetBugiOS_9_0_x() } func workaroundContentInsetBugiOS_9_0_x() { // Fix for http://www.openradar.me/22106545 self.collectionView.contentInset.top = self.topLayoutGuide.length + self.constants.defaultContentInsets.top self.collectionView.scrollIndicatorInsets.top = self.topLayoutGuide.length + self.constants.defaultScrollIndicatorInsets.top } func rectAtIndexPath(indexPath: NSIndexPath?) -> CGRect? { if let indexPath = indexPath { return self.collectionView.collectionViewLayout.layoutAttributesForItemAtIndexPath(indexPath)?.frame } return nil } var autoLoadingEnabled: Bool = false var accessoryViewRevealer: AccessoryViewRevealer! public private(set) var inputContainer: UIView! var presenterFactory : ChatItemPresenterFactoryProtocol! let presentersByCell = NSMapTable(keyOptions: .WeakMemory, valueOptions: .WeakMemory) var updateQueue: SerialTaskQueueProtocol = SerialTaskQueue() /** - You can use a decorator to: - Provide the ChatCollectionViewLayout with margins between messages - Provide to your pressenters additional attributes to help them configure their cells (for instance if a bubble should show a tail) - You can also add new items (for instance time markers or failed cells) */ public var chatItemsDecorator: ChatItemsDecoratorProtocol? public var createCollectionViewLayout: UICollectionViewLayout { let layout = ChatCollectionViewLayout() layout.delegate = self return layout } var layoutModel = ChatCollectionViewLayoutModel.createModel(0, itemsLayoutData: []) // MARK: Subclass overrides public func createPresenterFactory() -> ChatItemPresenterFactoryProtocol { // Default implementation return BaseChatItemPresenterFactory(presenterBuildersByType: self.createPresenterBuilders()) } public func createPresenterBuilders() -> [ChatItemType: [ChatItemPresenterBuilderProtocol]] { assert(false, "Override in subclass") return [ChatItemType: [ChatItemPresenterBuilderProtocol]]() } public func createChatInputView() -> UIView { assert(false, "Override in subclass") return UIView() } /** When paginating up we need to change the scroll position as the content is pushed down. We take distance to top from beforeUpdate indexPath and then we make afterUpdate indexPath to appear at the same distance */ public func referenceIndexPathsToRestoreScrollPositionOnUpdate(itemsBeforeUpdate itemsBeforeUpdate: ChatItemCompanionCollection, changes: CollectionChanges) -> (beforeUpdate: NSIndexPath?, afterUpdate: NSIndexPath?) { let firstItemMoved = changes.movedIndexPaths.first return (firstItemMoved?.indexPathOld, firstItemMoved?.indexPathNew) } } extension BaseChatViewController { // Rotation public override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator) let shouldScrollToBottom = self.isScrolledAtBottom() let referenceIndexPath = self.collectionView.indexPathsForVisibleItems().first let oldRect = self.rectAtIndexPath(referenceIndexPath) coordinator.animateAlongsideTransition({ (context) -> Void in if shouldScrollToBottom { self.scrollToBottom(animated: false) } else { let newRect = self.rectAtIndexPath(referenceIndexPath) self.scrollToPreservePosition(oldRefRect: oldRect, newRefRect: newRect) } }, completion: nil) } }