blob: b430d628f36b9150a1a46eef37340fddb4472c19 [file] [log] [blame]
//
// Created by Jesse Squires
// http://www.jessesquires.com
//
//
// Documentation
// http://cocoadocs.org/docsets/JSQMessagesViewController
//
//
// GitHub
// https://github.com/jessesquires/JSQMessagesViewController
//
//
// License
// Copyright (c) 2014 Jesse Squires
// Released under an MIT license: http://opensource.org/licenses/MIT
//
//
// Ideas for springy collection view layout taken from Ash Furrow
// ASHSpringyCollectionView
// https://github.com/AshFurrow/ASHSpringyCollectionView
//
#import "JSQMessagesCollectionViewFlowLayout.h"
#import "JSQMessageData.h"
#import "JSQMessagesCollectionView.h"
#import "JSQMessagesCollectionViewCell.h"
#import "JSQMessagesCollectionViewLayoutAttributes.h"
#import "JSQMessagesCollectionViewFlowLayoutInvalidationContext.h"
#import "JSQMessagesBubblesSizeCalculator.h"
#import "UIImage+JSQMessages.h"
const CGFloat kJSQMessagesCollectionViewCellLabelHeightDefault = 20.0f;
const CGFloat kJSQMessagesCollectionViewAvatarSizeDefault = 30.0f;
@interface JSQMessagesCollectionViewFlowLayout ()
@property (strong, nonatomic) UIDynamicAnimator *dynamicAnimator;
@property (strong, nonatomic) NSMutableSet *visibleIndexPaths;
@property (assign, nonatomic) CGFloat latestDelta;
@end
@implementation JSQMessagesCollectionViewFlowLayout
@dynamic collectionView;
@synthesize bubbleSizeCalculator = _bubbleSizeCalculator;
#pragma mark - Initialization
- (void)jsq_configureFlowLayout
{
self.scrollDirection = UICollectionViewScrollDirectionVertical;
self.sectionInset = UIEdgeInsetsMake(10.0f, 4.0f, 10.0f, 4.0f);
self.minimumLineSpacing = 4.0f;
_messageBubbleFont = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) {
_messageBubbleLeftRightMargin = 240.0f;
}
else {
_messageBubbleLeftRightMargin = 50.0f;
}
_messageBubbleTextViewFrameInsets = UIEdgeInsetsMake(0.0f, 0.0f, 0.0f, 6.0f);
_messageBubbleTextViewTextContainerInsets = UIEdgeInsetsMake(7.0f, 14.0f, 7.0f, 14.0f);
CGSize defaultAvatarSize = CGSizeMake(kJSQMessagesCollectionViewAvatarSizeDefault, kJSQMessagesCollectionViewAvatarSizeDefault);
_incomingAvatarViewSize = defaultAvatarSize;
_outgoingAvatarViewSize = defaultAvatarSize;
_springinessEnabled = NO;
_springResistanceFactor = 1000;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(jsq_didReceiveApplicationMemoryWarningNotification:)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(jsq_didReceiveDeviceOrientationDidChangeNotification:)
name:UIDeviceOrientationDidChangeNotification
object:nil];
}
- (instancetype)init
{
self = [super init];
if (self) {
[self jsq_configureFlowLayout];
}
return self;
}
- (void)awakeFromNib
{
[super awakeFromNib];
[self jsq_configureFlowLayout];
}
+ (Class)layoutAttributesClass
{
return [JSQMessagesCollectionViewLayoutAttributes class];
}
+ (Class)invalidationContextClass
{
return [JSQMessagesCollectionViewFlowLayoutInvalidationContext class];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Setters
- (void)setBubbleSizeCalculator:(id<JSQMessagesBubbleSizeCalculating>)bubbleSizeCalculator
{
NSParameterAssert(bubbleSizeCalculator != nil);
_bubbleSizeCalculator = bubbleSizeCalculator;
}
- (void)setSpringinessEnabled:(BOOL)springinessEnabled
{
if (_springinessEnabled == springinessEnabled) {
return;
}
_springinessEnabled = springinessEnabled;
if (!springinessEnabled) {
[_dynamicAnimator removeAllBehaviors];
[_visibleIndexPaths removeAllObjects];
}
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)setMessageBubbleFont:(UIFont *)messageBubbleFont
{
if ([_messageBubbleFont isEqual:messageBubbleFont]) {
return;
}
NSParameterAssert(messageBubbleFont != nil);
_messageBubbleFont = messageBubbleFont;
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)setMessageBubbleLeftRightMargin:(CGFloat)messageBubbleLeftRightMargin
{
NSParameterAssert(messageBubbleLeftRightMargin >= 0.0f);
_messageBubbleLeftRightMargin = ceilf(messageBubbleLeftRightMargin);
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)setMessageBubbleTextViewTextContainerInsets:(UIEdgeInsets)messageBubbleTextContainerInsets
{
if (UIEdgeInsetsEqualToEdgeInsets(_messageBubbleTextViewTextContainerInsets, messageBubbleTextContainerInsets)) {
return;
}
_messageBubbleTextViewTextContainerInsets = messageBubbleTextContainerInsets;
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)setIncomingAvatarViewSize:(CGSize)incomingAvatarViewSize
{
if (CGSizeEqualToSize(_incomingAvatarViewSize, incomingAvatarViewSize)) {
return;
}
_incomingAvatarViewSize = incomingAvatarViewSize;
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)setOutgoingAvatarViewSize:(CGSize)outgoingAvatarViewSize
{
if (CGSizeEqualToSize(_outgoingAvatarViewSize, outgoingAvatarViewSize)) {
return;
}
_outgoingAvatarViewSize = outgoingAvatarViewSize;
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
#pragma mark - Getters
- (CGFloat)itemWidth
{
return CGRectGetWidth(self.collectionView.frame) - self.sectionInset.left - self.sectionInset.right;
}
- (UIDynamicAnimator *)dynamicAnimator
{
if (!_dynamicAnimator) {
_dynamicAnimator = [[UIDynamicAnimator alloc] initWithCollectionViewLayout:self];
}
return _dynamicAnimator;
}
- (NSMutableSet *)visibleIndexPaths
{
if (!_visibleIndexPaths) {
_visibleIndexPaths = [NSMutableSet new];
}
return _visibleIndexPaths;
}
- (id<JSQMessagesBubbleSizeCalculating>)bubbleSizeCalculator
{
if (_bubbleSizeCalculator == nil) {
_bubbleSizeCalculator = [JSQMessagesBubblesSizeCalculator new];
}
return _bubbleSizeCalculator;
}
#pragma mark - Notifications
- (void)jsq_didReceiveApplicationMemoryWarningNotification:(NSNotification *)notification
{
[self jsq_resetLayout];
}
- (void)jsq_didReceiveDeviceOrientationDidChangeNotification:(NSNotification *)notification
{
[self jsq_resetLayout];
[self invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
#pragma mark - Collection view flow layout
- (void)invalidateLayoutWithContext:(JSQMessagesCollectionViewFlowLayoutInvalidationContext *)context
{
if (context.invalidateDataSourceCounts) {
context.invalidateFlowLayoutAttributes = YES;
context.invalidateFlowLayoutDelegateMetrics = YES;
}
if (context.invalidateFlowLayoutAttributes
|| context.invalidateFlowLayoutDelegateMetrics) {
[self jsq_resetDynamicAnimator];
}
if (context.invalidateFlowLayoutMessagesCache) {
[self jsq_resetLayout];
}
[super invalidateLayoutWithContext:context];
}
- (void)prepareLayout
{
[super prepareLayout];
if (self.springinessEnabled) {
// pad rect to avoid flickering
CGFloat padding = -100.0f;
CGRect visibleRect = CGRectInset(self.collectionView.bounds, padding, padding);
NSArray *visibleItems = [super layoutAttributesForElementsInRect:visibleRect];
NSSet *visibleItemsIndexPaths = [NSSet setWithArray:[visibleItems valueForKey:NSStringFromSelector(@selector(indexPath))]];
[self jsq_removeNoLongerVisibleBehaviorsFromVisibleItemsIndexPaths:visibleItemsIndexPaths];
[self jsq_addNewlyVisibleBehaviorsFromVisibleItems:visibleItems];
}
}
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *attributesInRect = [[super layoutAttributesForElementsInRect:rect] copy];
if (self.springinessEnabled) {
NSMutableArray *attributesInRectCopy = [attributesInRect mutableCopy];
NSArray *dynamicAttributes = [self.dynamicAnimator itemsInRect:rect];
// avoid duplicate attributes
// use dynamic animator attribute item instead of regular item, if it exists
for (UICollectionViewLayoutAttributes *eachItem in attributesInRect) {
for (UICollectionViewLayoutAttributes *eachDynamicItem in dynamicAttributes) {
if ([eachItem.indexPath isEqual:eachDynamicItem.indexPath]
&& eachItem.representedElementCategory == eachDynamicItem.representedElementCategory) {
[attributesInRectCopy removeObject:eachItem];
[attributesInRectCopy addObject:eachDynamicItem];
continue;
}
}
}
attributesInRect = [attributesInRectCopy copy];
}
[attributesInRect enumerateObjectsUsingBlock:^(JSQMessagesCollectionViewLayoutAttributes *attributesItem, NSUInteger idx, BOOL *stop) {
if (attributesItem.representedElementCategory == UICollectionElementCategoryCell) {
[self jsq_configureMessageCellLayoutAttributes:attributesItem];
}
else {
attributesItem.zIndex = -1;
}
}];
return attributesInRect;
}
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
JSQMessagesCollectionViewLayoutAttributes *customAttributes = (JSQMessagesCollectionViewLayoutAttributes *)[[super layoutAttributesForItemAtIndexPath:indexPath] copy];
if (customAttributes.representedElementCategory == UICollectionElementCategoryCell) {
[self jsq_configureMessageCellLayoutAttributes:customAttributes];
}
return customAttributes;
}
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
if (self.springinessEnabled) {
UIScrollView *scrollView = self.collectionView;
CGFloat delta = newBounds.origin.y - scrollView.bounds.origin.y;
self.latestDelta = delta;
CGPoint touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];
[self.dynamicAnimator.behaviors enumerateObjectsUsingBlock:^(UIAttachmentBehavior *springBehaviour, NSUInteger idx, BOOL *stop) {
[self jsq_adjustSpringBehavior:springBehaviour forTouchLocation:touchLocation];
[self.dynamicAnimator updateItemUsingCurrentState:[springBehaviour.items firstObject]];
}];
}
CGRect oldBounds = self.collectionView.bounds;
if (CGRectGetWidth(newBounds) != CGRectGetWidth(oldBounds)) {
return YES;
}
return NO;
}
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems
{
[super prepareForCollectionViewUpdates:updateItems];
[updateItems enumerateObjectsUsingBlock:^(UICollectionViewUpdateItem *updateItem, NSUInteger index, BOOL *stop) {
if (updateItem.updateAction == UICollectionUpdateActionInsert) {
if (self.springinessEnabled && [self.dynamicAnimator layoutAttributesForCellAtIndexPath:updateItem.indexPathAfterUpdate]) {
*stop = YES;
}
CGFloat collectionViewHeight = CGRectGetHeight(self.collectionView.bounds);
JSQMessagesCollectionViewLayoutAttributes *attributes = [JSQMessagesCollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:updateItem.indexPathAfterUpdate];
if (attributes.representedElementCategory == UICollectionElementCategoryCell) {
[self jsq_configureMessageCellLayoutAttributes:attributes];
}
attributes.frame = CGRectMake(0.0f,
collectionViewHeight + CGRectGetHeight(attributes.frame),
CGRectGetWidth(attributes.frame),
CGRectGetHeight(attributes.frame));
if (self.springinessEnabled) {
UIAttachmentBehavior *springBehaviour = [self jsq_springBehaviorWithLayoutAttributesItem:attributes];
[self.dynamicAnimator addBehavior:springBehaviour];
}
}
}];
}
#pragma mark - Invalidation utilities
- (void)jsq_resetLayout
{
[self.bubbleSizeCalculator prepareForResettingLayout:self];
[self jsq_resetDynamicAnimator];
}
- (void)jsq_resetDynamicAnimator
{
if (self.springinessEnabled) {
[self.dynamicAnimator removeAllBehaviors];
[self.visibleIndexPaths removeAllObjects];
}
}
#pragma mark - Message cell layout utilities
- (CGSize)messageBubbleSizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
id<JSQMessageData> messageItem = [self.collectionView.dataSource collectionView:self.collectionView
messageDataForItemAtIndexPath:indexPath];
return [self.bubbleSizeCalculator messageBubbleSizeForMessageData:messageItem
atIndexPath:indexPath
withLayout:self];
}
- (CGSize)sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
CGSize messageBubbleSize = [self messageBubbleSizeForItemAtIndexPath:indexPath];
JSQMessagesCollectionViewLayoutAttributes *attributes = (JSQMessagesCollectionViewLayoutAttributes *)[self layoutAttributesForItemAtIndexPath:indexPath];
CGFloat finalHeight = messageBubbleSize.height;
finalHeight += attributes.cellTopLabelHeight;
finalHeight += attributes.messageBubbleTopLabelHeight;
finalHeight += attributes.cellBottomLabelHeight;
return CGSizeMake(self.itemWidth, ceilf(finalHeight));
}
- (void)jsq_configureMessageCellLayoutAttributes:(JSQMessagesCollectionViewLayoutAttributes *)layoutAttributes
{
NSIndexPath *indexPath = layoutAttributes.indexPath;
CGSize messageBubbleSize = [self messageBubbleSizeForItemAtIndexPath:indexPath];
layoutAttributes.messageBubbleContainerViewWidth = messageBubbleSize.width;
layoutAttributes.textViewFrameInsets = self.messageBubbleTextViewFrameInsets;
layoutAttributes.textViewTextContainerInsets = self.messageBubbleTextViewTextContainerInsets;
layoutAttributes.messageBubbleFont = self.messageBubbleFont;
layoutAttributes.incomingAvatarViewSize = self.incomingAvatarViewSize;
layoutAttributes.outgoingAvatarViewSize = self.outgoingAvatarViewSize;
layoutAttributes.cellTopLabelHeight = [self.collectionView.delegate collectionView:self.collectionView
layout:self
heightForCellTopLabelAtIndexPath:indexPath];
layoutAttributes.messageBubbleTopLabelHeight = [self.collectionView.delegate collectionView:self.collectionView
layout:self
heightForMessageBubbleTopLabelAtIndexPath:indexPath];
layoutAttributes.cellBottomLabelHeight = [self.collectionView.delegate collectionView:self.collectionView
layout:self
heightForCellBottomLabelAtIndexPath:indexPath];
}
#pragma mark - Spring behavior utilities
- (UIAttachmentBehavior *)jsq_springBehaviorWithLayoutAttributesItem:(UICollectionViewLayoutAttributes *)item
{
if (CGSizeEqualToSize(item.frame.size, CGSizeZero)) {
// adding a spring behavior with zero size will fail in in -prepareForCollectionViewUpdates:
return nil;
}
UIAttachmentBehavior *springBehavior = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:item.center];
springBehavior.length = 1.0f;
springBehavior.damping = 1.0f;
springBehavior.frequency = 1.0f;
return springBehavior;
}
- (void)jsq_addNewlyVisibleBehaviorsFromVisibleItems:(NSArray *)visibleItems
{
// a "newly visible" item is in `visibleItems` but not in `self.visibleIndexPaths`
NSIndexSet *indexSet = [visibleItems indexesOfObjectsPassingTest:^BOOL(UICollectionViewLayoutAttributes *item, NSUInteger index, BOOL *stop) {
return ![self.visibleIndexPaths containsObject:item.indexPath];
}];
NSArray *newlyVisibleItems = [visibleItems objectsAtIndexes:indexSet];
CGPoint touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];
[newlyVisibleItems enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *item, NSUInteger index, BOOL *stop) {
UIAttachmentBehavior *springBehaviour = [self jsq_springBehaviorWithLayoutAttributesItem:item];
[self jsq_adjustSpringBehavior:springBehaviour forTouchLocation:touchLocation];
[self.dynamicAnimator addBehavior:springBehaviour];
[self.visibleIndexPaths addObject:item.indexPath];
}];
}
- (void)jsq_removeNoLongerVisibleBehaviorsFromVisibleItemsIndexPaths:(NSSet *)visibleItemsIndexPaths
{
NSArray *behaviors = self.dynamicAnimator.behaviors;
NSIndexSet *indexSet = [behaviors indexesOfObjectsPassingTest:^BOOL(UIAttachmentBehavior *springBehaviour, NSUInteger index, BOOL *stop) {
UICollectionViewLayoutAttributes *layoutAttributes = (UICollectionViewLayoutAttributes *)[springBehaviour.items firstObject];
return ![visibleItemsIndexPaths containsObject:layoutAttributes.indexPath];
}];
NSArray *behaviorsToRemove = [self.dynamicAnimator.behaviors objectsAtIndexes:indexSet];
[behaviorsToRemove enumerateObjectsUsingBlock:^(UIAttachmentBehavior *springBehaviour, NSUInteger index, BOOL *stop) {
UICollectionViewLayoutAttributes *layoutAttributes = (UICollectionViewLayoutAttributes *)[springBehaviour.items firstObject];
[self.dynamicAnimator removeBehavior:springBehaviour];
[self.visibleIndexPaths removeObject:layoutAttributes.indexPath];
}];
}
- (void)jsq_adjustSpringBehavior:(UIAttachmentBehavior *)springBehavior forTouchLocation:(CGPoint)touchLocation
{
UICollectionViewLayoutAttributes *item = (UICollectionViewLayoutAttributes *)[springBehavior.items firstObject];
CGPoint center = item.center;
// if touch is not (0,0) -- adjust item center "in flight"
if (!CGPointEqualToPoint(CGPointZero, touchLocation)) {
CGFloat distanceFromTouch = fabs(touchLocation.y - springBehavior.anchorPoint.y);
CGFloat scrollResistance = distanceFromTouch / self.springResistanceFactor;
if (self.latestDelta < 0.0f) {
center.y += MAX(self.latestDelta, self.latestDelta * scrollResistance);
}
else {
center.y += MIN(self.latestDelta, self.latestDelta * scrollResistance);
}
item.center = center;
}
}
@end