Files
react-native/Libraries/Text/Text/RCTTextShadowView.m
T
BingBing c1561ab441 iOS: fix the baseline issue when displaying a mixture of different-language characters (#19653)
Summary:
This fixes #19193

Characters of different languages may have different height and different baseline, even with a same font and same font-size. When they mixed together, iOS will adjust their baselines automatically, to display a suitable view of text.

The problem is the behavior of dealing with the text-style property 'lineHeight'.

It once to be a right way at version 0.52.3, to setting a base-line-offset attribute for the whole range of the string. However, in current version, it enumerated the string, and set a different  base-line-offset for different font-height characters.

And this behavior broke the baseline adjustment made by the iOS. It just make every character's baseline aligned to a same line. But it is not supposed to displaying characters of different languages like that. Chinese characters' baseline is the bottom of each, however, it is not for English characters.

So maybe it is the better way to give a same value of base-line-offset attribute for the whole string. And this PR just did that: found the biggest value of font-height in the text, and calculate the offset with that biggest value, then set to the whole string. This will keep the origin baseline adjustment for different languages' chars made by iOS.

Since I always got an error when running the snapshot test locally, I can't even pass the them with the unmodified code in master branch.

The error is "Nesting of \<View\> within \<Text\> is not currently supported."

After I comment all of the check of that error from the source code, I got a different snapshot from the reference ones. It seems that all components which will cause that error are not rendered in the reference images.

Since this PR changed the behavior of 'lineHeight' property, it's better to add a new snapshot case for the situation of different-language-characters' mixture. So maybe somebody could help me with that or maybe it should be a issue?

[IOS] [BUGFIX] [Text] - fix the baseline issue when displaying a mixture of different-language characters
Pull Request resolved: https://github.com/facebook/react-native/pull/19653

Differential Revision: D10140131

Pulled By: shergin

fbshipit-source-id: 646a9c4046d497b78a980d82a015168cf940646b
2018-10-01 21:19:40 -07:00

413 lines
14 KiB
Objective-C

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import "RCTTextShadowView.h"
#import <React/RCTBridge.h>
#import <React/RCTShadowView+Layout.h>
#import <React/RCTUIManager.h>
#import <yoga/Yoga.h>
#import "NSTextStorage+FontScaling.h"
#import "RCTTextView.h"
@implementation RCTTextShadowView
{
__weak RCTBridge *_bridge;
BOOL _needsUpdateView;
NSMapTable<id, NSTextStorage *> *_cachedTextStorages;
}
- (instancetype)initWithBridge:(RCTBridge *)bridge
{
if (self = [super init]) {
_bridge = bridge;
_cachedTextStorages = [NSMapTable strongToStrongObjectsMapTable];
_needsUpdateView = YES;
YGNodeSetMeasureFunc(self.yogaNode, RCTTextShadowViewMeasure);
YGNodeSetBaselineFunc(self.yogaNode, RCTTextShadowViewBaseline);
}
return self;
}
- (BOOL)isYogaLeafNode
{
return YES;
}
- (void)dirtyLayout
{
[super dirtyLayout];
YGNodeMarkDirty(self.yogaNode);
[self invalidateCache];
}
- (void)invalidateCache
{
[_cachedTextStorages removeAllObjects];
_needsUpdateView = YES;
}
#pragma mark - RCTUIManagerObserver
- (void)uiManagerWillPerformMounting
{
if (YGNodeIsDirty(self.yogaNode)) {
return;
}
if (!_needsUpdateView) {
return;
}
_needsUpdateView = NO;
CGRect contentFrame = self.contentFrame;
NSTextStorage *textStorage = [self textStorageAndLayoutManagerThatFitsSize:self.contentFrame.size
exclusiveOwnership:YES];
NSNumber *tag = self.reactTag;
NSMutableArray<NSNumber *> *descendantViewTags = [NSMutableArray new];
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, textStorage.length)
options:0
usingBlock:
^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
if (!shadowView) {
return;
}
[descendantViewTags addObject:shadowView.reactTag];
}
];
[_bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
RCTTextView *textView = (RCTTextView *)viewRegistry[tag];
if (!textView) {
return;
}
NSMutableArray<UIView *> *descendantViews =
[NSMutableArray arrayWithCapacity:descendantViewTags.count];
[descendantViewTags enumerateObjectsUsingBlock:^(NSNumber *_Nonnull descendantViewTag, NSUInteger index, BOOL *_Nonnull stop) {
UIView *descendantView = viewRegistry[descendantViewTag];
if (!descendantView) {
return;
}
[descendantViews addObject:descendantView];
}];
// Removing all references to Shadow Views to avoid unnececery retainning.
[textStorage removeAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName range:NSMakeRange(0, textStorage.length)];
[textView setTextStorage:textStorage
contentFrame:contentFrame
descendantViews:descendantViews];
}];
}
- (void)postprocessAttributedText:(NSMutableAttributedString *)attributedText
{
__block CGFloat maximumLineHeight = 0;
[attributedText enumerateAttribute:NSParagraphStyleAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:
^(NSParagraphStyle *paragraphStyle, __unused NSRange range, __unused BOOL *stop) {
if (!paragraphStyle) {
return;
}
maximumLineHeight = MAX(paragraphStyle.maximumLineHeight, maximumLineHeight);
}
];
if (maximumLineHeight == 0) {
// `lineHeight` was not specified, nothing to do.
return;
}
__block CGFloat maximumFontLineHeight = 0;
[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:
^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (!font) {
return;
}
if (maximumFontLineHeight <= font.lineHeight) {
maximumFontLineHeight = font.lineHeight;
}
}
];
if (maximumLineHeight < maximumFontLineHeight) {
return;
}
CGFloat baseLineOffset = maximumLineHeight / 2.0 - maximumFontLineHeight / 2.0;
[attributedText addAttribute:NSBaselineOffsetAttributeName
value:@(baseLineOffset)
range:NSMakeRange(0, attributedText.length)];
}
- (NSAttributedString *)attributedTextWithMeasuredAttachmentsThatFitSize:(CGSize)size
{
NSMutableAttributedString *attributedText =
[[NSMutableAttributedString alloc] initWithAttributedString:[self attributedTextWithBaseTextAttributes:nil]];
[attributedText beginEditing];
[attributedText enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:0
usingBlock:
^(RCTShadowView *shadowView, NSRange range, __unused BOOL *stop) {
if (!shadowView) {
return;
}
CGSize fittingSize = [shadowView sizeThatFitsMinimumSize:CGSizeZero
maximumSize:size];
NSTextAttachment *attachment = [NSTextAttachment new];
attachment.bounds = (CGRect){CGPointZero, fittingSize};
[attributedText addAttribute:NSAttachmentAttributeName value:attachment range:range];
}
];
[attributedText endEditing];
return [attributedText copy];
}
- (NSTextStorage *)textStorageAndLayoutManagerThatFitsSize:(CGSize)size
exclusiveOwnership:(BOOL)exclusiveOwnership
{
NSValue *key = [NSValue valueWithCGSize:size];
NSTextStorage *cachedTextStorage = [_cachedTextStorages objectForKey:key];
if (cachedTextStorage) {
if (exclusiveOwnership) {
[_cachedTextStorages removeObjectForKey:key];
}
return cachedTextStorage;
}
NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size];
textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5.
textContainer.lineBreakMode =
_maximumNumberOfLines > 0 ? _lineBreakMode : NSLineBreakByClipping;
textContainer.maximumNumberOfLines = _maximumNumberOfLines;
NSLayoutManager *layoutManager = [NSLayoutManager new];
[layoutManager addTextContainer:textContainer];
NSTextStorage *textStorage =
[[NSTextStorage alloc] initWithAttributedString:[self attributedTextWithMeasuredAttachmentsThatFitSize:size]];
[self postprocessAttributedText:textStorage];
[textStorage addLayoutManager:layoutManager];
if (_adjustsFontSizeToFit) {
CGFloat minimumFontSize =
MAX(_minimumFontScale * (self.textAttributes.effectiveFont.pointSize), 4.0);
[textStorage scaleFontSizeToFitSize:size
minimumFontSize:minimumFontSize
maximumFontSize:self.textAttributes.effectiveFont.pointSize];
}
if (!exclusiveOwnership) {
[_cachedTextStorages setObject:textStorage forKey:key];
}
return textStorage;
}
- (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics
layoutContext:(RCTLayoutContext)layoutContext
{
// If the view got new `contentFrame`, we have to redraw it because
// and sizes of embedded views may change.
if (!CGRectEqualToRect(self.layoutMetrics.contentFrame, layoutMetrics.contentFrame)) {
_needsUpdateView = YES;
}
if (self.textAttributes.layoutDirection != layoutMetrics.layoutDirection) {
self.textAttributes.layoutDirection = layoutMetrics.layoutDirection;
[self invalidateCache];
}
[super layoutWithMetrics:layoutMetrics layoutContext:layoutContext];
}
- (void)layoutSubviewsWithContext:(RCTLayoutContext)layoutContext
{
NSTextStorage *textStorage =
[self textStorageAndLayoutManagerThatFitsSize:self.availableSize
exclusiveOwnership:NO];
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange
actualGlyphRange:NULL];
[textStorage enumerateAttribute:RCTBaseTextShadowViewEmbeddedShadowViewAttributeName
inRange:characterRange
options:0
usingBlock:
^(RCTShadowView *shadowView, NSRange range, BOOL *stop) {
if (!shadowView) {
return;
}
CGRect glyphRect = [layoutManager boundingRectForGlyphRange:range
inTextContainer:textContainer];
NSTextAttachment *attachment =
[textStorage attribute:NSAttachmentAttributeName atIndex:range.location effectiveRange:nil];
CGSize attachmentSize = attachment.bounds.size;
UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil];
CGRect frame = {{
RCTRoundPixelValue(glyphRect.origin.x),
RCTRoundPixelValue(glyphRect.origin.y + glyphRect.size.height - attachmentSize.height + font.descender)
}, {
RCTRoundPixelValue(attachmentSize.width),
RCTRoundPixelValue(attachmentSize.height)
}};
RCTLayoutContext localLayoutContext = layoutContext;
localLayoutContext.absolutePosition.x += frame.origin.x;
localLayoutContext.absolutePosition.y += frame.origin.y;
[shadowView layoutWithMinimumSize:frame.size
maximumSize:frame.size
layoutDirection:self.layoutMetrics.layoutDirection
layoutContext:localLayoutContext];
// Reinforcing a proper frame origin for the Shadow View.
RCTLayoutMetrics localLayoutMetrics = shadowView.layoutMetrics;
localLayoutMetrics.frame.origin = frame.origin;
[shadowView layoutWithMetrics:localLayoutMetrics layoutContext:localLayoutContext];
}
];
if (_onTextLayout) {
NSMutableArray *lineData = [NSMutableArray new];
[layoutManager
enumerateLineFragmentsForGlyphRange:glyphRange
usingBlock:^(CGRect overallRect, CGRect usedRect, NSTextContainer * _Nonnull usedTextContainer, NSRange lineGlyphRange, BOOL * _Nonnull stop) {
NSRange range = [layoutManager characterRangeForGlyphRange:lineGlyphRange actualGlyphRange:nil];
NSString *renderedString = [textStorage.string substringWithRange:range];
UIFont *font = [[textStorage attributedSubstringFromRange:range] attribute:NSFontAttributeName atIndex:0 effectiveRange:nil];
[lineData addObject:
@{
@"text": renderedString,
@"x": @(usedRect.origin.x),
@"y": @(usedRect.origin.y),
@"width": @(usedRect.size.width),
@"height": @(usedRect.size.height),
@"descender": @(-font.descender),
@"capHeight": @(font.capHeight),
@"ascender": @(font.ascender),
@"xHeight": @(font.xHeight),
}];
}];
NSDictionary *payload =
@{
@"lines": lineData,
};
_onTextLayout(payload);
}
}
- (CGFloat)lastBaselineForSize:(CGSize)size
{
NSAttributedString *attributedText =
[self textStorageAndLayoutManagerThatFitsSize:size exclusiveOwnership:NO];
__block CGFloat maximumDescender = 0.0;
[attributedText enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, attributedText.length)
options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired
usingBlock:
^(UIFont *font, NSRange range, __unused BOOL *stop) {
if (maximumDescender > font.descender) {
maximumDescender = font.descender;
}
}
];
return size.height + maximumDescender;
}
static YGSize RCTTextShadowViewMeasure(YGNodeRef node, float width, YGMeasureMode widthMode, float height, YGMeasureMode heightMode)
{
CGSize maximumSize = (CGSize){
widthMode == YGMeasureModeUndefined ? CGFLOAT_MAX : RCTCoreGraphicsFloatFromYogaFloat(width),
heightMode == YGMeasureModeUndefined ? CGFLOAT_MAX : RCTCoreGraphicsFloatFromYogaFloat(height),
};
RCTTextShadowView *shadowTextView = (__bridge RCTTextShadowView *)YGNodeGetContext(node);
NSTextStorage *textStorage =
[shadowTextView textStorageAndLayoutManagerThatFitsSize:maximumSize
exclusiveOwnership:NO];
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
[layoutManager ensureLayoutForTextContainer:textContainer];
CGSize size = [layoutManager usedRectForTextContainer:textContainer].size;
CGFloat letterSpacing = shadowTextView.textAttributes.letterSpacing;
if (!isnan(letterSpacing) && letterSpacing < 0) {
size.width -= letterSpacing;
}
size = (CGSize){
MIN(RCTCeilPixelValue(size.width), maximumSize.width),
MIN(RCTCeilPixelValue(size.height), maximumSize.height)
};
// Adding epsilon value illuminates problems with converting values from
// `double` to `float`, and then rounding them to pixel grid in Yoga.
CGFloat epsilon = 0.001;
return (YGSize){
RCTYogaFloatFromCoreGraphicsFloat(size.width + epsilon),
RCTYogaFloatFromCoreGraphicsFloat(size.height + epsilon)
};
}
static float RCTTextShadowViewBaseline(YGNodeRef node, const float width, const float height)
{
RCTTextShadowView *shadowTextView = (__bridge RCTTextShadowView *)YGNodeGetContext(node);
CGSize size = (CGSize){
RCTCoreGraphicsFloatFromYogaFloat(width),
RCTCoreGraphicsFloatFromYogaFloat(height)
};
CGFloat lastBaseline = [shadowTextView lastBaselineForSize:size];
return RCTYogaFloatFromCoreGraphicsFloat(lastBaseline);
}
@end