| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #import "ios/chrome/browser/shared/ui/elements/fade_truncating_label.h" |
| |
| #import <CoreText/CoreText.h> |
| |
| #import <algorithm> |
| |
| #import "base/i18n/rtl.h" |
| #import "base/notreached.h" |
| #import "base/numerics/safe_conversions.h" |
| #import "base/strings/sys_string_conversions.h" |
| #import "ios/chrome/browser/shared/public/features/features.h" |
| #import "ios/chrome/browser/shared/ui/elements/fade_truncating_label+Testing.h" |
| #import "ios/chrome/browser/shared/ui/util/attributed_string_util.h" |
| |
| /// The edges where the gradient is applied. |
| enum class GradientEdge { |
| kLeft, ///< Left edge. |
| kRight, ///< Right edge. |
| }; |
| |
| namespace { |
| |
| /// Creates a gradient opacity mask based on direction of `truncate_mode` for |
| /// `rect`. |
| UIImage* CreateLinearGradient(CGRect rect, GradientEdge gradient_edge) { |
| // Create an opaque context. |
| CGColorSpaceRef color_space = CGColorSpaceCreateDeviceGray(); |
| CGContextRef context = CGBitmapContextCreate( |
| nullptr, rect.size.width, rect.size.height, 8, 4 * rect.size.width, |
| color_space, kCGImageAlphaNone); |
| |
| // White background will mask opaque, black gradient will mask transparent. |
| CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor); |
| CGContextFillRect(context, rect); |
| |
| // Create gradient from white to black. |
| CGFloat locs[2] = {0.0f, 1.0f}; |
| CGFloat components[4] = {1.0f, 1.0f, 0.0f, 1.0f}; |
| CGGradientRef gradient = |
| CGGradientCreateWithColorComponents(color_space, components, locs, 2); |
| CGColorSpaceRelease(color_space); |
| |
| // Draw head and/or tail gradient. |
| CGFloat fade_width = |
| std::min(rect.size.height * 2, (CGFloat)floor(rect.size.width / 4)); |
| CGFloat minX = CGRectGetMinX(rect); |
| CGFloat maxX = CGRectGetMaxX(rect); |
| switch (gradient_edge) { |
| case GradientEdge::kLeft: { |
| CGFloat start_x = minX + fade_width; |
| CGPoint start_point = CGPointMake(start_x, CGRectGetMidY(rect)); |
| CGPoint end_point = CGPointMake(minX, CGRectGetMidY(rect)); |
| CGContextDrawLinearGradient(context, gradient, start_point, end_point, 0); |
| break; |
| } |
| case GradientEdge::kRight: { |
| CGFloat start_x = maxX - fade_width; |
| CGPoint start_point = CGPointMake(start_x, CGRectGetMidY(rect)); |
| CGPoint end_point = CGPointMake(maxX, CGRectGetMidY(rect)); |
| CGContextDrawLinearGradient(context, gradient, start_point, end_point, 0); |
| break; |
| } |
| } |
| CGGradientRelease(gradient); |
| |
| // Clean up, return image. |
| CGImageRef ref = CGBitmapContextCreateImage(context); |
| UIImage* image = [UIImage imageWithCGImage:ref]; |
| CGImageRelease(ref); |
| CGContextRelease(context); |
| return image; |
| } |
| |
| /// Returns the substring ranges to draw `attributed_string` with lines of |
| /// `limited_width`. |
| NSArray<NSValue*>* StringRangeInLines(NSAttributedString* attributed_string, |
| CGFloat limited_width) { |
| NSMutableArray<NSValue*>* line_ranges = [[NSMutableArray alloc] init]; |
| CTFramesetterRef frame_setter = CTFramesetterCreateWithAttributedString( |
| (CFAttributedStringRef)attributed_string); |
| UIBezierPath* path = [UIBezierPath |
| bezierPathWithRect:CGRectMake(0, 0, limited_width, FLT_MAX)]; |
| CTFrameRef frame = CTFramesetterCreateFrame(frame_setter, CFRangeMake(0, 0), |
| path.CGPath, NULL); |
| NSArray* lines = CFBridgingRelease(CTFrameGetLines(frame)); |
| for (id line in lines) { |
| CTLineRef line_ref = (__bridge CTLineRef)line; |
| CFRange line_range = CTLineGetStringRange(line_ref); |
| NSRange range = NSMakeRange(line_range.location, line_range.length); |
| [line_ranges addObject:[NSValue valueWithRange:range]]; |
| } |
| CFRelease(frame_setter); |
| return line_ranges; |
| } |
| |
| } // namespace |
| |
| @interface FadeTruncatingLabel () |
| |
| // Gradient used to create fade effect. Changes based on view.frame size. |
| @property(nonatomic, strong) UIImage* gradient; |
| |
| @end |
| |
| @implementation FadeTruncatingLabel { |
| /// The edge where the gradient is applied. |
| GradientEdge _gradientEdge; |
| /// Current text direction. |
| base::i18n::TextDirection _textDirection; |
| } |
| |
| - (void)setup { |
| self.backgroundColor = [UIColor clearColor]; |
| } |
| |
| - (id)initWithFrame:(CGRect)frame { |
| self = [super initWithFrame:frame]; |
| if (self) { |
| self.lineBreakMode = NSLineBreakByClipping; |
| self.lineSpacing = 0; |
| _gradientEdge = GradientEdge::kRight; |
| [self setup]; |
| } |
| return self; |
| } |
| |
| - (void)awakeFromNib { |
| [super awakeFromNib]; |
| [self setup]; |
| } |
| |
| - (void)layoutSubviews { |
| [super layoutSubviews]; |
| |
| // Cache the fade gradient when the bounds change. |
| if (!CGRectIsEmpty(self.bounds) && |
| (!self.gradient || |
| !CGSizeEqualToSize([self.gradient size], self.bounds.size))) { |
| const CGRect rect = |
| CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height); |
| self.gradient = CreateLinearGradient(rect, _gradientEdge); |
| } |
| } |
| |
| - (void)setText:(NSString*)text { |
| [super setText:text]; |
| [self updateTextDirection]; |
| } |
| |
| - (void)setAttributedText:(NSAttributedString*)attributedText { |
| [super setAttributedText:attributedText]; |
| [self updateTextDirection]; |
| } |
| |
| - (void)setTextAlignmentFollowsTextDirection: |
| (BOOL)textAlignmentFollowsTextDirection { |
| _textAlignmentFollowsTextDirection = textAlignmentFollowsTextDirection; |
| if (_textAlignmentFollowsTextDirection) { |
| if (_textDirection == base::i18n::RIGHT_TO_LEFT) { |
| self.textAlignment = NSTextAlignmentRight; |
| } else { |
| self.textAlignment = NSTextAlignmentLeft; |
| } |
| } else { |
| self.textAlignment = NSTextAlignmentNatural; |
| } |
| } |
| |
| #pragma mark - Private |
| |
| /// Updates the text direction and invalidate the gradient if needed. |
| - (void)updateTextDirection { |
| base::i18n::TextDirection textDirection = |
| base::i18n::GetStringDirection(base::SysNSStringToUTF16(self.text)); |
| if (textDirection != _textDirection) { |
| _gradientEdge = textDirection == base::i18n::RIGHT_TO_LEFT |
| ? GradientEdge::kLeft |
| : GradientEdge::kRight; |
| self.gradient = nil; |
| if (self.textAlignmentFollowsTextDirection) { |
| if (textDirection == base::i18n::RIGHT_TO_LEFT) { |
| self.textAlignment = NSTextAlignmentRight; |
| } else { |
| self.textAlignment = NSTextAlignmentLeft; |
| } |
| } |
| } |
| _textDirection = textDirection; |
| } |
| |
| #pragma mark - Text Drawing |
| |
| /// Draws `attributedText` with a maximum of `numberOfLines` lines in |
| /// `requestedRect`. |
| - (void)drawTextInRect:(CGRect)requestedRect { |
| const CGFloat lineHeight = self.font.lineHeight; |
| if (!lineHeight || !self.attributedText || CGRectIsEmpty(requestedRect)) { |
| return; |
| } |
| |
| // Force NSLineBreakByWordWrapping to be able to draw multiple lines. |
| NSAttributedString* wrappingString = |
| [self attributedString:self.attributedText |
| withLineBreakMode:NSLineBreakByWordWrapping]; |
| |
| NSArray<NSValue*>* stringRangeForLines = |
| StringRangeInLines(wrappingString, requestedRect.size.width); |
| |
| // Like UILabel, always draw a minimum of one line even if there is not enough |
| // vertical space. |
| NSInteger availableLineCount = |
| MAX(1, floor(requestedRect.size.height / lineHeight)); |
| |
| const NSInteger maxAvailableLineCount = |
| self.numberOfLines ? self.numberOfLines : INT_MAX; |
| availableLineCount = MIN(availableLineCount, maxAvailableLineCount); |
| |
| const NSInteger stringLineCount = |
| base::checked_cast<NSInteger>(stringRangeForLines.count); |
| |
| const BOOL applyGradient = availableLineCount < stringLineCount; |
| |
| const NSInteger lineCount = MIN(availableLineCount, stringLineCount); |
| if (lineCount <= 0) { |
| return; |
| } |
| |
| const CGFloat lineSpacing = self.lineSpacing; |
| const CGFloat totalLineSpacing = MAX(lineCount - 1, 0) * lineSpacing; |
| // Offset to vertical center the text. |
| const CGFloat verticalOffset = |
| (requestedRect.size.height - lineCount * lineHeight - totalLineSpacing) / |
| 2; |
| const NSInteger lastLine = lineCount - 1; |
| |
| /* Draw every line before last line. */ |
| for (int i = 0; i < lastLine; ++i) { |
| const CGRect lineRect = |
| CGRectMake(requestedRect.origin.x, |
| requestedRect.origin.y + i * (lineHeight + lineSpacing) + |
| verticalOffset, |
| requestedRect.size.width, lineHeight); |
| const NSRange stringRange = stringRangeForLines[i].rangeValue; |
| NSAttributedString* subString = |
| [wrappingString attributedSubstringFromRange:stringRange]; |
| [self drawAttributedString:subString |
| inRect:lineRect |
| applyGradient:NO |
| alignmentOffset:0.0]; |
| } |
| |
| /* Draw last line. */ |
| const CGRect lastLineRect = |
| CGRectMake(requestedRect.origin.x, |
| requestedRect.origin.y + |
| lastLine * (lineHeight + lineSpacing) + verticalOffset, |
| requestedRect.size.width, lineHeight); |
| // Last line takes all the remaining text, from start of last line to end of |
| // `attributedText`. |
| const NSRange lastLineRange = |
| NSMakeRange(stringRangeForLines[lastLine].rangeValue.location, |
| wrappingString.length - |
| stringRangeForLines[lastLine].rangeValue.location); |
| NSAttributedString* lastLineString = |
| [wrappingString attributedSubstringFromRange:lastLineRange]; |
| // Last line is clipped instead of wrapped. |
| lastLineString = [self attributedString:lastLineString |
| withLineBreakMode:NSLineBreakByClipping]; |
| const CGFloat rtlOffset = |
| _textDirection == base::i18n::RIGHT_TO_LEFT |
| ? MAX(lastLineString.size.width - lastLineRect.size.width, 0) |
| : 0.0; |
| [self drawAttributedString:lastLineString |
| inRect:lastLineRect |
| applyGradient:applyGradient |
| alignmentOffset:rtlOffset]; |
| } |
| |
| /// Computes the bounding rect necessary to draw text in `bounds` limited to |
| /// `numberOfLines`. |
| - (CGRect)textRectForBounds:(CGRect)bounds |
| limitedToNumberOfLines:(NSInteger)numberOfLines { |
| NSInteger maxNumberOfLines = numberOfLines ? numberOfLines : INT_MAX; |
| // Force NSLineBreakByWordWrapping to be able to draw multiple lines. |
| NSAttributedString* wrappingString = |
| [self attributedString:self.attributedText |
| withLineBreakMode:NSLineBreakByWordWrapping]; |
| // Compute the number of lines needed to draw the string with limited width. |
| const CGSize wrappingStringSize = |
| [wrappingString boundingRectWithSize:CGSizeMake(bounds.size.width, 0) |
| options:NSStringDrawingUsesLineFragmentOrigin |
| context:nil] |
| .size; |
| |
| const CGSize singleLineStringSize = wrappingString.size; |
| const NSInteger wrappingStringNumberOfLines = |
| round(wrappingStringSize.height / singleLineStringSize.height); |
| const NSInteger numberOfLinesToDraw = |
| MIN(maxNumberOfLines, wrappingStringNumberOfLines); |
| const CGFloat totalLineSpacing = |
| MAX((numberOfLinesToDraw - 1), 0) * self.lineSpacing; |
| |
| const CGFloat boundingWidth = |
| MIN(ceil(singleLineStringSize.width), bounds.size.width); |
| CGFloat boundingHeight = ceil( |
| singleLineStringSize.height * numberOfLinesToDraw + totalLineSpacing); |
| boundingHeight = MIN(boundingHeight, bounds.size.height); |
| const CGRect boundingRect = CGRectMake(bounds.origin.x, bounds.origin.y, |
| boundingWidth, boundingHeight); |
| return boundingRect; |
| } |
| |
| #pragma mark Text Drawing Private |
| |
| /// Draws `attributedString` in `requestedRect`. |
| /// `applyGradient`: Whether gradient should be applied when drawing the text. |
| /// `alignmentOffset`: offset added to draw the text on the left of |
| /// `requestedRect`. Note: with NSLineBreakByClipping the text is always clipped |
| /// to the right even when the text is aligned to the right, with the offset the |
| /// text starts to draw on the left of `requestedRect`, this allow the text to |
| /// end inside of `requestedRect` clipping it on the left. |
| - (void)drawAttributedString:(NSAttributedString*)attributedString |
| inRect:(CGRect)requestedRect |
| applyGradient:(BOOL)applyGradient |
| alignmentOffset:(CGFloat)alignmentOffset { |
| CGContextRef context = UIGraphicsGetCurrentContext(); |
| CGContextSaveGState(context); |
| |
| if (applyGradient) { |
| CGContextClipToMask(context, requestedRect, [self.gradient CGImage]); |
| } |
| |
| CGRect drawingRect = requestedRect; |
| if (alignmentOffset != 0) { |
| drawingRect = CGRectMake( |
| requestedRect.origin.x - alignmentOffset, requestedRect.origin.y, |
| requestedRect.size.width + alignmentOffset, requestedRect.size.height); |
| } |
| [attributedString drawInRect:drawingRect]; |
| |
| CGContextRestoreGState(context); |
| } |
| |
| #pragma mark - Private methods |
| |
| /// Adds specified attributes to a copy of `attributedString` and sets line |
| /// break mode to `lineBreakMode`. |
| - (NSAttributedString*)attributedString:(NSAttributedString*)attributedString |
| withLineBreakMode:(NSLineBreakMode)lineBreakMode { |
| // URLs have their text direction set to to LTR (avoids RTL characters |
| // making the URL render from right to left, as per RFC 3987 Section 4.1). |
| return AttributedStringCopyWithAttributes( |
| attributedString, lineBreakMode, self.textAlignment, |
| /*force_left_to_right=*/self.displayAsURL); |
| } |
| |
| @end |