session-ios/Signal/src/Models/OWSMessagesBubblesSizeCalculator.m
2017-03-29 14:34:21 -04:00

243 lines
11 KiB
Objective-C

//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "OWSMessagesBubblesSizeCalculator.h"
#import "OWSCall.h"
#import "OWSDisplayedMessageCollectionViewCell.h"
#import "TSGenericAttachmentAdapter.h"
#import "TSMessageAdapter.h"
#import "UIFont+OWS.h"
#import "tgmath.h" // generic math allows fmax to handle CGFLoat correctly on 32 & 64bit.
#import <JSQMessagesViewController/JSQMessagesCollectionViewFlowLayout.h>
NS_ASSUME_NONNULL_BEGIN
/**
* We use some private method to size our info messages.
*/
@interface OWSMessagesBubblesSizeCalculator (JSQPrivateMethods)
@property (strong, nonatomic, readonly) NSCache *cache;
@property (assign, nonatomic, readonly) NSUInteger minimumBubbleWidth;
@property (assign, nonatomic, readonly) BOOL usesFixedWidthBubbles;
@property (assign, nonatomic, readonly) NSInteger additionalInset;
@property (assign, nonatomic) CGFloat layoutWidthForFixedWidthBubbles;
- (CGSize)jsq_avatarSizeForMessageData:(id<JSQMessageData>)messageData
withLayout:(JSQMessagesCollectionViewFlowLayout *)layout;
- (CGFloat)textBubbleWidthForLayout:(JSQMessagesCollectionViewFlowLayout *)layout;
@end
@implementation OWSMessagesBubblesSizeCalculator
/**
* Computes and returns the size of the `messageBubbleImageView` property
* of a `JSQMessagesCollectionViewCell` for the specified messageData at indexPath.
*
* @param messageData A message data object.
* @param indexPath The index path at which messageData is located.
* @param layout The layout object asking for this information.
*
* @return A sizes that specifies the required dimensions to display the entire message contents.
* Note, this is *not* the entire cell, but only its message bubble.
*/
- (CGSize)messageBubbleSizeForMessageData:(id<JSQMessageData>)messageData
atIndexPath:(NSIndexPath *)indexPath
withLayout:(JSQMessagesCollectionViewFlowLayout *)layout
{
if ([messageData isKindOfClass:[TSMessageAdapter class]]) {
TSMessageAdapter *message = (TSMessageAdapter *)messageData;
if (message.messageType == TSInfoMessageAdapter || message.messageType == TSErrorMessageAdapter) {
return [self messageBubbleSizeForInfoMessageData:messageData atIndexPath:indexPath withLayout:layout];
}
}
if ([messageData isKindOfClass:[OWSCall class]]) {
return [self messageBubbleSizeForCallData:messageData atIndexPath:indexPath withLayout:layout];
}
// BEGIN HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368
if ([self shouldApplyiOS10EmojiFixToString:messageData.text font:layout.messageBubbleFont]) {
return [self withiOS10EmojiFixSuperMessageBubbleSizeForMessageData:messageData
atIndexPath:indexPath
withLayout:layout];
} else {
// END HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368
return [super messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout];
}
}
/**
* Emoji sizing bug only affects iOS10. Unfortunately the "fix" for emoji font breaks some other fonts, so it's
* important
* to only apply it when emoji is actually present.
*/
- (BOOL)shouldApplyiOS10EmojiFixToString:(NSString *)string font:(UIFont *)font
{
if (!string) {
return NO;
}
BOOL isIOS10OrGreater =
[[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){.majorVersion = 10 }];
if (!isIOS10OrGreater) {
return NO;
}
__block BOOL foundEmoji = NO;
NSDictionary *attributes = @{ NSFontAttributeName : font };
NSMutableAttributedString *attributedString =
[[NSMutableAttributedString alloc] initWithString:string attributes:attributes];
[attributedString fixAttributesInRange:NSMakeRange(0, string.length)];
[attributedString enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, string.length)
options:0
usingBlock:^(id _Nullable value, NSRange range, BOOL *_Nonnull stop) {
UIFont *rangeFont = (UIFont *)value;
if ([rangeFont.fontName isEqualToString:@".AppleColorEmojiUI"]) {
DDLogVerbose(@"Detected Emoji at location: %lu, for length: %lu",
(unsigned long)range.location,
(unsigned long)range.length);
foundEmoji = YES;
*stop = YES;
}
}];
return foundEmoji;
}
/**
* HACK iOS10EmojiBug see: https://github.com/WhisperSystems/Signal-iOS/issues/1368
* As of iOS10.0 the UIEmoji font doesn't present proper line heights. In some cases this causes the last line in a
* message to get cropped off.
*/
- (CGSize)withiOS10EmojiFixSuperMessageBubbleSizeForMessageData:(id<JSQMessageData>)messageData
atIndexPath:(NSIndexPath *)indexPath
withLayout:(JSQMessagesCollectionViewFlowLayout *)layout
{
UIFont *emojiFont = [UIFont fontWithName:@".AppleColorEmojiUI" size:layout.messageBubbleFont.pointSize];
CGSize superSize = [super messageBubbleSizeForMessageData:messageData atIndexPath:indexPath withLayout:layout];
int lines = (int)floor(superSize.height / emojiFont.lineHeight);
// Add an extra pixel per line to fit the emoji.
// This is a crappy solution. Long messages with only one line of emoji will have an extra pixel per line.
return CGSizeMake(superSize.width, superSize.height + (CGFloat)1.5 * lines);
}
- (CGSize)messageBubbleSizeForInfoMessageData:(id<JSQMessageData>)messageData
atIndexPath:(NSIndexPath *)indexPath
withLayout:(JSQMessagesCollectionViewFlowLayout *)layout
{
NSValue *cachedSize = [self.cache objectForKey:@([messageData messageHash])];
if (cachedSize != nil) {
return [cachedSize CGSizeValue];
}
CGSize finalSize = CGSizeZero;
if ([messageData isMediaMessage]) {
finalSize = [[messageData media] mediaViewDisplaySize];
} else {
///////////////////
// BEGIN InfoMessage sizing HACK
// Braindead, and painstakingly produced.
// If you want to change, check for clipping / excess space on 1, 2, and 3 line messages with short and long
// words very near the edge.
// CGSize avatarSize = [self jsq_avatarSizeForMessageData:messageData withLayout:layout];
// // from the cell xibs, there is a 2 point space between avatar and bubble
// CGFloat spacingBetweenAvatarAndBubble = 2.0f;
// CGFloat horizontalContainerInsets = layout.messageBubbleTextViewTextContainerInsets.left + layout.messageBubbleTextViewTextContainerInsets.right;
// CGFloat horizontalFrameInsets = layout.messageBubbleTextViewFrameInsets.left + layout.messageBubbleTextViewFrameInsets.right;
// CGFloat horizontalInsetsTotal = horizontalContainerInsets + horizontalFrameInsets + spacingBetweenAvatarAndBubble;
// CGFloat maximumTextWidth = [self textBubbleWidthForLayout:layout] - avatarSize.width - layout.messageBubbleLeftRightMargin - horizontalInsetsTotal;
// The full layout width, less the textView margins from xib.
// CGFloat horizontalInsetsTotal = 12.0; cropped 3rd line
CGFloat horizontalInsetsTotal = 50.0;
CGFloat maximumTextWidth = [self textBubbleWidthForLayout:layout] - horizontalInsetsTotal;
CGRect stringRect = [[messageData text]
boundingRectWithSize:CGSizeMake(maximumTextWidth, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading)
attributes:@{
NSFontAttributeName : [UIFont ows_dynamicTypeBodyFont]
} // Hack to use a slightly larger than actual font, because I'm seeing messages with higher line count get clipped.
context:nil];
// END InfoMessage sizing HACK
////////////////////
CGSize stringSize = CGRectIntegral(stringRect).size;
CGFloat verticalContainerInsets = layout.messageBubbleTextViewTextContainerInsets.top
+ layout.messageBubbleTextViewTextContainerInsets.bottom;
CGFloat verticalFrameInsets
= layout.messageBubbleTextViewFrameInsets.top + layout.messageBubbleTextViewFrameInsets.bottom;
///////////////////
// BEGIN InfoMessage sizing HACK
CGFloat topIconPortrusion = 28;
verticalFrameInsets += topIconPortrusion;
// END InfoMessage sizing HACK
///////////////////
// add extra 2 points of space (`self.additionalInset`), because `boundingRectWithSize:` is slightly off
// not sure why. magix. (shrug) if you know, submit a PR
CGFloat verticalInsets = verticalContainerInsets + verticalFrameInsets + self.additionalInset;
// same as above, an extra 2 points of magix
CGFloat finalWidth
= MAX(stringSize.width + horizontalInsetsTotal, self.minimumBubbleWidth) + self.additionalInset;
finalSize = CGSizeMake(finalWidth, stringSize.height + verticalInsets);
}
[self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:@([messageData messageHash])];
return finalSize;
}
- (CGSize)messageBubbleSizeForCallData:(id<JSQMessageData>)messageData
atIndexPath:(NSIndexPath *)indexPath
withLayout:(JSQMessagesCollectionViewFlowLayout *)layout
{
NSValue *cachedSize = [self.cache objectForKey:@([messageData messageHash])];
if (cachedSize != nil) {
return [cachedSize CGSizeValue];
}
CGFloat horizontalInsetsTotal = 0.0;
CGFloat maximumTextWidth = [self textBubbleWidthForLayout:layout] - horizontalInsetsTotal;
CGRect stringRect = [[messageData text]
boundingRectWithSize:CGSizeMake(maximumTextWidth, CGFLOAT_MAX)
options:(NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading)
attributes:@{
NSFontAttributeName : [UIFont ows_dynamicTypeBodyFont]
} // Hack to use a slightly larger than actual font, because I'm seeing messages with higher line
// count get clipped.
context:nil];
CGSize stringSize = CGRectIntegral(stringRect).size;
CGFloat verticalInsets = 0;
CGFloat finalWidth = maximumTextWidth + horizontalInsetsTotal;
CGSize finalSize = CGSizeMake(finalWidth, stringSize.height + verticalInsets);
[self.cache setObject:[NSValue valueWithCGSize:finalSize] forKey:@([messageData messageHash])];
return finalSize;
}
@end
NS_ASSUME_NONNULL_END