273 lines
14 KiB
Swift
273 lines
14 KiB
Swift
/*
|
|
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<ChatItemCompanion>
|
|
|
|
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)
|
|
}
|
|
}
|