Chatto/ChattoAdditions/Source/Chat Items/BaseMessage/Views/BaseMessageCollectionViewCe...

426 lines
17 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
import Chatto
public protocol BaseMessageCollectionViewCellStyleProtocol {
func avatarSize(viewModel viewModel: MessageViewModelProtocol) -> CGSize // .zero => no avatar
func avatarVerticalAlignment(viewModel viewModel: MessageViewModelProtocol) -> VerticalAlignment
var failedIcon: UIImage { get }
var failedIconHighlighted: UIImage { get }
func attributedStringForDate(date: String) -> NSAttributedString
func layoutConstants(viewModel viewModel: MessageViewModelProtocol) -> BaseMessageCollectionViewCellLayoutConstants
}
public struct BaseMessageCollectionViewCellLayoutConstants {
public let horizontalMargin: CGFloat
public let horizontalInterspacing: CGFloat
public let horizontalTimestampMargin: CGFloat
public let maxContainerWidthPercentageForBubbleView: CGFloat
public init(horizontalMargin: CGFloat,
horizontalInterspacing: CGFloat,
horizontalTimestampMargin: CGFloat,
maxContainerWidthPercentageForBubbleView: CGFloat) {
self.horizontalMargin = horizontalMargin
self.horizontalInterspacing = horizontalInterspacing
self.horizontalTimestampMargin = horizontalTimestampMargin
self.maxContainerWidthPercentageForBubbleView = maxContainerWidthPercentageForBubbleView
}
}
/**
Base class for message cells
Provides:
- Reveleable timestamp layout logic
- Failed view
- Incoming/outcoming layout
Subclasses responsability
- Implement createBubbleView
- Have a BubbleViewType that responds properly to sizeThatFits:
*/
public class BaseMessageCollectionViewCell<BubbleViewType where BubbleViewType:UIView, BubbleViewType:MaximumLayoutWidthSpecificable, BubbleViewType: BackgroundSizingQueryable>: UICollectionViewCell, BackgroundSizingQueryable, AccessoryViewRevealable, UIGestureRecognizerDelegate {
public var animationDuration: CFTimeInterval = 0.33
public var viewContext: ViewContext = .Normal
public private(set) var isUpdating: Bool = false
public func performBatchUpdates(updateClosure: () -> Void, animated: Bool, completion: (() ->())?) {
self.isUpdating = true
let updateAndRefreshViews = {
updateClosure()
self.isUpdating = false
self.updateViews()
if animated {
self.layoutIfNeeded()
}
}
if animated {
UIView.animateWithDuration(self.animationDuration, animations: updateAndRefreshViews, completion: { (finished) -> Void in
completion?()
})
} else {
updateAndRefreshViews()
}
}
public var messageViewModel: MessageViewModelProtocol! {
didSet {
updateViews()
}
}
var failedIcon: UIImage!
var failedIconHighlighted: UIImage!
public var baseStyle: BaseMessageCollectionViewCellStyleProtocol! {
didSet {
self.failedIcon = self.baseStyle.failedIcon
self.failedIconHighlighted = self.baseStyle.failedIconHighlighted
self.updateViews()
}
}
override public var selected: Bool {
didSet {
if oldValue != self.selected {
self.updateViews()
}
}
}
public var canCalculateSizeInBackground: Bool {
return self.bubbleView.canCalculateSizeInBackground
}
public private(set) var bubbleView: BubbleViewType!
public func createBubbleView() -> BubbleViewType! {
assert(false, "Override in subclass")
return nil
}
public private(set) var avatarView: UIImageView!
func createAvatarView() -> UIImageView! {
let avatarImageView = UIImageView(frame: CGRect.zero)
avatarImageView.userInteractionEnabled = true
return avatarImageView
}
public override init(frame: CGRect) {
super.init(frame: frame)
self.commonInit()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.commonInit()
}
public private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(BaseMessageCollectionViewCell.bubbleTapped(_:)))
return tapGestureRecognizer
}()
public private (set) lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let longpressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(BaseMessageCollectionViewCell.bubbleLongPressed(_:)))
longpressGestureRecognizer.delegate = self
return longpressGestureRecognizer
}()
public private(set) lazy var avatarTapGestureRecognizer: UITapGestureRecognizer = {
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(BaseMessageCollectionViewCell.avatarTapped(_:)))
return tapGestureRecognizer
}()
private func commonInit() {
self.avatarView = self.createAvatarView()
self.avatarView.addGestureRecognizer(self.avatarTapGestureRecognizer)
self.bubbleView = self.createBubbleView()
self.bubbleView.exclusiveTouch = true
self.bubbleView.addGestureRecognizer(self.tapGestureRecognizer)
self.bubbleView.addGestureRecognizer(self.longPressGestureRecognizer)
self.contentView.addSubview(self.avatarView)
self.contentView.addSubview(self.bubbleView)
self.contentView.addSubview(self.failedButton)
self.contentView.exclusiveTouch = true
self.exclusiveTouch = true
}
public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldReceiveTouch touch: UITouch) -> Bool {
return self.bubbleView.bounds.contains(touch.locationInView(self.bubbleView))
}
public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWithGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return gestureRecognizer === self.longPressGestureRecognizer
}
public override func prepareForReuse() {
super.prepareForReuse()
self.removeAccessoryView()
}
public lazy var failedButton: UIButton = {
let button = UIButton(type: .Custom)
button.addTarget(self, action: #selector(BaseMessageCollectionViewCell.failedButtonTapped), forControlEvents: .TouchUpInside)
return button
}()
// MARK: View model binding
final private func updateViews() {
if self.viewContext == .Sizing { return }
if self.isUpdating { return }
guard let viewModel = self.messageViewModel, style = self.baseStyle else { return }
if viewModel.showsFailedIcon {
self.failedButton.setImage(self.failedIcon, forState: .Normal)
self.failedButton.setImage(self.failedIconHighlighted, forState: .Highlighted)
self.failedButton.alpha = 1
} else {
self.failedButton.alpha = 0
}
self.accessoryTimestampView.attributedText = style.attributedStringForDate(viewModel.date)
let avatarImageSize = baseStyle.avatarSize(viewModel: messageViewModel)
if avatarImageSize != CGSize.zero {
self.avatarView.image = self.messageViewModel.avatarImage.value
}
self.setNeedsLayout()
}
// MARK: layout
public override func layoutSubviews() {
super.layoutSubviews()
let layoutModel = self.calculateLayout(availableWidth: self.contentView.bounds.width)
self.failedButton.bma_rect = layoutModel.failedViewFrame
self.bubbleView.bma_rect = layoutModel.bubbleViewFrame
self.bubbleView.preferredMaxLayoutWidth = layoutModel.preferredMaxWidthForBubble
self.bubbleView.layoutIfNeeded()
self.avatarView.bma_rect = layoutModel.avatarViewFrame
if self.accessoryTimestampView.superview != nil {
let layoutConstants = baseStyle.layoutConstants(viewModel: messageViewModel)
self.accessoryTimestampView.bounds = CGRect(origin: CGPoint.zero, size: self.accessoryTimestampView.intrinsicContentSize())
let accessoryViewWidth = self.accessoryTimestampView.bounds.width
let leftOffsetForContentView = max(0, offsetToRevealAccessoryView)
let leftOffsetForAccessoryView = min(leftOffsetForContentView, accessoryViewWidth + layoutConstants.horizontalTimestampMargin)
var contentViewframe = self.contentView.frame
if self.messageViewModel.isIncoming {
contentViewframe.origin = CGPoint.zero
} else {
contentViewframe.origin.x = -leftOffsetForContentView
}
self.contentView.frame = contentViewframe
self.accessoryTimestampView.center = CGPoint(x: self.bounds.width - leftOffsetForAccessoryView + accessoryViewWidth / 2, y: self.contentView.center.y)
}
}
public override func sizeThatFits(size: CGSize) -> CGSize {
return self.calculateLayout(availableWidth: size.width).size
}
private func calculateLayout(availableWidth availableWidth: CGFloat) -> BaseMessageLayoutModel {
let layoutConstants = baseStyle.layoutConstants(viewModel: messageViewModel)
let parameters = BaseMessageLayoutModelParameters(
containerWidth: availableWidth,
horizontalMargin: layoutConstants.horizontalMargin,
horizontalInterspacing: layoutConstants.horizontalInterspacing,
failedButtonSize: self.failedIcon.size,
maxContainerWidthPercentageForBubbleView: layoutConstants.maxContainerWidthPercentageForBubbleView,
bubbleView: self.bubbleView,
isIncoming: self.messageViewModel.isIncoming,
isFailed: self.messageViewModel.showsFailedIcon,
avatarSize: baseStyle.avatarSize(viewModel: messageViewModel),
avatarVerticalAlignment: baseStyle.avatarVerticalAlignment(viewModel: messageViewModel)
)
var layoutModel = BaseMessageLayoutModel()
layoutModel.calculateLayout(parameters: parameters)
return layoutModel
}
// MARK: timestamp revealing
lazy var accessoryTimestampView = UILabel()
var offsetToRevealAccessoryView: CGFloat = 0 {
didSet {
self.setNeedsLayout()
}
}
public var allowAccessoryViewRevealing: Bool = true
public func preferredOffsetToRevealAccessoryView() -> CGFloat? {
let layoutConstants = baseStyle.layoutConstants(viewModel: messageViewModel)
return self.accessoryTimestampView.intrinsicContentSize().width + layoutConstants.horizontalTimestampMargin
}
public func revealAccessoryView(withOffset offset: CGFloat, animated: Bool) {
self.offsetToRevealAccessoryView = offset
if self.accessoryTimestampView.superview == nil {
if offset > 0 {
self.addSubview(self.accessoryTimestampView)
self.layoutIfNeeded()
}
if animated {
UIView.animateWithDuration(self.animationDuration, animations: { () -> Void in
self.layoutIfNeeded()
})
}
} else {
if animated {
UIView.animateWithDuration(self.animationDuration, animations: { () -> Void in
self.layoutIfNeeded()
}, completion: { (finished) -> Void in
if offset == 0 {
self.removeAccessoryView()
}
})
}
}
}
func removeAccessoryView() {
self.accessoryTimestampView.removeFromSuperview()
}
// MARK: User interaction
public var onFailedButtonTapped: ((cell: BaseMessageCollectionViewCell) -> Void)?
@objc
func failedButtonTapped() {
self.onFailedButtonTapped?(cell: self)
}
public var onAvatarTapped: ((cell: BaseMessageCollectionViewCell) -> Void)?
@objc
func avatarTapped(tapGestureRecognizer: UITapGestureRecognizer) {
self.onAvatarTapped?(cell: self)
}
public var onBubbleTapped: ((cell: BaseMessageCollectionViewCell) -> Void)?
@objc
func bubbleTapped(tapGestureRecognizer: UITapGestureRecognizer) {
self.onBubbleTapped?(cell: self)
}
public var onBubbleLongPressBegan: ((cell: BaseMessageCollectionViewCell) -> Void)?
public var onBubbleLongPressEnded: ((cell: BaseMessageCollectionViewCell) -> Void)?
@objc
private func bubbleLongPressed(longPressGestureRecognizer: UILongPressGestureRecognizer) {
switch longPressGestureRecognizer.state {
case .Began:
self.onBubbleLongPressBegan?(cell: self)
case .Ended, .Cancelled:
self.onBubbleLongPressEnded?(cell: self)
default:
break
}
}
}
struct BaseMessageLayoutModel {
private (set) var size = CGSize.zero
private (set) var failedViewFrame = CGRect.zero
private (set) var bubbleViewFrame = CGRect.zero
private (set) var avatarViewFrame = CGRect.zero
private (set) var preferredMaxWidthForBubble: CGFloat = 0
mutating func calculateLayout(parameters parameters: BaseMessageLayoutModelParameters) {
let containerWidth = parameters.containerWidth
let isIncoming = parameters.isIncoming
let isFailed = parameters.isFailed
let failedButtonSize = parameters.failedButtonSize
let bubbleView = parameters.bubbleView
let horizontalMargin = parameters.horizontalMargin
let horizontalInterspacing = parameters.horizontalInterspacing
let avatarSize = parameters.avatarSize
let preferredWidthForBubble = (containerWidth * parameters.maxContainerWidthPercentageForBubbleView).bma_round()
let bubbleSize = bubbleView.sizeThatFits(CGSize(width: preferredWidthForBubble, height: .max))
let containerRect = CGRect(origin: CGPoint.zero, size: CGSize(width: containerWidth, height: bubbleSize.height))
self.bubbleViewFrame = bubbleSize.bma_rect(inContainer: containerRect, xAlignament: .Center, yAlignment: .Center, dx: 0, dy: 0)
self.failedViewFrame = failedButtonSize.bma_rect(inContainer: containerRect, xAlignament: .Center, yAlignment: .Center, dx: 0, dy: 0)
self.avatarViewFrame = avatarSize.bma_rect(inContainer: containerRect, xAlignament: .Center, yAlignment: parameters.avatarVerticalAlignment, dx: 0, dy: 0)
// Adjust horizontal positions
var currentX: CGFloat = 0
if isIncoming {
currentX = horizontalMargin
self.avatarViewFrame.origin.x = currentX
currentX += avatarSize.width
currentX += horizontalInterspacing
if isFailed {
self.failedViewFrame.origin.x = currentX
currentX += failedButtonSize.width
currentX += horizontalInterspacing
} else {
self.failedViewFrame.origin.x = -failedButtonSize.width
}
self.bubbleViewFrame.origin.x = currentX
} else {
currentX = containerRect.maxX - horizontalMargin
currentX -= avatarSize.width
self.avatarViewFrame.origin.x = currentX
currentX -= horizontalInterspacing
if isFailed {
currentX -= failedButtonSize.width
self.failedViewFrame.origin.x = currentX
currentX -= horizontalInterspacing
} else {
self.failedViewFrame.origin.x = containerRect.width - -failedButtonSize.width
}
currentX -= bubbleSize.width
self.bubbleViewFrame.origin.x = currentX
}
self.size = containerRect.size
self.preferredMaxWidthForBubble = preferredWidthForBubble
}
}
struct BaseMessageLayoutModelParameters {
let containerWidth: CGFloat
let horizontalMargin: CGFloat
let horizontalInterspacing: CGFloat
let failedButtonSize: CGSize
let maxContainerWidthPercentageForBubbleView: CGFloat // in [0, 1]
let bubbleView: UIView
let isIncoming: Bool
let isFailed: Bool
let avatarSize: CGSize
let avatarVerticalAlignment: VerticalAlignment
}