adjust font size to make text fit within constrained size

# Conflicts:
#	AsyncDisplayKit/ASTextNode.mm
This commit is contained in:
rcancro 2016-02-12 11:11:06 -08:00
parent a4789f3524
commit a920e353c6
8 changed files with 284 additions and 129 deletions

View File

@ -10,9 +10,9 @@
@interface ASTextNode ()
/**
@abstract The minimum scale that the textnode can apply to fit long words.
@default 0 (No scaling)
@abstract An array of descending scale factors that will be applied to this text node to try to make it fit within its constrained size
@default nil (no scaling)
*/
@property (nonatomic, assign) CGFloat minimumScaleFactor;
@property (nonatomic, copy) NSArray *pointSizeScaleFactors;
@end

View File

@ -18,6 +18,7 @@
#import "ASTextKitCoreTextAdditions.h"
#import "ASTextKitHelpers.h"
#import "ASTextKitFontSizeAdjuster.h"
#import "ASTextKitRenderer.h"
#import "ASTextKitRenderer+Positioning.h"
#import "ASTextKitShadower.h"
@ -78,6 +79,7 @@ static NSString *ASTextNodeTruncationTokenAttributeName = @"ASTextNodeTruncation
CGSize _constrainedSize;
ASTextKitRenderer *_renderer;
CGFloat _currentScaleFactor;
UILongPressGestureRecognizer *_longPressGestureRecognizer;
}
@ -242,7 +244,8 @@ static NSArray *DefaultLinkAttributeNames = @[ NSLinkAttributeName ];
.lineBreakMode = _truncationMode,
.maximumNumberOfLines = _maximumNumberOfLines,
.exclusionPaths = _exclusionPaths,
.minimumScaleFactor = _minimumScaleFactor,
.pointSizeScaleFactors = _pointSizeScaleFactors,
.currentScaleFactor = _currentScaleFactor,
};
}
@ -256,6 +259,8 @@ static NSArray *DefaultLinkAttributeNames = @[ NSLinkAttributeName ];
// actually dealloc.
__block ASTextKitRenderer *renderer = _renderer;
ASPerformBlockOnBackgroundThread(^{
// before we remove the renderer, take its scale factor so we set it when a new renderer is created
_currentScaleFactor = renderer.currentScaleFactor;
renderer = nil;
});
_renderer = nil;
@ -1059,16 +1064,15 @@ static NSAttributedString *DefaultTruncationAttributedString()
return visibleRange.length < _attributedString.length;
}
- (void)setMinimumScaleFactor:(CGFloat)minimumScaleFactor
- (void)setPointSizeScaleFactors:(NSArray *)pointSizeScaleFactors
{
if (_minimumScaleFactor != minimumScaleFactor) {
_minimumScaleFactor = minimumScaleFactor;
if ([_pointSizeScaleFactors isEqualToArray:pointSizeScaleFactors] == NO) {
_pointSizeScaleFactors = pointSizeScaleFactors;
[self _invalidateRenderer];
ASDisplayNodeRespectThreadAffinityOfNode(self, ^{
[self setNeedsDisplay];
});
}
}
}}
- (void)setMaximumNumberOfLines:(NSUInteger)maximumNumberOfLines
{

View File

@ -58,6 +58,7 @@ struct ASTextKitAttributes {
NSLineBreakMode lineBreakMode;
/**
The maximum number of lines to draw in the drawable region. Leave blank or set to 0 to define no maximum.
This is required to apply scale factors to shrink text to fit within a number of lines
*/
NSUInteger maximumNumberOfLines;
/**
@ -82,9 +83,13 @@ struct ASTextKitAttributes {
*/
CGFloat shadowRadius;
/**
The minimum scale that the textnode can apply to fit long words in constrained size.
An array of scale factors in descending order to apply to the text to try to make it fit into a constrained size.
*/
CGFloat minimumScaleFactor;
NSArray *pointSizeScaleFactors;
/**
The currently applied scale factor. Only valid if pointSizeScaleFactors are provided. Defaults to 0 (no scaling)
*/
CGFloat currentScaleFactor;
/**
A pointer to a function that that returns a custom layout manager subclass. If nil, defaults to NSLayoutManager.
*/
@ -112,8 +117,10 @@ struct ASTextKitAttributes {
[shadowColor copy],
shadowOpacity,
shadowRadius,
minimumScaleFactor,
layoutManagerFactory
pointSizeScaleFactors,
currentScaleFactor,
layoutManagerFactory,
layoutManagerDelegate,
};
};
@ -124,7 +131,8 @@ struct ASTextKitAttributes {
&& maximumNumberOfLines == other.maximumNumberOfLines
&& shadowOpacity == other.shadowOpacity
&& shadowRadius == other.shadowRadius
&& minimumScaleFactor == other.minimumScaleFactor
&& [pointSizeScaleFactors isEqualToArray:other.pointSizeScaleFactors]
&& currentScaleFactor == currentScaleFactor
&& layoutManagerFactory == other.layoutManagerFactory
&& CGSizeEqualToSize(shadowOffset, other.shadowOffset)
&& _objectsEqual(exclusionPaths, other.exclusionPaths)

View File

@ -1,20 +1,46 @@
//
// ASTextKitFontSizeAdjuster.h
// AsyncDisplayKit
//
// Created by Luke on 1/20/16.
// Copyright © 2016 Facebook. All rights reserved.
//
/* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import <Foundation/Foundation.h>
#import "ASTextKitAttributes.h"
#import "ASTextKitContext.h"
@interface ASTextKitFontSizeAdjuster : NSObject
@property (nonatomic, assign) CGSize constrainedSize;
/**
* Creates a class that will return a scale factor the will make a string fit inside the constrained size.
*
* "Fitting" means that both the longest word in the string will fit without breaking in the constrained
* size's width AND that the entire string will try to fit within attribute's maximumLineCount. The amount
* that the string will scale is based upon the attribute's pointSizeScaleFactors. If the string cannot fit
* in the given width/number of lines, the smallest scale factor will be returned.
*
* @param context The text kit context
* @param constrainedSize The constrained size to render into
* @param textComponentAttributes The renderer's text attributes
*/
- (instancetype)initWithContext:(ASTextKitContext *)context
minimumScaleFactor:(CGFloat)minimumScaleFactor
constrainedSize:(CGSize)constrainedSize;
constrainedSize:(CGSize)constrainedSize
textKitAttributes:(const ASTextKitAttributes &)textComponentAttributes;
/**
* Returns the best fit scale factor for the text
*/
- (CGFloat)scaleFactor;
/**
* Takes all of the attributed string attributes dealing with size (font size, line spacing, kerning, etc) and
* scales them by the scaleFactor. I wouldn't be surprised if I missed some in here.
*/
+ (void)adjustFontSizeForAttributeString:(NSMutableAttributedString *)attrString withScaleFactor:(CGFloat)scaleFactor;
- (void) adjustFontSize;
@end

View File

@ -1,98 +0,0 @@
//
// ASTextKitFontSizeAdjuster.m
// AsyncDisplayKit
//
// Created by Luke on 1/20/16.
// Copyright © 2016 Facebook. All rights reserved.
//
#import "ASTextKitContext.h"
#import "ASTextKitFontSizeAdjuster.h"
@implementation ASTextKitFontSizeAdjuster
{
__weak ASTextKitContext *_context;
CGFloat _minimumScaleFactor;
}
- (instancetype)initWithContext:(ASTextKitContext *)context
minimumScaleFactor:(CGFloat)minimumScaleFactor
constrainedSize:(CGSize)constrainedSize
{
if (self = [super init]) {
_context = context;
_minimumScaleFactor = minimumScaleFactor;
_constrainedSize = constrainedSize;
}
return self;
}
- (CGSize)sizeForAttributedString:(NSAttributedString *)attrString
{
return [attrString boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin
context:nil].size;
}
- (void) adjustFontSizeForAttributeString:(NSMutableAttributedString *)attrString withScaleFactor:(CGFloat)scaleFactor
{
{
[attrString beginEditing];
[attrString enumerateAttribute:NSFontAttributeName inRange:NSMakeRange(0, attrString.length) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
UIFont* font = value;
font = [font fontWithSize:font.pointSize * scaleFactor];
[attrString removeAttribute:NSFontAttributeName range:range];
[attrString addAttribute:NSFontAttributeName value:font range:range];
}];
[attrString endEditing];
}
}
- (void)adjustFontSize
{
if (_minimumScaleFactor <= 0 || _minimumScaleFactor >= 1) {
return;
}
[_context performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
NSString *str = textStorage.string;
NSArray *words = [str componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
NSString *longestWordNeedingResize = @"";
for (NSString *word in words) {
if ([word length] > [longestWordNeedingResize length]) {
longestWordNeedingResize = word;
}
}
if ([longestWordNeedingResize length] == 0) {
return;
}
NSRange range = [str rangeOfString:longestWordNeedingResize];
NSMutableAttributedString *attrString = [textStorage attributedSubstringFromRange:range].mutableCopy;
CGSize defaultSize = [self sizeForAttributedString:attrString];
if (defaultSize.width > _constrainedSize.width) {
[attrString removeAttribute:NSParagraphStyleAttributeName range:NSMakeRange(0, [attrString length])];
NSStringDrawingContext *context = [[NSStringDrawingContext alloc] init];
context.minimumScaleFactor = _minimumScaleFactor;
[attrString boundingRectWithSize:CGSizeMake(_constrainedSize.width, defaultSize.height)
options:NSStringDrawingUsesLineFragmentOrigin
context:context];
[self adjustFontSizeForAttributeString:attrString withScaleFactor:context.actualScaleFactor];
if ([self sizeForAttributedString:attrString].width <= _constrainedSize.width) {
[self adjustFontSizeForAttributeString:textStorage withScaleFactor:context.actualScaleFactor];
NSLog(@"ASTextKitFontSizeAdjuster : adjusted \"%@\"to fontsize actualScaleFactor:%f", longestWordNeedingResize, context.actualScaleFactor);
}
}
}];
}
@end

View File

@ -0,0 +1,185 @@
/* Copyright (c) 2014-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "ASTextKitContext.h"
#import "ASTextKitFontSizeAdjuster.h"
#import "ASLayoutManager.h"
#import <mutex>
//#define LOG(...) NSLog(__VA_ARGS__)
#define LOG(...)
@implementation ASTextKitFontSizeAdjuster
{
__weak ASTextKitContext *_context;
ASTextKitAttributes _attributes;
std::mutex _textKitMutex;
}
- (instancetype)initWithContext:(ASTextKitContext *)context
constrainedSize:(CGSize)constrainedSize
textKitAttributes:(const ASTextKitAttributes &)textComponentAttributes;
{
if (self = [super init]) {
_context = context;
_constrainedSize = constrainedSize;
_attributes = textComponentAttributes;
}
return self;
}
+ (void)adjustFontSizeForAttributeString:(NSMutableAttributedString *)attrString withScaleFactor:(CGFloat)scaleFactor
{
[attrString beginEditing];
// scale all the attributes that will change the bounding box
[attrString enumerateAttributesInRange:NSMakeRange(0, attrString.length) options:0 usingBlock:^(NSDictionary<NSString *,id> * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) {
if (attrs[NSFontAttributeName] != nil) {
UIFont *font = attrs[NSFontAttributeName];
font = [font fontWithSize:roundf(font.pointSize * scaleFactor)];
[attrString removeAttribute:NSFontAttributeName range:range];
[attrString addAttribute:NSFontAttributeName value:font range:range];
}
if (attrs[NSKernAttributeName] != nil) {
NSNumber *kerning = attrs[NSKernAttributeName];
[attrString removeAttribute:NSKernAttributeName range:range];
[attrString addAttribute:NSKernAttributeName value:@([kerning floatValue] * scaleFactor) range:range];
}
if (attrs[NSParagraphStyleAttributeName] != nil) {
NSMutableParagraphStyle *paragraphStyle = [attrs[NSParagraphStyleAttributeName] mutableCopy];
paragraphStyle.lineSpacing = (paragraphStyle.lineSpacing * scaleFactor);
paragraphStyle.paragraphSpacing = (paragraphStyle.paragraphSpacing * scaleFactor);
paragraphStyle.firstLineHeadIndent = (paragraphStyle.firstLineHeadIndent * scaleFactor);
paragraphStyle.headIndent = (paragraphStyle.headIndent * scaleFactor);
paragraphStyle.tailIndent = (paragraphStyle.tailIndent * scaleFactor);
paragraphStyle.minimumLineHeight = (paragraphStyle.minimumLineHeight * scaleFactor);
paragraphStyle.maximumLineHeight = (paragraphStyle.maximumLineHeight * scaleFactor);
paragraphStyle.lineHeightMultiple = (paragraphStyle.lineHeightMultiple * scaleFactor);
paragraphStyle.paragraphSpacing = (paragraphStyle.paragraphSpacing * scaleFactor);
[attrString removeAttribute:NSParagraphStyleAttributeName range:range];
[attrString addAttribute:NSParagraphStyleAttributeName value:paragraphStyle range:range];
}
}];
[attrString endEditing];
}
- (NSUInteger)lineCountForString:(NSAttributedString *)attributedString
{
NSUInteger lineCount = 0;
static std::mutex __static_mutex;
std::lock_guard<std::mutex> l(__static_mutex);
NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString];
NSLayoutManager *layoutManager = _attributes.layoutManagerFactory ? _attributes.layoutManagerFactory() : [[ASLayoutManager alloc] init];
layoutManager.usesFontLeading = NO;
[textStorage addLayoutManager:layoutManager];
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:_constrainedSize];
textContainer.lineFragmentPadding = 0;
textContainer.lineBreakMode = _attributes.lineBreakMode;
// use 0 regardless of what is in the attributes so that we get an accurate line count
textContainer.maximumNumberOfLines = 0;
textContainer.exclusionPaths = _attributes.exclusionPaths;
[layoutManager addTextContainer:textContainer];
NSRange lineRange = { 0, 0 };
while (NSMaxRange(lineRange) < [layoutManager numberOfGlyphs]/* && lineCount <= _attributes.maximumNumberOfLines*/) {
[layoutManager lineFragmentRectForGlyphAtIndex:NSMaxRange(lineRange) effectiveRange:&lineRange];
lineCount++;
}
return lineCount;
}
- (CGFloat)scaleFactor
{
if ([_attributes.pointSizeScaleFactors count] == 0 || isinf(_constrainedSize.width)) {
return 1.0;
}
__block CGFloat adjustedScale = 1.0;
NSArray *scaleFactors = _attributes.pointSizeScaleFactors;
[_context performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
// Check for two different situations (and correct for both)
// 1. The longest word in the string fits without being wrapped
// 2. The entire text fits in the given constrained size.
NSString *str = textStorage.string;
NSArray *words = [str componentsSeparatedByCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
NSString *longestWordNeedingResize = @"";
for (NSString *word in words) {
if ([word length] > [longestWordNeedingResize length]) {
longestWordNeedingResize = word;
}
}
NSUInteger scaleIndex = 0;
// find the longest word and make sure it fits in the constrained width
if ([longestWordNeedingResize length] > 0) {
NSRange longestWordRange = [str rangeOfString:longestWordNeedingResize];
NSMutableAttributedString *attrString = [textStorage attributedSubstringFromRange:longestWordRange].mutableCopy;
CGSize longestWordSize = [attrString boundingRectWithSize:CGSizeMake(FLT_MAX, FLT_MAX) options:NSStringDrawingUsesLineFragmentOrigin context:nil].size;
// check if the longest word is larger than our constrained width
if (longestWordSize.width > _constrainedSize.width) {
// we have a word that is too long. Loop through our scale factors until we fit
for (NSNumber *scaleFactor in scaleFactors) {
// even if we still don't fit, save this scaleFactor so more of the word will fit
adjustedScale = [scaleFactor floatValue];
// adjust here so we start at the proper place in our scale array if we have too many lines
scaleIndex++;
if (ceilf(longestWordSize.width * [scaleFactor floatValue]) <= _constrainedSize.width) {
// we fit! we are done
break;
}
}
}
}
if (_attributes.maximumNumberOfLines > 0) {
// get the number of lines in our possibly scaled string
NSUInteger numberOfLines = [self lineCountForString:textStorage];
if (numberOfLines > _attributes.maximumNumberOfLines) {
for (NSUInteger index = scaleIndex; index < scaleFactors.count; index++) {
NSMutableAttributedString *entireAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage];
[[self class] adjustFontSizeForAttributeString:entireAttributedString withScaleFactor:[scaleFactors[index] floatValue]];
// save away this scale factor. Even if we don't fit completely we should still scale down
adjustedScale = [scaleFactors[index] floatValue];
if ([self lineCountForString:entireAttributedString] <= _attributes.maximumNumberOfLines) {
// we fit! we are done
break;
}
}
}
}
}];
return adjustedScale;
}
@end

