426 lines
17 KiB
Swift
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
|
|
}
|