blob: 945efc37511636323d735b7ca496e22ed121e756 [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
//
#import "JSQMessagesViewController.h"
#import "JSQMessagesCollectionViewFlowLayoutInvalidationContext.h"
#import "JSQMessageData.h"
#import "JSQMessageBubbleImageDataSource.h"
#import "JSQMessageAvatarImageDataSource.h"
#import "JSQMessagesCollectionViewCellIncoming.h"
#import "JSQMessagesCollectionViewCellOutgoing.h"
#import "JSQMessagesTypingIndicatorFooterView.h"
#import "JSQMessagesLoadEarlierHeaderView.h"
#import "JSQMessagesToolbarContentView.h"
#import "JSQMessagesInputToolbar.h"
#import "JSQMessagesComposerTextView.h"
#import "NSString+JSQMessages.h"
#import "UIColor+JSQMessages.h"
#import "UIDevice+JSQMessages.h"
#import "NSBundle+JSQMessages.h"
#import <objc/runtime.h>
// Fixes rdar://26295020
// See issue #1247 and Peter Steinberger's comment:
// https://github.com/jessesquires/JSQMessagesViewController/issues/1247#issuecomment-219386199
// Gist with workaround: https://gist.github.com/steipete/b00fc02aa9f1c66c11d0f996b1ba1265
// Forgive me
static IMP JSQReplaceMethodWithBlock(Class c, SEL origSEL, id block) {
NSCParameterAssert(block);
// get original method
Method origMethod = class_getInstanceMethod(c, origSEL);
NSCParameterAssert(origMethod);
// convert block to IMP trampoline and replace method implementation
IMP newIMP = imp_implementationWithBlock(block);
// Try adding the method if not yet in the current class
if (!class_addMethod(c, origSEL, newIMP, method_getTypeEncoding(origMethod))) {
return method_setImplementation(origMethod, newIMP);
} else {
return method_getImplementation(origMethod);
}
}
static void JSQInstallWorkaroundForSheetPresentationIssue26295020(void) {
__block void (^removeWorkaround)(void) = ^{};
const void (^installWorkaround)(void) = ^{
const SEL presentSEL = @selector(presentViewController:animated:completion:);
__block IMP origIMP = JSQReplaceMethodWithBlock(UIViewController.class, presentSEL, ^(UIViewController *self, id vC, BOOL animated, id completion) {
UIViewController *targetVC = self;
while (targetVC.presentedViewController) {
targetVC = targetVC.presentedViewController;
}
((void (*)(id, SEL, id, BOOL, id))origIMP)(targetVC, presentSEL, vC, animated, completion);
});
removeWorkaround = ^{
Method origMethod = class_getInstanceMethod(UIViewController.class, presentSEL);
NSCParameterAssert(origMethod);
class_replaceMethod(UIViewController.class,
presentSEL,
origIMP,
method_getTypeEncoding(origMethod));
};
};
const SEL presentSheetSEL = NSSelectorFromString(@"presentSheetFromRect:");
const void (^swizzleOnClass)(Class k) = ^(Class klass) {
const __block IMP origIMP = JSQReplaceMethodWithBlock(klass, presentSheetSEL, ^(id self, CGRect rect) {
// Before calling the original implementation, we swizzle the presentation logic on UIViewController
installWorkaround();
// UIKit later presents the sheet on [view.window rootViewController];
// See https://github.com/WebKit/webkit/blob/1aceb9ed7a42d0a5ed11558c72bcd57068b642e7/Source/WebKit2/UIProcess/ios/WKActionSheet.mm#L102
// Our workaround forwards this to the topmost presentedViewController instead.
((void (*)(id, SEL, CGRect))origIMP)(self, presentSheetSEL, rect);
// Cleaning up again - this workaround would swallow bugs if we let it be there.
removeWorkaround();
});
};
// _UIRotatingAlertController
Class alertClass = NSClassFromString([NSString stringWithFormat:@"%@%@%@", @"_U", @"IRotat", @"ingAlertController"]);
if (alertClass) {
swizzleOnClass(alertClass);
}
// WKActionSheet
Class actionSheetClass = NSClassFromString([NSString stringWithFormat:@"%@%@%@", @"W", @"KActio", @"nSheet"]);
if (actionSheetClass) {
swizzleOnClass(actionSheetClass);
}
}
static void * kJSQMessagesKeyValueObservingContext = &kJSQMessagesKeyValueObservingContext;
@interface JSQMessagesViewController () <JSQMessagesInputToolbarDelegate,
JSQMessagesKeyboardControllerDelegate>
@property (weak, nonatomic) IBOutlet JSQMessagesCollectionView *collectionView;
@property (weak, nonatomic) IBOutlet JSQMessagesInputToolbar *inputToolbar;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *toolbarHeightConstraint;
@property (weak, nonatomic) IBOutlet NSLayoutConstraint *toolbarBottomLayoutGuide;
@property (weak, nonatomic) UIView *snapshotView;
@property (assign, nonatomic) BOOL jsq_isObserving;
@property (strong, nonatomic) NSIndexPath *selectedIndexPathForMenu;
@property (weak, nonatomic) UIGestureRecognizer *currentInteractivePopGestureRecognizer;
@property (assign, nonatomic) BOOL textViewWasFirstResponderDuringInteractivePop;
@end
@implementation JSQMessagesViewController
#pragma mark - Class methods
+ (UINib *)nib
{
return [UINib nibWithNibName:NSStringFromClass([JSQMessagesViewController class])
bundle:[NSBundle bundleForClass:[JSQMessagesViewController class]]];
}
+ (instancetype)messagesViewController
{
return [[[self class] alloc] initWithNibName:NSStringFromClass([JSQMessagesViewController class])
bundle:[NSBundle bundleForClass:[JSQMessagesViewController class]]];
}
+ (void)initialize {
[super initialize];
if (self == [JSQMessagesViewController self]) {
JSQInstallWorkaroundForSheetPresentationIssue26295020();
}
}
#pragma mark - Initialization
- (void)jsq_configureMessagesViewController
{
self.view.backgroundColor = [UIColor whiteColor];
self.jsq_isObserving = NO;
self.toolbarHeightConstraint.constant = self.inputToolbar.preferredDefaultHeight;
self.collectionView.dataSource = self;
self.collectionView.delegate = self;
self.inputToolbar.delegate = self;
self.inputToolbar.contentView.textView.placeHolder = [NSBundle jsq_localizedStringForKey:@"new_message"];
self.inputToolbar.contentView.textView.accessibilityLabel = [NSBundle jsq_localizedStringForKey:@"new_message"];
self.inputToolbar.contentView.textView.delegate = self;
self.automaticallyScrollsToMostRecentMessage = YES;
self.outgoingCellIdentifier = [JSQMessagesCollectionViewCellOutgoing cellReuseIdentifier];
self.outgoingMediaCellIdentifier = [JSQMessagesCollectionViewCellOutgoing mediaCellReuseIdentifier];
self.incomingCellIdentifier = [JSQMessagesCollectionViewCellIncoming cellReuseIdentifier];
self.incomingMediaCellIdentifier = [JSQMessagesCollectionViewCellIncoming mediaCellReuseIdentifier];
// NOTE: let this behavior be opt-in for now
// [JSQMessagesCollectionViewCell registerMenuAction:@selector(delete:)];
self.showTypingIndicator = NO;
self.showLoadEarlierMessagesHeader = NO;
self.topContentAdditionalInset = 0.0f;
[self jsq_updateCollectionViewInsets];
// Don't set keyboardController if client creates custom content view via -loadToolbarContentView
if (self.inputToolbar.contentView.textView != nil) {
self.keyboardController = [[JSQMessagesKeyboardController alloc] initWithTextView:self.inputToolbar.contentView.textView
contextView:self.view
panGestureRecognizer:self.collectionView.panGestureRecognizer
delegate:self];
}
}
- (void)dealloc
{
[self jsq_registerForNotifications:NO];
[self jsq_removeObservers];
_collectionView.dataSource = nil;
_collectionView.delegate = nil;
_inputToolbar.contentView.textView.delegate = nil;
_inputToolbar.delegate = nil;
[_keyboardController endListeningForKeyboard];
_keyboardController = nil;
}
#pragma mark - Setters
- (void)setShowTypingIndicator:(BOOL)showTypingIndicator
{
if (_showTypingIndicator == showTypingIndicator) {
return;
}
_showTypingIndicator = showTypingIndicator;
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView.collectionViewLayout invalidateLayout];
}
- (void)setShowLoadEarlierMessagesHeader:(BOOL)showLoadEarlierMessagesHeader
{
if (_showLoadEarlierMessagesHeader == showLoadEarlierMessagesHeader) {
return;
}
_showLoadEarlierMessagesHeader = showLoadEarlierMessagesHeader;
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView.collectionViewLayout invalidateLayout];
[self.collectionView reloadData];
}
- (void)setTopContentAdditionalInset:(CGFloat)topContentAdditionalInset
{
_topContentAdditionalInset = topContentAdditionalInset;
[self jsq_updateCollectionViewInsets];
}
#pragma mark - View lifecycle
- (void)viewDidLoad
{
[super viewDidLoad];
[[[self class] nib] instantiateWithOwner:self options:nil];
[self jsq_configureMessagesViewController];
[self jsq_registerForNotifications:YES];
}
- (void)viewWillAppear:(BOOL)animated
{
NSParameterAssert(self.senderId != nil);
NSParameterAssert(self.senderDisplayName != nil);
[super viewWillAppear:animated];
self.toolbarHeightConstraint.constant = self.inputToolbar.preferredDefaultHeight;
[self.view layoutIfNeeded];
[self.collectionView.collectionViewLayout invalidateLayout];
if (self.automaticallyScrollsToMostRecentMessage) {
dispatch_async(dispatch_get_main_queue(), ^{
[self scrollToBottomAnimated:NO];
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
});
}
[self jsq_updateKeyboardTriggerPoint];
}
- (void)viewDidAppear:(BOOL)animated
{
[super viewDidAppear:animated];
[self jsq_addObservers];
[self jsq_addActionToInteractivePopGestureRecognizer:YES];
[self.keyboardController beginListeningForKeyboard];
if ([UIDevice jsq_isCurrentDeviceBeforeiOS8]) {
[self.snapshotView removeFromSuperview];
}
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
self.collectionView.collectionViewLayout.springinessEnabled = NO;
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
[self jsq_addActionToInteractivePopGestureRecognizer:NO];
[self jsq_removeObservers];
[self.keyboardController endListeningForKeyboard];
}
#pragma mark - View rotation
- (BOOL)shouldAutorotate
{
return YES;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
return UIInterfaceOrientationMaskAll;
}
- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
{
[super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration];
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
}
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation
{
[super didRotateFromInterfaceOrientation:fromInterfaceOrientation];
if (self.showTypingIndicator) {
self.showTypingIndicator = NO;
self.showTypingIndicator = YES;
[self.collectionView reloadData];
}
}
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[self jsq_resetLayoutAndCaches];
}
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
[self jsq_resetLayoutAndCaches];
}
- (void)jsq_resetLayoutAndCaches
{
JSQMessagesCollectionViewFlowLayoutInvalidationContext *context = [JSQMessagesCollectionViewFlowLayoutInvalidationContext context];
context.invalidateFlowLayoutMessagesCache = YES;
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:context];
}
#pragma mark - Messages view controller
- (void)didPressSendButton:(UIButton *)button
withMessageText:(NSString *)text
senderId:(NSString *)senderId
senderDisplayName:(NSString *)senderDisplayName
date:(NSDate *)date
{
NSAssert(NO, @"Error! required method not implemented in subclass. Need to implement %s", __PRETTY_FUNCTION__);
}
- (void)didPressAccessoryButton:(UIButton *)sender
{
NSAssert(NO, @"Error! required method not implemented in subclass. Need to implement %s", __PRETTY_FUNCTION__);
}
- (void)finishSendingMessage
{
[self finishSendingMessageAnimated:YES];
}
- (void)finishSendingMessageAnimated:(BOOL)animated {
UITextView *textView = self.inputToolbar.contentView.textView;
textView.text = nil;
[textView.undoManager removeAllActions];
[self.inputToolbar toggleSendButtonEnabled];
[[NSNotificationCenter defaultCenter] postNotificationName:UITextViewTextDidChangeNotification object:textView];
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView reloadData];
if (self.automaticallyScrollsToMostRecentMessage) {
[self scrollToBottomAnimated:animated];
}
}
- (void)finishReceivingMessage
{
[self finishReceivingMessageAnimated:YES];
}
- (void)finishReceivingMessageAnimated:(BOOL)animated {
self.showTypingIndicator = NO;
[self.collectionView.collectionViewLayout invalidateLayoutWithContext:[JSQMessagesCollectionViewFlowLayoutInvalidationContext context]];
[self.collectionView reloadData];
if (self.automaticallyScrollsToMostRecentMessage && ![self jsq_isMenuVisible]) {
[self scrollToBottomAnimated:animated];
}
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, [NSBundle jsq_localizedStringForKey:@"new_message_received_accessibility_announcement"]);
}
- (void)scrollToBottomAnimated:(BOOL)animated
{
if ([self.collectionView numberOfSections] == 0) {
return;
}
NSIndexPath *lastCell = [NSIndexPath indexPathForItem:([self.collectionView numberOfItemsInSection:0] - 1) inSection:0];
[self scrollToIndexPath:lastCell animated:animated];
}
- (void)scrollToIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated
{
if ([self.collectionView numberOfSections] <= indexPath.section) {
return;
}
NSInteger numberOfItems = [self.collectionView numberOfItemsInSection:indexPath.section];
if (numberOfItems == 0) {
return;
}
CGFloat collectionViewContentHeight = [self.collectionView.collectionViewLayout collectionViewContentSize].height;
BOOL isContentTooSmall = (collectionViewContentHeight < CGRectGetHeight(self.collectionView.bounds));
if (isContentTooSmall) {
// workaround for the first few messages not scrolling
// when the collection view content size is too small, `scrollToItemAtIndexPath:` doesn't work properly
// this seems to be a UIKit bug, see #256 on GitHub
[self.collectionView scrollRectToVisible:CGRectMake(0.0, collectionViewContentHeight - 1.0f, 1.0f, 1.0f)
animated:animated];
return;
}
NSInteger item = MAX(MIN(indexPath.item, numberOfItems - 1), 0);
indexPath = [NSIndexPath indexPathForItem:item inSection:0];
// workaround for really long messages not scrolling
// if last message is too long, use scroll position bottom for better appearance, else use top
// possibly a UIKit bug, see #480 on GitHub
CGSize cellSize = [self.collectionView.collectionViewLayout sizeForItemAtIndexPath:indexPath];
CGFloat maxHeightForVisibleMessage = CGRectGetHeight(self.collectionView.bounds)
- self.collectionView.contentInset.top
- self.collectionView.contentInset.bottom
- CGRectGetHeight(self.inputToolbar.bounds);
UICollectionViewScrollPosition scrollPosition = (cellSize.height > maxHeightForVisibleMessage) ? UICollectionViewScrollPositionBottom : UICollectionViewScrollPositionTop;
[self.collectionView scrollToItemAtIndexPath:indexPath
atScrollPosition:scrollPosition
animated:animated];
}
- (BOOL)isOutgoingMessage:(id<JSQMessageData>)messageItem
{
NSString *messageSenderId = [messageItem senderId];
NSParameterAssert(messageSenderId != nil);
return [messageSenderId isEqualToString:self.senderId];
}
#pragma mark - JSQMessages collection view data source
- (id<JSQMessageData>)collectionView:(JSQMessagesCollectionView *)collectionView messageDataForItemAtIndexPath:(NSIndexPath *)indexPath
{
NSAssert(NO, @"ERROR: required method not implemented: %s", __PRETTY_FUNCTION__);
return nil;
}
- (void)collectionView:(JSQMessagesCollectionView *)collectionView didDeleteMessageAtIndexPath:(NSIndexPath *)indexPath
{
NSAssert(NO, @"ERROR: required method not implemented: %s", __PRETTY_FUNCTION__);
}
- (id<JSQMessageBubbleImageDataSource>)collectionView:(JSQMessagesCollectionView *)collectionView messageBubbleImageDataForItemAtIndexPath:(NSIndexPath *)indexPath
{
NSAssert(NO, @"ERROR: required method not implemented: %s", __PRETTY_FUNCTION__);
return nil;
}
- (id<JSQMessageAvatarImageDataSource>)collectionView:(JSQMessagesCollectionView *)collectionView avatarImageDataForItemAtIndexPath:(NSIndexPath *)indexPath
{
NSAssert(NO, @"ERROR: required method not implemented: %s", __PRETTY_FUNCTION__);
return nil;
}
- (NSAttributedString *)collectionView:(JSQMessagesCollectionView *)collectionView attributedTextForCellTopLabelAtIndexPath:(NSIndexPath *)indexPath
{
return nil;
}
- (NSAttributedString *)collectionView:(JSQMessagesCollectionView *)collectionView attributedTextForMessageBubbleTopLabelAtIndexPath:(NSIndexPath *)indexPath
{
return nil;
}
- (NSAttributedString *)collectionView:(JSQMessagesCollectionView *)collectionView attributedTextForCellBottomLabelAtIndexPath:(NSIndexPath *)indexPath
{
return nil;
}
#pragma mark - Collection view data source
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
return 0;
}
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView
{
return 1;
}
- (UICollectionViewCell *)collectionView:(JSQMessagesCollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
id<JSQMessageData> messageItem = [collectionView.dataSource collectionView:collectionView messageDataForItemAtIndexPath:indexPath];
NSParameterAssert(messageItem != nil);
BOOL isOutgoingMessage = [self isOutgoingMessage:messageItem];
BOOL isMediaMessage = [messageItem isMediaMessage];
NSString *cellIdentifier = nil;
if (isMediaMessage) {
cellIdentifier = isOutgoingMessage ? self.outgoingMediaCellIdentifier : self.incomingMediaCellIdentifier;
}
else {
cellIdentifier = isOutgoingMessage ? self.outgoingCellIdentifier : self.incomingCellIdentifier;
}
JSQMessagesCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:cellIdentifier forIndexPath:indexPath];
cell.delegate = collectionView;
if (!isMediaMessage) {
cell.textView.text = [messageItem text];
if ([UIDevice jsq_isCurrentDeviceBeforeiOS8]) {
// workaround for iOS 7 textView data detectors bug
cell.textView.text = nil;
cell.textView.attributedText = [[NSAttributedString alloc] initWithString:[messageItem text]
attributes:@{ NSFontAttributeName : collectionView.collectionViewLayout.messageBubbleFont }];
}
NSParameterAssert(cell.textView.text != nil);
id<JSQMessageBubbleImageDataSource> bubbleImageDataSource = [collectionView.dataSource collectionView:collectionView messageBubbleImageDataForItemAtIndexPath:indexPath];
cell.messageBubbleImageView.image = [bubbleImageDataSource messageBubbleImage];
cell.messageBubbleImageView.highlightedImage = [bubbleImageDataSource messageBubbleHighlightedImage];
}
else {
id<JSQMessageMediaData> messageMedia = [messageItem media];
cell.mediaView = [messageMedia mediaView] ?: [messageMedia mediaPlaceholderView];
NSParameterAssert(cell.mediaView != nil);
}
BOOL needsAvatar = YES;
if (isOutgoingMessage && CGSizeEqualToSize(collectionView.collectionViewLayout.outgoingAvatarViewSize, CGSizeZero)) {
needsAvatar = NO;
}
else if (!isOutgoingMessage && CGSizeEqualToSize(collectionView.collectionViewLayout.incomingAvatarViewSize, CGSizeZero)) {
needsAvatar = NO;
}
id<JSQMessageAvatarImageDataSource> avatarImageDataSource = nil;
if (needsAvatar) {
avatarImageDataSource = [collectionView.dataSource collectionView:collectionView avatarImageDataForItemAtIndexPath:indexPath];
if (avatarImageDataSource != nil) {
UIImage *avatarImage = [avatarImageDataSource avatarImage];
if (avatarImage == nil) {
cell.avatarImageView.image = [avatarImageDataSource avatarPlaceholderImage];
cell.avatarImageView.highlightedImage = nil;
}
else {
cell.avatarImageView.image = avatarImage;
cell.avatarImageView.highlightedImage = [avatarImageDataSource avatarHighlightedImage];
}
}
}
cell.cellTopLabel.attributedText = [collectionView.dataSource collectionView:collectionView attributedTextForCellTopLabelAtIndexPath:indexPath];
cell.messageBubbleTopLabel.attributedText = [collectionView.dataSource collectionView:collectionView attributedTextForMessageBubbleTopLabelAtIndexPath:indexPath];
cell.cellBottomLabel.attributedText = [collectionView.dataSource collectionView:collectionView attributedTextForCellBottomLabelAtIndexPath:indexPath];
CGFloat bubbleTopLabelInset = (avatarImageDataSource != nil) ? 60.0f : 15.0f;
if (isOutgoingMessage) {
cell.messageBubbleTopLabel.textInsets = UIEdgeInsetsMake(0.0f, 0.0f, 0.0f, bubbleTopLabelInset);
}
else {
cell.messageBubbleTopLabel.textInsets = UIEdgeInsetsMake(0.0f, bubbleTopLabelInset, 0.0f, 0.0f);
}
cell.textView.dataDetectorTypes = UIDataDetectorTypeAll;
cell.backgroundColor = [UIColor clearColor];
cell.layer.rasterizationScale = [UIScreen mainScreen].scale;
cell.layer.shouldRasterize = YES;
[self collectionView:collectionView accessibilityForCell:cell indexPath:indexPath message:messageItem];
return cell;
}
- (void)collectionView:(JSQMessagesCollectionView *)collectionView
accessibilityForCell:(JSQMessagesCollectionViewCell*)cell
indexPath:(NSIndexPath *)indexPath
message:(id<JSQMessageData>)messageItem
{
const BOOL isMediaMessage = [messageItem isMediaMessage];
cell.isAccessibilityElement = YES;
if (!isMediaMessage) {
cell.accessibilityLabel = [NSString stringWithFormat:[NSBundle jsq_localizedStringForKey:@"text_message_accessibility_label"],
[messageItem senderDisplayName],
[messageItem text]];
}
else {
cell.accessibilityLabel = [NSString stringWithFormat:[NSBundle jsq_localizedStringForKey:@"media_message_accessibility_label"],
[messageItem senderDisplayName]];
}
}
- (UICollectionReusableView *)collectionView:(JSQMessagesCollectionView *)collectionView
viewForSupplementaryElementOfKind:(NSString *)kind
atIndexPath:(NSIndexPath *)indexPath
{
if (self.showTypingIndicator && [kind isEqualToString:UICollectionElementKindSectionFooter]) {
return [collectionView dequeueTypingIndicatorFooterViewForIndexPath:indexPath];
}
else if (self.showLoadEarlierMessagesHeader && [kind isEqualToString:UICollectionElementKindSectionHeader]) {
return [collectionView dequeueLoadEarlierMessagesViewHeaderForIndexPath:indexPath];
}
return nil;
}
- (CGSize)collectionView:(UICollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section
{
if (!self.showTypingIndicator) {
return CGSizeZero;
}
return CGSizeMake([collectionViewLayout itemWidth], kJSQMessagesTypingIndicatorFooterViewHeight);
}
- (CGSize)collectionView:(UICollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section
{
if (!self.showLoadEarlierMessagesHeader) {
return CGSizeZero;
}
return CGSizeMake([collectionViewLayout itemWidth], kJSQMessagesLoadEarlierHeaderViewHeight);
}
#pragma mark - Collection view delegate
- (BOOL)collectionView:(JSQMessagesCollectionView *)collectionView shouldShowMenuForItemAtIndexPath:(NSIndexPath *)indexPath
{
// disable menu for media messages
id<JSQMessageData> messageItem = [collectionView.dataSource collectionView:collectionView messageDataForItemAtIndexPath:indexPath];
if ([messageItem isMediaMessage]) {
return NO;
}
self.selectedIndexPathForMenu = indexPath;
// textviews are selectable to allow data detectors
// however, this allows the 'copy, define, select' UIMenuController to show
// which conflicts with the collection view's UIMenuController
// temporarily disable 'selectable' to prevent this issue
JSQMessagesCollectionViewCell *selectedCell = (JSQMessagesCollectionViewCell *)[collectionView cellForItemAtIndexPath:indexPath];
selectedCell.textView.selectable = NO;
return YES;
}
- (BOOL)collectionView:(UICollectionView *)collectionView canPerformAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender
{
if (action == @selector(copy:) || action == @selector(delete:)) {
return YES;
}
return NO;
}
- (void)collectionView:(JSQMessagesCollectionView *)collectionView performAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender
{
if (action == @selector(copy:)) {
id<JSQMessageData> messageData = [collectionView.dataSource collectionView:collectionView messageDataForItemAtIndexPath:indexPath];
[[UIPasteboard generalPasteboard] setString:[messageData text]];
}
else if (action == @selector(delete:)) {
[collectionView.dataSource collectionView:collectionView didDeleteMessageAtIndexPath:indexPath];
[collectionView deleteItemsAtIndexPaths:@[indexPath]];
[collectionView.collectionViewLayout invalidateLayout];
}
}
#pragma mark - Collection view delegate flow layout
- (CGSize)collectionView:(JSQMessagesCollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath
{
return [collectionViewLayout sizeForItemAtIndexPath:indexPath];
}
- (CGFloat)collectionView:(JSQMessagesCollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout heightForCellTopLabelAtIndexPath:(NSIndexPath *)indexPath
{
return 0.0f;
}
- (CGFloat)collectionView:(JSQMessagesCollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout heightForMessageBubbleTopLabelAtIndexPath:(NSIndexPath *)indexPath
{
return 0.0f;
}
- (CGFloat)collectionView:(JSQMessagesCollectionView *)collectionView
layout:(JSQMessagesCollectionViewFlowLayout *)collectionViewLayout heightForCellBottomLabelAtIndexPath:(NSIndexPath *)indexPath
{
return 0.0f;
}
- (void)collectionView:(JSQMessagesCollectionView *)collectionView
didTapAvatarImageView:(UIImageView *)avatarImageView
atIndexPath:(NSIndexPath *)indexPath { }
- (void)collectionView:(JSQMessagesCollectionView *)collectionView didTapMessageBubbleAtIndexPath:(NSIndexPath *)indexPath { }
- (void)collectionView:(JSQMessagesCollectionView *)collectionView
didTapCellAtIndexPath:(NSIndexPath *)indexPath
touchLocation:(CGPoint)touchLocation { }
#pragma mark - Input toolbar delegate
- (void)messagesInputToolbar:(JSQMessagesInputToolbar *)toolbar didPressLeftBarButton:(UIButton *)sender
{
if (toolbar.sendButtonOnRight) {
[self didPressAccessoryButton:sender];
}
else {
[self didPressSendButton:sender
withMessageText:[self jsq_currentlyComposedMessageText]
senderId:self.senderId
senderDisplayName:self.senderDisplayName
date:[NSDate date]];
}
}
- (void)messagesInputToolbar:(JSQMessagesInputToolbar *)toolbar didPressRightBarButton:(UIButton *)sender
{
if (toolbar.sendButtonOnRight) {
[self didPressSendButton:sender
withMessageText:[self jsq_currentlyComposedMessageText]
senderId:self.senderId
senderDisplayName:self.senderDisplayName
date:[NSDate date]];
}
else {
[self didPressAccessoryButton:sender];
}
}
- (NSString *)jsq_currentlyComposedMessageText
{
// auto-accept any auto-correct suggestions
[self.inputToolbar.contentView.textView.inputDelegate selectionWillChange:self.inputToolbar.contentView.textView];
[self.inputToolbar.contentView.textView.inputDelegate selectionDidChange:self.inputToolbar.contentView.textView];
return [self.inputToolbar.contentView.textView.text jsq_stringByTrimingWhitespace];
}
#pragma mark - Text view delegate
- (void)textViewDidBeginEditing:(UITextView *)textView
{
if (textView != self.inputToolbar.contentView.textView) {
return;
}
[textView becomeFirstResponder];
if (self.automaticallyScrollsToMostRecentMessage) {
[self scrollToBottomAnimated:YES];
}
}
- (void)textViewDidChange:(UITextView *)textView
{
if (textView != self.inputToolbar.contentView.textView) {
return;
}
[self.inputToolbar toggleSendButtonEnabled];
}
- (void)textViewDidEndEditing:(UITextView *)textView
{
if (textView != self.inputToolbar.contentView.textView) {
return;
}
[textView resignFirstResponder];
}
#pragma mark - Notifications
- (void)jsq_handleDidChangeStatusBarFrameNotification:(NSNotification *)notification
{
if (self.keyboardController.keyboardIsVisible) {
[self jsq_setToolbarBottomLayoutGuideConstant:CGRectGetHeight(self.keyboardController.currentKeyboardFrame)];
}
}
- (void)didReceiveMenuWillShowNotification:(NSNotification *)notification
{
if (!self.selectedIndexPathForMenu) {
return;
}
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIMenuControllerWillShowMenuNotification
object:nil];
UIMenuController *menu = [notification object];
[menu setMenuVisible:NO animated:NO];
JSQMessagesCollectionViewCell *selectedCell = (JSQMessagesCollectionViewCell *)[self.collectionView cellForItemAtIndexPath:self.selectedIndexPathForMenu];
CGRect selectedCellMessageBubbleFrame = [selectedCell convertRect:selectedCell.messageBubbleContainerView.frame toView:self.view];
[menu setTargetRect:selectedCellMessageBubbleFrame inView:self.view];
[menu setMenuVisible:YES animated:YES];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMenuWillShowNotification:)
name:UIMenuControllerWillShowMenuNotification
object:nil];
}
- (void)didReceiveMenuWillHideNotification:(NSNotification *)notification
{
if (!self.selectedIndexPathForMenu) {
return;
}
// per comment above in 'shouldShowMenuForItemAtIndexPath:'
// re-enable 'selectable', thus re-enabling data detectors if present
JSQMessagesCollectionViewCell *selectedCell = (JSQMessagesCollectionViewCell *)[self.collectionView cellForItemAtIndexPath:self.selectedIndexPathForMenu];
selectedCell.textView.selectable = YES;
self.selectedIndexPathForMenu = nil;
}
#pragma mark - Key-value observing
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context == kJSQMessagesKeyValueObservingContext) {
if (object == self.inputToolbar.contentView.textView
&& [keyPath isEqualToString:NSStringFromSelector(@selector(contentSize))]) {
CGSize oldContentSize = [[change objectForKey:NSKeyValueChangeOldKey] CGSizeValue];
CGSize newContentSize = [[change objectForKey:NSKeyValueChangeNewKey] CGSizeValue];
CGFloat dy = newContentSize.height - oldContentSize.height;
[self jsq_adjustInputToolbarForComposerTextViewContentSizeChange:dy];
[self jsq_updateCollectionViewInsets];
if (self.automaticallyScrollsToMostRecentMessage) {
[self scrollToBottomAnimated:NO];
}
}
}
}
#pragma mark - Keyboard controller delegate
- (void)keyboardController:(JSQMessagesKeyboardController *)keyboardController keyboardDidChangeFrame:(CGRect)keyboardFrame
{
if (![self.inputToolbar.contentView.textView isFirstResponder] && self.toolbarBottomLayoutGuide.constant == 0.0) {
return;
}
CGFloat heightFromBottom = CGRectGetMaxY(self.collectionView.frame) - CGRectGetMinY(keyboardFrame);
heightFromBottom = MAX(0.0, heightFromBottom);
[self jsq_setToolbarBottomLayoutGuideConstant:heightFromBottom];
}
- (void)jsq_setToolbarBottomLayoutGuideConstant:(CGFloat)constant
{
self.toolbarBottomLayoutGuide.constant = constant;
[self.view setNeedsUpdateConstraints];
[self.view layoutIfNeeded];
[self jsq_updateCollectionViewInsets];
}
- (void)jsq_updateKeyboardTriggerPoint
{
self.keyboardController.keyboardTriggerPoint = CGPointMake(0.0f, CGRectGetHeight(self.inputToolbar.bounds));
}
#pragma mark - Gesture recognizers
- (void)jsq_handleInteractivePopGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
{
switch (gestureRecognizer.state) {
case UIGestureRecognizerStateBegan:
{
if ([UIDevice jsq_isCurrentDeviceBeforeiOS8]) {
[self.snapshotView removeFromSuperview];
}
self.textViewWasFirstResponderDuringInteractivePop = [self.inputToolbar.contentView.textView isFirstResponder];
[self.keyboardController endListeningForKeyboard];
if ([UIDevice jsq_isCurrentDeviceBeforeiOS8]) {
[self.inputToolbar.contentView.textView resignFirstResponder];
[UIView animateWithDuration:0.0
animations:^{
[self jsq_setToolbarBottomLayoutGuideConstant:0.0];
}];
UIView *snapshot = [self.view snapshotViewAfterScreenUpdates:YES];
[self.view addSubview:snapshot];
self.snapshotView = snapshot;
}
}
break;
case UIGestureRecognizerStateChanged:
break;
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateEnded:
case UIGestureRecognizerStateFailed:
[self.keyboardController beginListeningForKeyboard];
if (self.textViewWasFirstResponderDuringInteractivePop) {
[self.inputToolbar.contentView.textView becomeFirstResponder];
}
if ([UIDevice jsq_isCurrentDeviceBeforeiOS8]) {
[self.snapshotView removeFromSuperview];
}
break;
default:
break;
}
}
#pragma mark - Input toolbar utilities
- (BOOL)jsq_inputToolbarHasReachedMaximumHeight
{
return CGRectGetMinY(self.inputToolbar.frame) == (self.topLayoutGuide.length + self.topContentAdditionalInset);
}
- (void)jsq_adjustInputToolbarForComposerTextViewContentSizeChange:(CGFloat)dy
{
BOOL contentSizeIsIncreasing = (dy > 0);
if ([self jsq_inputToolbarHasReachedMaximumHeight]) {
BOOL contentOffsetIsPositive = (self.inputToolbar.contentView.textView.contentOffset.y > 0);
if (contentSizeIsIncreasing || contentOffsetIsPositive) {
[self jsq_scrollComposerTextViewToBottomAnimated:YES];
return;
}
}
CGFloat toolbarOriginY = CGRectGetMinY(self.inputToolbar.frame);
CGFloat newToolbarOriginY = toolbarOriginY - dy;
// attempted to increase origin.Y above topLayoutGuide
if (newToolbarOriginY <= self.topLayoutGuide.length + self.topContentAdditionalInset) {
dy = toolbarOriginY - (self.topLayoutGuide.length + self.topContentAdditionalInset);
[self jsq_scrollComposerTextViewToBottomAnimated:YES];
}
[self jsq_adjustInputToolbarHeightConstraintByDelta:dy];
[self jsq_updateKeyboardTriggerPoint];
if (dy < 0) {
[self jsq_scrollComposerTextViewToBottomAnimated:NO];
}
}
- (void)jsq_adjustInputToolbarHeightConstraintByDelta:(CGFloat)dy
{
CGFloat proposedHeight = self.toolbarHeightConstraint.constant + dy;
CGFloat finalHeight = MAX(proposedHeight, self.inputToolbar.preferredDefaultHeight);
if (self.inputToolbar.maximumHeight != NSNotFound) {
finalHeight = MIN(finalHeight, self.inputToolbar.maximumHeight);
}
if (self.toolbarHeightConstraint.constant != finalHeight) {
self.toolbarHeightConstraint.constant = finalHeight;
[self.view setNeedsUpdateConstraints];
[self.view layoutIfNeeded];
}
}
- (void)jsq_scrollComposerTextViewToBottomAnimated:(BOOL)animated
{
UITextView *textView = self.inputToolbar.contentView.textView;
CGPoint contentOffsetToShowLastLine = CGPointMake(0.0f, textView.contentSize.height - CGRectGetHeight(textView.bounds));
if (!animated) {
textView.contentOffset = contentOffsetToShowLastLine;
return;
}
[UIView animateWithDuration:0.01
delay:0.01
options:UIViewAnimationOptionCurveLinear
animations:^{
textView.contentOffset = contentOffsetToShowLastLine;
}
completion:nil];
}
#pragma mark - Collection view utilities
- (void)jsq_updateCollectionViewInsets
{
[self jsq_setCollectionViewInsetsTopValue:self.topLayoutGuide.length + self.topContentAdditionalInset
bottomValue:CGRectGetMaxY(self.collectionView.frame) - CGRectGetMinY(self.inputToolbar.frame)];
}
- (void)jsq_setCollectionViewInsetsTopValue:(CGFloat)top bottomValue:(CGFloat)bottom
{
UIEdgeInsets insets = UIEdgeInsetsMake(top, 0.0f, bottom, 0.0f);
self.collectionView.contentInset = insets;
self.collectionView.scrollIndicatorInsets = insets;
}
- (BOOL)jsq_isMenuVisible
{
// check if cell copy menu is showing
// it is only our menu if `selectedIndexPathForMenu` is not `nil`
return self.selectedIndexPathForMenu != nil && [[UIMenuController sharedMenuController] isMenuVisible];
}
#pragma mark - Utilities
- (void)jsq_addObservers
{
if (self.jsq_isObserving) {
return;
}
[self.inputToolbar.contentView.textView addObserver:self
forKeyPath:NSStringFromSelector(@selector(contentSize))
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:kJSQMessagesKeyValueObservingContext];
self.jsq_isObserving = YES;
}
- (void)jsq_removeObservers
{
if (!_jsq_isObserving) {
return;
}
@try {
[_inputToolbar.contentView.textView removeObserver:self
forKeyPath:NSStringFromSelector(@selector(contentSize))
context:kJSQMessagesKeyValueObservingContext];
}
@catch (NSException * __unused exception) { }
_jsq_isObserving = NO;
}
- (void)jsq_registerForNotifications:(BOOL)registerForNotifications
{
if (registerForNotifications) {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(jsq_handleDidChangeStatusBarFrameNotification:)
name:UIApplicationDidChangeStatusBarFrameNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMenuWillShowNotification:)
name:UIMenuControllerWillShowMenuNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(didReceiveMenuWillHideNotification:)
name:UIMenuControllerWillHideMenuNotification
object:nil];
}
else {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIApplicationDidChangeStatusBarFrameNotification
object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIMenuControllerWillShowMenuNotification
object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UIMenuControllerWillHideMenuNotification
object:nil];
}
}
- (void)jsq_addActionToInteractivePopGestureRecognizer:(BOOL)addAction
{
if (self.currentInteractivePopGestureRecognizer != nil) {
[self.currentInteractivePopGestureRecognizer removeTarget:nil
action:@selector(jsq_handleInteractivePopGestureRecognizer:)];
self.currentInteractivePopGestureRecognizer = nil;
}
if (addAction) {
[self.navigationController.interactivePopGestureRecognizer addTarget:self
action:@selector(jsq_handleInteractivePopGestureRecognizer:)];
self.currentInteractivePopGestureRecognizer = self.navigationController.interactivePopGestureRecognizer;
}
}
@end