iOS: Enable views to be nested within <Text>

Summary:
Previously, only Text and Image could be nested within Text. Now, any
view can be nested within Text. One restriction of this feature is
that developers must give inline views a width and a height via
the style prop.

Previously, inline Images were supported by using iOS's built-in support
for rendering images with an NSAttributedString via NSTextAttachment.
However, NSAttributedString doesn't support rendering arbitrary views.

This change adds support for nesting views within Text by creating one
NSTextAttachment per inline view. The NSTextAttachments act as placeholders.
They are set to be the size of the corresponding view. After the text is
laid out, we query the text system to find out where it has positioned each
NSTextAttachment. We then position the views to be at those locations.

This commit also contains a change in `RCTShadowText.m`
`_setParagraphStyleOnAttributedString:heightOfTallestSubview:`. It now only sets
`lineHeight`, `textAlign`, and `writingDirection` when they've actua
Closes https://github.com/facebook/react-native/pull/7304

Reviewed By: javache

Differential Revision: D3365373

Pulled By: nicklockwood

fbshipit-source-id: 66d149eb80c5c6725311e1e46d7323eec086ce64
This commit is contained in:
Adam Comella
2016-05-31 10:18:37 -07:00
committed by Facebook Github Bot 5
parent 5136d95f2c
commit 486dbe4e8f
16 changed files with 209 additions and 226 deletions
-12
View File
@@ -201,10 +201,6 @@ var Image = React.createClass({
validAttributes: ReactNativeViewAttributes.UIView
},
contextTypes: {
isInAParentText: React.PropTypes.bool
},
render: function() {
var source = resolveAssetSource(this.props.source) || {};
var {width, height, uri} = source;
@@ -225,13 +221,6 @@ var Image = React.createClass({
console.warn('The <Image> component requires a `source` property rather than `src`.');
}
if (this.context.isInAParentText) {
RawImage = RCTVirtualImage;
if (!width || !height) {
console.warn('You must specify a width and height for the image %s', uri);
}
}
return (
<RawImage
{...this.props}
@@ -252,7 +241,6 @@ var styles = StyleSheet.create({
var RCTImageView = requireNativeComponent('RCTImageView', Image);
var RCTNetworkImageView = NetworkImageViewManager ? requireNativeComponent('RCTNetworkImageView', Image) : RCTImageView;
var RCTVirtualImage = requireNativeComponent('RCTVirtualImage', Image);
module.exports = Image;
@@ -44,10 +44,6 @@
134B00A11B54232B00EC8DFB /* RCTImageUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageUtils.m; sourceTree = "<group>"; };
139A38821C4D57AD00862840 /* RCTResizeMode.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTResizeMode.h; sourceTree = "<group>"; };
139A38831C4D587C00862840 /* RCTResizeMode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTResizeMode.m; sourceTree = "<group>"; };
13EF7F071BC42D4E003F47DD /* RCTShadowVirtualImage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTShadowVirtualImage.h; sourceTree = "<group>"; };
13EF7F081BC42D4E003F47DD /* RCTShadowVirtualImage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTShadowVirtualImage.m; sourceTree = "<group>"; };
13EF7F091BC42D4E003F47DD /* RCTVirtualImageManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTVirtualImageManager.h; sourceTree = "<group>"; };
13EF7F0A1BC42D4E003F47DD /* RCTVirtualImageManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTVirtualImageManager.m; sourceTree = "<group>"; };
13EF7F7D1BC825B1003F47DD /* RCTXCAssetImageLoader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTXCAssetImageLoader.h; sourceTree = "<group>"; };
13EF7F7E1BC825B1003F47DD /* RCTXCAssetImageLoader.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTXCAssetImageLoader.m; sourceTree = "<group>"; };
143879361AAD32A300F088A5 /* RCTImageLoader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTImageLoader.h; sourceTree = "<group>"; };
@@ -95,10 +91,6 @@
35123E6A1B59C99D00EBAD80 /* RCTImageStoreManager.m */,
134B00A01B54232B00EC8DFB /* RCTImageUtils.h */,
134B00A11B54232B00EC8DFB /* RCTImageUtils.m */,
13EF7F071BC42D4E003F47DD /* RCTShadowVirtualImage.h */,
13EF7F081BC42D4E003F47DD /* RCTShadowVirtualImage.m */,
13EF7F091BC42D4E003F47DD /* RCTVirtualImageManager.h */,
13EF7F0A1BC42D4E003F47DD /* RCTVirtualImageManager.m */,
58B5115E1A9E6B3D00147676 /* Products */,
);
indentWidth = 2;
@@ -169,7 +161,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13EF7F0C1BC42D4E003F47DD /* RCTVirtualImageManager.m in Sources */,
35123E6B1B59C99D00EBAD80 /* RCTImageStoreManager.m in Sources */,
1304D5AC1AA8C4A30002E2BE /* RCTImageViewManager.m in Sources */,
1304D5B21AA8C50D0002E2BE /* RCTGIFImageDecoder.m in Sources */,
@@ -178,7 +169,6 @@
139A38841C4D587C00862840 /* RCTResizeMode.m in Sources */,
1304D5AB1AA8C4A30002E2BE /* RCTImageView.m in Sources */,
EEF314721C9B0DD30049118E /* RCTImageBlurUtils.m in Sources */,
13EF7F0B1BC42D4E003F47DD /* RCTShadowVirtualImage.m in Sources */,
13EF7F7F1BC825B1003F47DD /* RCTXCAssetImageLoader.m in Sources */,
134B00A21B54232B00EC8DFB /* RCTImageUtils.m in Sources */,
);
+1 -2
View File
@@ -8,13 +8,12 @@
*/
#import <UIKit/UIKit.h>
#import "RCTImageComponent.h"
#import "RCTResizeMode.h"
@class RCTBridge;
@class RCTImageSource;
@interface RCTImageView : UIImageView <RCTImageComponent>
@interface RCTImageView : UIImageView
- (instancetype)initWithBridge:(RCTBridge *)bridge NS_DESIGNATED_INITIALIZER;
-28
View File
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2015-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 "RCTShadowView.h"
#import "RCTImageComponent.h"
#import "RCTImageSource.h"
#import "RCTResizeMode.h"
@class RCTBridge;
/**
* Shadow image component, used for embedding images in non-view contexts such
* as text. This is NOT used for ordinary <Image> views.
*/
@interface RCTShadowVirtualImage : RCTShadowView <RCTImageComponent>
- (instancetype)initWithBridge:(RCTBridge *)bridge;
@property (nonatomic, strong) RCTImageSource *source;
@property (nonatomic, assign) RCTResizeMode resizeMode;
@end
-82
View File
@@ -1,82 +0,0 @@
/**
* Copyright (c) 2015-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 "RCTShadowVirtualImage.h"
#import "RCTImageLoader.h"
#import "RCTImageUtils.h"
#import "RCTBridge.h"
#import "RCTConvert.h"
#import "RCTUIManager.h"
#import "RCTUtils.h"
@implementation RCTShadowVirtualImage
{
RCTBridge *_bridge;
RCTImageLoaderCancellationBlock _cancellationBlock;
}
@synthesize image = _image;
- (instancetype)initWithBridge:(RCTBridge *)bridge
{
if ((self = [super init])) {
_bridge = bridge;
}
return self;
}
RCT_NOT_IMPLEMENTED(-(instancetype)init)
- (void)didSetProps:(NSArray<NSString *> *)changedProps
{
[super didSetProps:changedProps];
if (changedProps.count == 0) {
// No need to reload image
return;
}
// Cancel previous request
if (_cancellationBlock) {
_cancellationBlock();
}
CGSize imageSize = {
RCTZeroIfNaN(self.width),
RCTZeroIfNaN(self.height),
};
__weak RCTShadowVirtualImage *weakSelf = self;
_cancellationBlock = [_bridge.imageLoader loadImageWithTag:_source.imageURL.absoluteString
size:imageSize
scale:RCTScreenScale()
resizeMode:_resizeMode
progressBlock:nil
completionBlock:^(NSError *error, UIImage *image) {
dispatch_async(_bridge.uiManager.methodQueue, ^{
RCTShadowVirtualImage *strongSelf = weakSelf;
if (![_source isEqual:strongSelf.source]) {
// Bail out if source has changed since we started loading
return;
}
strongSelf->_image = image;
[strongSelf dirtyText];
});
}];
}
- (void)dealloc
{
if (_cancellationBlock) {
_cancellationBlock();
}
}
@end
-14
View File
@@ -1,14 +0,0 @@
/**
* Copyright (c) 2015-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 "RCTViewManager.h"
@interface RCTVirtualImageManager : RCTViewManager
@end
-25
View File
@@ -1,25 +0,0 @@
/**
* Copyright (c) 2015-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 "RCTVirtualImageManager.h"
#import "RCTShadowVirtualImage.h"
@implementation RCTVirtualImageManager
RCT_EXPORT_MODULE()
- (RCTShadowView *)shadowView
{
return [[RCTShadowVirtualImage alloc] initWithBridge:self.bridge];
}
RCT_EXPORT_SHADOW_PROPERTY(source, RCTImageSource)
RCT_EXPORT_SHADOW_PROPERTY(resizeMode, UIViewContentMode)
@end
+92 -29
View File
@@ -13,12 +13,12 @@
#import "RCTUIManager.h"
#import "RCTBridge.h"
#import "RCTConvert.h"
#import "RCTImageComponent.h"
#import "RCTLog.h"
#import "RCTShadowRawText.h"
#import "RCTText.h"
#import "RCTUtils.h"
NSString *const RCTShadowViewAttributeName = @"RCTShadowViewAttributeName";
NSString *const RCTIsHighlightedAttributeName = @"IsHighlightedAttributeName";
NSString *const RCTReactTagAttributeName = @"ReactTagAttributeName";
@@ -114,6 +114,45 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
[self dirtyPropagation];
}
- (void)applyLayoutToChildren:(css_node_t *)node
viewsWithNewFrame:(NSMutableSet<RCTShadowView *> *)viewsWithNewFrame
absolutePosition:(CGPoint)absolutePosition
{
// Run layout on subviews.
NSTextStorage *textStorage = [self buildTextStorageForWidth:self.frame.size.width widthMode:CSS_MEASURE_MODE_EXACTLY];
NSLayoutManager *layoutManager = textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;
NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:NULL];
[layoutManager.textStorage enumerateAttribute:RCTShadowViewAttributeName inRange:characterRange options:0 usingBlock:^(RCTShadowView *child, NSRange range, BOOL *_) {
if (child) {
css_node_t *childNode = child.cssNode;
float width = childNode->style.dimensions[CSS_WIDTH];
float height = childNode->style.dimensions[CSS_HEIGHT];
if (isUndefined(width) || isUndefined(height)) {
RCTLogError(@"Views nested within a <Text> must have a width and height");
}
UIFont *font = [textStorage attribute:NSFontAttributeName atIndex:range.location effectiveRange:nil];
CGRect glyphRect = [layoutManager boundingRectForGlyphRange:range inTextContainer:textContainer];
CGRect childFrame = {{
RCTRoundPixelValue(glyphRect.origin.x),
RCTRoundPixelValue(glyphRect.origin.y + glyphRect.size.height - height + font.descender)
}, {
RCTRoundPixelValue(width),
RCTRoundPixelValue(height)
}};
NSRange truncatedGlyphRange = [layoutManager truncatedGlyphRangeInLineFragmentForGlyphAtIndex:range.location];
BOOL childIsTruncated = NSIntersectionRange(range, truncatedGlyphRange).length != 0;
[child collectUpdatedFrames:viewsWithNewFrame
withFrame:childFrame
hidden:childIsTruncated
absolutePosition:absolutePosition];
}
}];
}
- (NSTextStorage *)buildTextStorageForWidth:(CGFloat)width widthMode:(css_measure_mode_t)widthMode
{
if (_cachedTextStorage && width == _cachedTextStorageWidth && widthMode == _cachedTextStorageWidthMode) {
@@ -199,33 +238,48 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
_effectiveLetterSpacing = letterSpacing.doubleValue;
UIFont *font = [RCTConvert UIFont:nil withFamily:fontFamily
size:fontSize weight:fontWeight style:fontStyle
scaleMultiplier:_allowFontScaling ? _fontSizeMultiplier : 1.0];
CGFloat heightOfTallestSubview = 0.0;
NSMutableAttributedString *attributedString = [NSMutableAttributedString new];
for (RCTShadowView *child in [self reactSubviews]) {
if ([child isKindOfClass:[RCTShadowText class]]) {
RCTShadowText *shadowText = (RCTShadowText *)child;
[attributedString appendAttributedString:
[shadowText _attributedStringWithFontFamily:fontFamily
fontSize:fontSize
fontWeight:fontWeight
fontStyle:fontStyle
letterSpacing:letterSpacing
useBackgroundColor:YES
foregroundColor:shadowText.color ?: foregroundColor
backgroundColor:shadowText.backgroundColor ?: backgroundColor
opacity:opacity * shadowText.opacity]];
[shadowText _attributedStringWithFontFamily:fontFamily
fontSize:fontSize
fontWeight:fontWeight
fontStyle:fontStyle
letterSpacing:letterSpacing
useBackgroundColor:YES
foregroundColor:shadowText.color ?: foregroundColor
backgroundColor:shadowText.backgroundColor ?: backgroundColor
opacity:opacity * shadowText.opacity]];
[child setTextComputed];
} else if ([child isKindOfClass:[RCTShadowRawText class]]) {
RCTShadowRawText *shadowRawText = (RCTShadowRawText *)child;
[attributedString appendAttributedString:[[NSAttributedString alloc] initWithString:shadowRawText.text ?: @""]];
} else if ([child conformsToProtocol:@protocol(RCTImageComponent)]) {
NSTextAttachment *imageAttachment = [NSTextAttachment new];
imageAttachment.image = ((id<RCTImageComponent>)child).image;
imageAttachment.bounds = (CGRect){CGPointZero, {RCTZeroIfNaN(child.width), RCTZeroIfNaN(child.height)}};
[attributedString appendAttributedString:[NSAttributedString attributedStringWithAttachment:imageAttachment]];
[child setTextComputed];
} else {
RCTLogError(@"<Text> can't have any children except <Text>, <Image> or raw strings");
float width = child.cssNode->style.dimensions[CSS_WIDTH];
float height = child.cssNode->style.dimensions[CSS_HEIGHT];
if (isUndefined(width) || isUndefined(height)) {
RCTLogError(@"Views nested within a <Text> must have a width and height");
}
NSTextAttachment *attachment = [NSTextAttachment new];
attachment.bounds = (CGRect){CGPointZero, {width, height}};
NSMutableAttributedString *attachmentString = [NSMutableAttributedString new];
[attachmentString appendAttributedString:[NSAttributedString attributedStringWithAttachment:attachment]];
[attachmentString addAttribute:RCTShadowViewAttributeName value:child range:(NSRange){0, attachmentString.length}];
[attributedString appendAttributedString:attachmentString];
if (height > heightOfTallestSubview) {
heightOfTallestSubview = height;
}
// Don't call setTextComputed on this child. RCTTextManager takes care of
// processing inline UIViews.
}
[child setTextComputed];
}
[self _addAttribute:NSForegroundColorAttributeName
@@ -241,13 +295,10 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
toAttributedString:attributedString];
}
UIFont *font = [RCTConvert UIFont:nil withFamily:fontFamily
size:fontSize weight:fontWeight style:fontStyle
scaleMultiplier:_allowFontScaling ? _fontSizeMultiplier : 1.0];
[self _addAttribute:NSFontAttributeName withValue:font toAttributedString:attributedString];
[self _addAttribute:NSKernAttributeName withValue:letterSpacing toAttributedString:attributedString];
[self _addAttribute:RCTReactTagAttributeName withValue:self.reactTag toAttributedString:attributedString];
[self _setParagraphStyleOnAttributedString:attributedString];
[self _setParagraphStyleOnAttributedString:attributedString heightOfTallestSubview:heightOfTallestSubview];
// create a non-mutable attributedString for use by the Text system which avoids copies down the line
_cachedAttributedString = [[NSAttributedString alloc] initWithAttributedString:attributedString];
@@ -270,6 +321,7 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
* varying lineHeights, we simply take the max.
*/
- (void)_setParagraphStyleOnAttributedString:(NSMutableAttributedString *)attributedString
heightOfTallestSubview:(CGFloat)heightOfTallestSubview
{
// check if we have lineHeight set on self
__block BOOL hasParagraphStyle = NO;
@@ -277,9 +329,7 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
hasParagraphStyle = YES;
}
if (!_lineHeight) {
self.lineHeight = 0.0;
}
__block float newLineHeight = _lineHeight ?: 0.0;
CGFloat fontSizeMultiplier = _allowFontScaling ? _fontSizeMultiplier : 1.0;
@@ -288,15 +338,25 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
if (value) {
NSParagraphStyle *paragraphStyle = (NSParagraphStyle *)value;
CGFloat maximumLineHeight = round(paragraphStyle.maximumLineHeight / fontSizeMultiplier);
if (maximumLineHeight > self.lineHeight) {
self.lineHeight = maximumLineHeight;
if (maximumLineHeight > newLineHeight) {
newLineHeight = maximumLineHeight;
}
hasParagraphStyle = YES;
}
}];
self.textAlign = _textAlign ?: NSTextAlignmentNatural;
self.writingDirection = _writingDirection ?: NSWritingDirectionNatural;
if (self.lineHeight != newLineHeight) {
self.lineHeight = newLineHeight;
}
NSTextAlignment newTextAlign = _textAlign ?: NSTextAlignmentNatural;
if (self.textAlign != newTextAlign) {
self.textAlign = newTextAlign;
}
NSWritingDirection newWritingDirection = _writingDirection ?: NSWritingDirectionNatural;
if (self.writingDirection != newWritingDirection) {
self.writingDirection = newWritingDirection;
}
// if we found anything, set it :D
if (hasParagraphStyle) {
@@ -304,6 +364,9 @@ static css_dim_t RCTMeasure(void *context, float width, css_measure_mode_t width
paragraphStyle.alignment = _textAlign;
paragraphStyle.baseWritingDirection = _writingDirection;
CGFloat lineHeight = round(_lineHeight * fontSizeMultiplier);
if (heightOfTallestSubview > lineHeight) {
lineHeight = ceilf(heightOfTallestSubview);
}
paragraphStyle.minimumLineHeight = lineHeight;
paragraphStyle.maximumLineHeight = lineHeight;
[attributedString addAttribute:NSParagraphStyleAttributeName
+26
View File
@@ -13,6 +13,17 @@
#import "RCTUtils.h"
#import "UIView+React.h"
static void collectNonTextDescendants(RCTText *view, NSMutableArray *nonTextDescendants)
{
for (UIView *child in view.reactSubviews) {
if ([child isKindOfClass:[RCTText class]]) {
collectNonTextDescendants((RCTText *)child, nonTextDescendants);
} else if (!CGRectEqualToRect(child.frame, CGRectZero)) {
[nonTextDescendants addObject:child];
}
}
}
@implementation RCTText
{
NSTextStorage *_textStorage;
@@ -76,6 +87,21 @@
{
if (_textStorage != textStorage) {
_textStorage = textStorage;
NSMutableArray *nonTextDescendants = [NSMutableArray new];
collectNonTextDescendants(self, nonTextDescendants);
NSArray *subviews = self.subviews;
if (![subviews isEqualToArray:nonTextDescendants]) {
for (UIView *child in subviews) {
if (![nonTextDescendants containsObject:child]) {
[child removeFromSuperview];
}
}
for (UIView *child in nonTextDescendants) {
[self addSubview:child];
}
}
[self setNeedsDisplay];
}
}
+13
View File
@@ -20,6 +20,18 @@
#import "RCTTextView.h"
#import "UIView+React.h"
static void collectDirtyNonTextDescendants(RCTShadowText *shadowView, NSMutableArray *nonTextDescendants) {
for (RCTShadowView *child in shadowView.reactSubviews) {
if ([child isKindOfClass:[RCTShadowText class]]) {
collectDirtyNonTextDescendants((RCTShadowText *)child, nonTextDescendants);
} else if ([child isKindOfClass:[RCTShadowRawText class]]) {
// no-op
} else if ([child isTextDirty]) {
[nonTextDescendants addObject:child];
}
}
}
@interface RCTShadowText (Private)
- (NSTextStorage *)buildTextStorageForWidth:(CGFloat)width widthMode:(css_measure_mode_t)widthMode;
@@ -85,6 +97,7 @@ RCT_EXPORT_SHADOW_PROPERTY(textShadowColor, UIColor)
if ([shadowView isKindOfClass:[RCTShadowText class]]) {
((RCTShadowText *)shadowView).fontSizeMultiplier = self.bridge.accessibilityManager.multiplier;
[(RCTShadowText *)shadowView recomputeText];
collectDirtyNonTextDescendants((RCTShadowText *)shadowView, queue);
} else if ([shadowView isKindOfClass:[RCTShadowRawText class]]) {
RCTLogError(@"Raw text cannot be used outside of a <Text> tag. Not rendering string: '%@'",
[(RCTShadowRawText *)shadowView text]);