View File

@ -55,6 +55,8 @@
@property (nonatomic, assign, readwrite) CGSize constrainedSize;
@property (nonatomic, assign, readonly) CGFloat currentScaleFactor;
#pragma mark - Drawing
/*
Draw the renderer's text content into the bounds provided.

View File

@ -49,6 +49,9 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
_constrainedSize = constrainedSize;
_attributes = attributes;
_sizeIsCalculated = NO;
if ([attributes.pointSizeScaleFactors count] > 0) {
_currentScaleFactor = attributes.currentScaleFactor;
}
}
return self;
}
@ -84,8 +87,8 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
// We must inset the constrained size by the size of the shadower.
CGSize shadowConstrainedSize = [[self shadower] insetSizeWithConstrainedSize:_constrainedSize];
_fontSizeAdjuster = [[ASTextKitFontSizeAdjuster alloc] initWithContext:[self context]
minimumScaleFactor:attributes.minimumScaleFactor
constrainedSize:shadowConstrainedSize];
constrainedSize:shadowConstrainedSize
textKitAttributes:attributes];
}
return _fontSizeAdjuster;
}
@ -137,8 +140,8 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
- (void)_calculateSize
{
[self truncater];
if (_attributes.minimumScaleFactor < 1 && _attributes.minimumScaleFactor > 0) {
[[self fontSizeAdjuster] adjustFontSize];
if ([_attributes.pointSizeScaleFactors count] > 0) {
_currentScaleFactor = [[self fontSizeAdjuster] scaleFactor];
}
// Force glyph generation and layout, which may not have happened yet (and isn't triggered by
@ -156,8 +159,12 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
// TextKit often returns incorrect glyph bounding rects in the horizontal direction, so we clip to our bounding rect
// to make sure our width calculations aren't being offset by glyphs going beyond the constrained rect.
boundingRect = CGRectIntersection(boundingRect, {.size = constrainedRect.size});
_calculatedSize = [_shadower outsetSizeWithInsetSize:boundingRect.size];
CGSize boundingSize = [_shadower outsetSizeWithInsetSize:boundingRect.size];
_calculatedSize = CGSizeMake(boundingSize.width, boundingSize.height);
if (_currentScaleFactor > 0.0 && _currentScaleFactor < 1.0) {
_calculatedSize.height = ceilf(_calculatedSize.height * _currentScaleFactor);
}
}
#pragma mark - Drawing
@ -176,11 +183,32 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
LOG(@"%@, shadowInsetBounds = %@",self, NSStringFromCGRect(shadowInsetBounds));
[[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
NSTextStorage *scaledTextStorage = nil;
BOOL isScaled = (self.currentScaleFactor > 0 && self.currentScaleFactor < 1.0);
if (isScaled) {
// if we are going to scale the text, swap out the non-scaled text for the scaled version.
NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage];
[ASTextKitFontSizeAdjuster adjustFontSizeForAttributeString:scaledString withScaleFactor:_currentScaleFactor];
scaledTextStorage = [[NSTextStorage alloc] initWithAttributedString:scaledString];
[textStorage removeLayoutManager:layoutManager];
[scaledTextStorage addLayoutManager:layoutManager];
}
LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer]));
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer];
LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]));
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin];
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin];
if (isScaled) {
// put the non-scaled version back
[scaledTextStorage removeLayoutManager:layoutManager];
[textStorage addLayoutManager:layoutManager];
}
}];
UIGraphicsPopContext();