// // 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 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)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)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)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)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)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