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