From c6b6f53ae7d5196bc89c5e71c20ffc3630ae0817 Mon Sep 17 00:00:00 2001 From: "tfallon@mail.depaul.edu" Date: Wed, 10 Aug 2016 11:14:36 -0700 Subject: [PATCH] Initial implementation of adjustsFontSizeToFit. Summary: Closes https://github.com/facebook/react-native/pull/4026 Differential Revision: D2678492 Pulled By: nicklockwood fbshipit-source-id: 0467814f810fee997ac50960ffb1daa74d52acba --- Examples/UIExplorer/js/TextExample.ios.js | 86 +++++++++++ Libraries/Text/RCTShadowText.h | 10 ++ Libraries/Text/RCTShadowText.m | 167 ++++++++++++++++++++++ Libraries/Text/RCTText.h | 2 + Libraries/Text/RCTText.m | 10 +- Libraries/Text/RCTTextManager.m | 2 + Libraries/Text/Text.js | 15 +- 7 files changed, 286 insertions(+), 6 deletions(-) diff --git a/Examples/UIExplorer/js/TextExample.ios.js b/Examples/UIExplorer/js/TextExample.ios.js index 755594274..605ed2b8a 100644 --- a/Examples/UIExplorer/js/TextExample.ios.js +++ b/Examples/UIExplorer/js/TextExample.ios.js @@ -29,6 +29,7 @@ var { StyleSheet, Text, View, + LayoutAnimation, } = ReactNative; class Entity extends React.Component { @@ -81,6 +82,86 @@ class AttributeToggler extends React.Component { } } +var AdjustingFontSize = React.createClass({ + getInitialState: function() { + return {dynamicText:'', shouldRender: true,}; + }, + reset: function() { + LayoutAnimation.easeInEaseOut(); + this.setState({ + shouldRender: false, + }); + setTimeout(()=>{ + LayoutAnimation.easeInEaseOut(); + this.setState({ + dynamicText: '', + shouldRender: true, + }); + }, 300); + }, + addText: function() { + this.setState({ + dynamicText: this.state.dynamicText + (Math.floor((Math.random() * 10) % 2) ? ' foo' : ' bar'), + }); + }, + removeText: function() { + this.setState({ + dynamicText: this.state.dynamicText.slice(0, this.state.dynamicText.length - 4), + }); + }, + render: function() { + + if (!this.state.shouldRender) { + return (); + } + return ( + + + Truncated text is baaaaad. + + + Shrinking to fit available space is much better! + + + + {'Add text to me to watch me shrink!' + ' ' + this.state.dynamicText} + + + + {'Multiline text component shrinking is supported, watch as this reeeeaaaally loooooong teeeeeeext grooooows and then shriiiinks as you add text to me! ioahsdia soady auydoa aoisyd aosdy ' + ' ' + this.state.dynamicText} + + + + + {'Differently sized nested elements will shrink together. '} + + + {'LARGE TEXT! ' + this.state.dynamicText} + + + + + + Reset + + + Remove Text + + + Add Text + + + + ); + } +}); + exports.title = ''; exports.description = 'Base component for rendering styled text.'; exports.displayName = 'TextExample'; @@ -492,6 +573,11 @@ exports.examples = [ ); }, +}, { + title: 'Dynamic Font Size Adjustment', + render: function(): ReactElement { + return ; + }, }]; var styles = StyleSheet.create({ diff --git a/Libraries/Text/RCTShadowText.h b/Libraries/Text/RCTShadowText.h index 7468a2ad4..2c8944221 100644 --- a/Libraries/Text/RCTShadowText.h +++ b/Libraries/Text/RCTShadowText.h @@ -10,6 +10,14 @@ #import "RCTShadowView.h" #import "RCTTextDecorationLineType.h" +typedef NS_ENUM(NSInteger, RCTSizeComparison) +{ + RCTSizeTooLarge, + RCTSizeTooSmall, + RCTSizeWithinRange, +}; + + extern NSString *const RCTIsHighlightedAttributeName; extern NSString *const RCTReactTagAttributeName; @@ -38,6 +46,8 @@ extern NSString *const RCTReactTagAttributeName; @property (nonatomic, assign) CGSize textShadowOffset; @property (nonatomic, assign) CGFloat textShadowRadius; @property (nonatomic, strong) UIColor *textShadowColor; +@property (nonatomic, assign) BOOL adjustsFontSizeToFit; +@property (nonatomic, assign) CGFloat minimumFontScale; - (void)recomputeText; diff --git a/Libraries/Text/RCTShadowText.m b/Libraries/Text/RCTShadowText.m index f177ad78d..15f33ddef 100644 --- a/Libraries/Text/RCTShadowText.m +++ b/Libraries/Text/RCTShadowText.m @@ -24,6 +24,11 @@ NSString *const RCTShadowViewAttributeName = @"RCTShadowViewAttributeName"; NSString *const RCTIsHighlightedAttributeName = @"IsHighlightedAttributeName"; NSString *const RCTReactTagAttributeName = @"ReactTagAttributeName"; +CGFloat const RCTTextAutoSizeDefaultMinimumFontScale = 0.5f; +CGFloat const RCTTextAutoSizeWidthErrorMargin = 0.05f; +CGFloat const RCTTextAutoSizeHeightErrorMargin = 0.025f; +CGFloat const RCTTextAutoSizeGranularity = 0.001f; + @implementation RCTShadowText { NSTextStorage *_cachedTextStorage; @@ -37,6 +42,7 @@ static CSSSize RCTMeasure(void *context, float width, CSSMeasureMode widthMode, { RCTShadowText *shadowText = (__bridge RCTShadowText *)context; NSTextStorage *textStorage = [shadowText buildTextStorageForWidth:width widthMode:widthMode]; + [shadowText calculateTextFrame:textStorage]; NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject; NSTextContainer *textContainer = layoutManager.textContainers.firstObject; CGSize computedSize = [layoutManager usedRectForTextContainer:textContainer].size; @@ -105,10 +111,13 @@ static CSSSize RCTMeasure(void *context, float width, CSSMeasureMode widthMode, UIEdgeInsets padding = self.paddingAsInsets; CGFloat width = self.frame.size.width - (padding.left + padding.right); + NSNumber *parentTag = [[self reactSuperview] reactTag]; NSTextStorage *textStorage = [self buildTextStorageForWidth:width widthMode:CSSMeasureModeExactly]; + CGRect textFrame = [self calculateTextFrame:textStorage]; [applierBlocks addObject:^(NSDictionary *viewRegistry) { RCTText *view = (RCTText *)viewRegistry[self.reactTag]; + view.textFrame = textFrame; view.textStorage = textStorage; /** @@ -452,6 +461,155 @@ static CSSSize RCTMeasure(void *context, float width, CSSMeasureMode widthMode, } } +#pragma mark Autosizing + +- (CGRect)calculateTextFrame:(NSTextStorage *)textStorage +{ + CGRect textFrame = UIEdgeInsetsInsetRect((CGRect){CGPointZero, self.frame.size}, + self.paddingAsInsets); + + + if (_adjustsFontSizeToFit) { + textFrame = [self updateStorage:textStorage toFitFrame:textFrame]; + } + + return textFrame; +} + +- (CGRect)updateStorage:(NSTextStorage *)textStorage toFitFrame:(CGRect)frame +{ + + BOOL fits = [self attemptScale:1.0f + inStorage:textStorage + forFrame:frame]; + CGSize requiredSize; + if (!fits) { + requiredSize = [self calculateOptimumScaleInFrame:frame + forStorage:textStorage + minScale:self.minimumFontScale + maxScale:1.0 + prevMid:INT_MAX]; + } else { + requiredSize = [self calculateSize:textStorage]; + } + + //Vertically center draw position for new text sizing. + frame.origin.y = self.paddingAsInsets.top + RCTRoundPixelValue((CGRectGetHeight(frame) - requiredSize.height) / 2.0f); + return frame; +} + +- (CGSize)calculateOptimumScaleInFrame:(CGRect)frame + forStorage:(NSTextStorage *)textStorage + minScale:(CGFloat)minScale + maxScale:(CGFloat)maxScale + prevMid:(CGFloat)prevMid +{ + CGFloat midScale = (minScale + maxScale) / 2.0f; + if (round((prevMid / RCTTextAutoSizeGranularity)) == round((midScale / RCTTextAutoSizeGranularity))) { + //Bail because we can't meet error margin. + return [self calculateSize:textStorage]; + } else { + RCTSizeComparison comparison = [self attemptScale:midScale + inStorage:textStorage + forFrame:frame]; + if (comparison == RCTSizeWithinRange) { + return [self calculateSize:textStorage]; + } else if (comparison == RCTSizeTooLarge) { + return [self calculateOptimumScaleInFrame:frame + forStorage:textStorage + minScale:minScale + maxScale:midScale - RCTTextAutoSizeGranularity + prevMid:midScale]; + } else { + return [self calculateOptimumScaleInFrame:frame + forStorage:textStorage + minScale:midScale + RCTTextAutoSizeGranularity + maxScale:maxScale + prevMid:midScale]; + } + } +} + +- (RCTSizeComparison)attemptScale:(CGFloat)scale + inStorage:(NSTextStorage *)textStorage + forFrame:(CGRect)frame +{ + NSLayoutManager *layoutManager = [textStorage.layoutManagers firstObject]; + NSTextContainer *textContainer = [layoutManager.textContainers firstObject]; + + NSRange glyphRange = NSMakeRange(0, textStorage.length); + [textStorage beginEditing]; + [textStorage enumerateAttribute:NSFontAttributeName + inRange:glyphRange + options:0 + usingBlock:^(UIFont *font, NSRange range, BOOL *stop) + { + if (font) { + UIFont *originalFont = [self.attributedString attribute:NSFontAttributeName + atIndex:range.location + effectiveRange:&range]; + UIFont *newFont = [font fontWithSize:originalFont.pointSize * scale]; + [textStorage removeAttribute:NSFontAttributeName range:range]; + [textStorage addAttribute:NSFontAttributeName value:newFont range:range]; + } + }]; + + [textStorage endEditing]; + + NSInteger linesRequired = [self numberOfLinesRequired:[textStorage.layoutManagers firstObject]]; + CGSize requiredSize = [self calculateSize:textStorage]; + + BOOL fitSize = requiredSize.height <= CGRectGetHeight(frame) && + requiredSize.width <= CGRectGetWidth(frame); + + BOOL fitLines = linesRequired <= textContainer.maximumNumberOfLines || + textContainer.maximumNumberOfLines == 0; + + if (fitLines && fitSize) { + if ((requiredSize.width + (CGRectGetWidth(frame) * RCTTextAutoSizeWidthErrorMargin)) > CGRectGetWidth(frame) && + (requiredSize.height + (CGRectGetHeight(frame) * RCTTextAutoSizeHeightErrorMargin)) > CGRectGetHeight(frame)) + { + return RCTSizeWithinRange; + } else { + return RCTSizeTooSmall; + } + } else { + return RCTSizeTooLarge; + } +} + +// Via Apple Text Layout Programming Guide +// https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/TextLayout/Tasks/CountLines.html +- (NSInteger)numberOfLinesRequired:(NSLayoutManager *)layoutManager +{ + NSInteger numberOfLines, index, numberOfGlyphs = [layoutManager numberOfGlyphs]; + NSRange lineRange; + for (numberOfLines = 0, index = 0; index < numberOfGlyphs; numberOfLines++){ + (void) [layoutManager lineFragmentRectForGlyphAtIndex:index + effectiveRange:&lineRange]; + index = NSMaxRange(lineRange); + } + + return numberOfLines; +} + +// Via Apple Text Layout Programming Guide +//https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/TextLayout/Tasks/StringHeight.html +- (CGSize)calculateSize:(NSTextStorage *)storage +{ + NSLayoutManager *layoutManager = [storage.layoutManagers firstObject]; + NSTextContainer *textContainer = [layoutManager.textContainers firstObject]; + + [textContainer setLineBreakMode:NSLineBreakByWordWrapping]; + NSInteger maxLines = [textContainer maximumNumberOfLines]; + [textContainer setMaximumNumberOfLines:0]; + (void) [layoutManager glyphRangeForTextContainer:textContainer]; + CGSize requiredSize = [layoutManager usedRectForTextContainer:textContainer].size; + [textContainer setMaximumNumberOfLines:maxLines]; + + return requiredSize; +} + - (void)setBackgroundColor:(UIColor *)backgroundColor { super.backgroundColor = backgroundColor; @@ -465,6 +623,7 @@ static CSSSize RCTMeasure(void *context, float width, CSSMeasureMode widthMode, [self dirtyText]; \ } +RCT_TEXT_PROPERTY(AdjustsFontSizeToFit, _adjustsFontSizeToFit, BOOL) RCT_TEXT_PROPERTY(Color, _color, UIColor *) RCT_TEXT_PROPERTY(FontFamily, _fontFamily, NSString *) RCT_TEXT_PROPERTY(FontSize, _fontSize, CGFloat) @@ -512,4 +671,12 @@ RCT_TEXT_PROPERTY(TextShadowColor, _textShadowColor, UIColor *); [self dirtyText]; } +- (void)setMinimumFontScale:(CGFloat)minimumFontScale +{ + if (minimumFontScale >= 0.01) { + _minimumFontScale = minimumFontScale; + } + [self dirtyText]; +} + @end diff --git a/Libraries/Text/RCTText.h b/Libraries/Text/RCTText.h index 487954f8a..aef976303 100644 --- a/Libraries/Text/RCTText.h +++ b/Libraries/Text/RCTText.h @@ -13,5 +13,7 @@ @property (nonatomic, assign) UIEdgeInsets contentInset; @property (nonatomic, strong) NSTextStorage *textStorage; +@property (nonatomic, assign) CGRect textFrame; + @end diff --git a/Libraries/Text/RCTText.m b/Libraries/Text/RCTText.m index fec4f31d9..427c9ac68 100644 --- a/Libraries/Text/RCTText.m +++ b/Libraries/Text/RCTText.m @@ -34,7 +34,6 @@ static void collectNonTextDescendants(RCTText *view, NSMutableArray *nonTextDesc { if ((self = [super initWithFrame:frame])) { _textStorage = [NSTextStorage new]; - self.isAccessibilityElement = YES; self.accessibilityTraits |= UIAccessibilityTraitStaticText; @@ -97,11 +96,11 @@ static void collectNonTextDescendants(RCTText *view, NSMutableArray *nonTextDesc - (void)drawRect:(CGRect)rect { - NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject; - NSTextContainer *textContainer = layoutManager.textContainers.firstObject; - CGRect textFrame = UIEdgeInsetsInsetRect(self.bounds, _contentInset); - NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + NSLayoutManager *layoutManager = [_textStorage.layoutManagers firstObject]; + NSTextContainer *textContainer = [layoutManager.textContainers firstObject]; + NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer]; + CGRect textFrame = self.textFrame; [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:textFrame.origin]; [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:textFrame.origin]; @@ -170,6 +169,7 @@ static void collectNonTextDescendants(RCTText *view, NSMutableArray *nonTextDesc } } + #pragma mark - Accessibility - (NSString *)accessibilityLabel diff --git a/Libraries/Text/RCTTextManager.m b/Libraries/Text/RCTTextManager.m index adc8dea8a..0e66829e6 100644 --- a/Libraries/Text/RCTTextManager.m +++ b/Libraries/Text/RCTTextManager.m @@ -76,6 +76,8 @@ RCT_EXPORT_SHADOW_PROPERTY(opacity, CGFloat) RCT_EXPORT_SHADOW_PROPERTY(textShadowOffset, CGSize) RCT_EXPORT_SHADOW_PROPERTY(textShadowRadius, CGFloat) RCT_EXPORT_SHADOW_PROPERTY(textShadowColor, UIColor) +RCT_EXPORT_SHADOW_PROPERTY(adjustsFontSizeToFit, BOOL) +RCT_EXPORT_SHADOW_PROPERTY(minimumFontScale, CGFloat) - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(NSDictionary *)shadowViewRegistry { diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index d90d75301..710d8f861 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -32,6 +32,8 @@ const viewConfig = { ellipsizeMode: true, allowFontScaling: true, selectable: true, + adjustsFontSizeToFit: true, + minimumFontScale: true, }), uiViewClassName: 'RCTText', }; @@ -166,7 +168,18 @@ const Text = React.createClass({ * [Accessibility guide](/react-native/docs/accessibility.html#accessible-ios-android) * for more information. */ - accessible: React.PropTypes.bool, + accessible: React.PropTypes.bool, + /** + * Specifies whether font should be scaled down automatically to fit given style constraints. + * @platform ios + */ + adjustsFontSizeToFit: React.PropTypes.bool, + + /** + * Specifies smallest possible scale a font can reach when adjustsFontSizeToFit is enabled. (values 0.01-1.0). + * @platform ios + */ + minimumFontScale: React.PropTypes.number, }, getDefaultProps(): Object { return {