Text: send metrics after rendering (iOS)

Summary: This adds a callback for <Text> to get metrics about the rendered text. It's divided by line but that could be changed to "fragments" (which makes more sense for multi-lingual). Right now by line is convenient as you frequently want to know where the first and last line end (though we could make this work with fragments I suppose).

Reviewed By: shergin

Differential Revision: D9440914

fbshipit-source-id: bb011bb7a52438380d3f604ffe7019b98c18d978
This commit is contained in:
Mehdi Mulani 2018-08-24 13:28:36 -07:00 committed by Facebook Github Bot
parent 1f96ff62cf
commit 64a52532fe
6 changed files with 372 additions and 1 deletions

View File

@ -64,6 +64,12 @@ const viewConfig = {
adjustsFontSizeToFit: true,
minimumFontScale: true,
textBreakStrategy: true,
onTextLayout: true,
},
directEventTypes: {
topTextLayout: {
registrationName: 'onTextLayout',
},
},
uiViewClassName: 'RCTText',
};

View File

@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) NSLineBreakMode lineBreakMode;
@property (nonatomic, assign) BOOL adjustsFontSizeToFit;
@property (nonatomic, assign) CGFloat minimumFontScale;
@property (nonatomic, copy) RCTDirectEventBlock onTextLayout;
- (void)uiManagerWillPerformMounting;

View File

@ -304,6 +304,35 @@
[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

View File

@ -5,6 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
#import <React/RCTComponent.h>
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN

View File

@ -34,6 +34,8 @@ RCT_REMAP_SHADOW_PROPERTY(ellipsizeMode, lineBreakMode, NSLineBreakMode)
RCT_REMAP_SHADOW_PROPERTY(adjustsFontSizeToFit, adjustsFontSizeToFit, BOOL)
RCT_REMAP_SHADOW_PROPERTY(minimumFontScale, minimumFontScale, CGFloat)
RCT_EXPORT_SHADOW_PROPERTY(onTextLayout, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
- (void)setBridge:(RCTBridge *)bridge

View File

@ -14,7 +14,15 @@ const Platform = require('Platform');
var React = require('react');
var createReactClass = require('create-react-class');
var ReactNative = require('react-native');
var {Image, Text, TextInput, View, LayoutAnimation, Button} = ReactNative;
var {
Image,
Text,
TextInput,
View,
LayoutAnimation,
Button,
Picker,
} = ReactNative;
type TextAlignExampleRTLState = {|
isRTL: boolean,
@ -275,6 +283,311 @@ class TextBaseLineLayoutExample extends React.Component<*, *> {
}
}
class TextRenderInfoExample extends React.Component<*, *> {
state = {
textMetrics: {
x: 0,
y: 0,
width: 0,
height: 0,
capHeight: 0,
descender: 0,
ascender: 0,
xHeight: 0,
},
numberOfTextBlocks: 1,
fontSize: 14,
};
render() {
const topOfBox =
this.state.textMetrics.y +
this.state.textMetrics.height -
(this.state.textMetrics.descender + this.state.textMetrics.capHeight);
return (
<View>
<View>
<View
style={{
position: 'absolute',
left: this.state.textMetrics.x + this.state.textMetrics.width,
top: topOfBox,
width: 5,
height: Math.ceil(
this.state.textMetrics.capHeight -
this.state.textMetrics.xHeight,
),
backgroundColor: 'red',
}}
/>
<View
style={{
position: 'absolute',
left: this.state.textMetrics.x + this.state.textMetrics.width,
top:
topOfBox +
(this.state.textMetrics.capHeight -
this.state.textMetrics.xHeight),
width: 5,
height: Math.ceil(this.state.textMetrics.xHeight),
backgroundColor: 'green',
}}
/>
<Text
style={{fontSize: this.state.fontSize}}
onTextLayout={event => {
const {lines} = event.nativeEvent;
if (lines.length > 0) {
this.setState({textMetrics: lines[lines.length - 1]});
}
}}>
{new Array(this.state.numberOfTextBlocks)
.fill('A tiny block of text.')
.join(' ')}
</Text>
</View>
<Text
onPress={() =>
this.setState({
numberOfTextBlocks: this.state.numberOfTextBlocks + 1,
})
}>
More text
</Text>
<Text
onPress={() => this.setState({fontSize: this.state.fontSize + 1})}>
Increase size
</Text>
<Text
onPress={() => this.setState({fontSize: this.state.fontSize - 1})}>
Decrease size
</Text>
</View>
);
}
}
class TextWithCapBaseBox extends React.Component<*, *> {
state = {
textMetrics: {
x: 0,
y: 0,
width: 0,
height: 0,
capHeight: 0,
descender: 0,
ascender: 0,
xHeight: 0,
},
};
render() {
return (
<Text
onTextLayout={event => {
const {lines} = event.nativeEvent;
if (lines.length > 0) {
this.setState({textMetrics: lines[0]});
}
}}
style={[
{
marginTop: Math.ceil(
-(
this.state.textMetrics.ascender -
this.state.textMetrics.capHeight
),
),
marginBottom: Math.ceil(-this.state.textMetrics.descender),
},
this.props.style,
]}>
{this.props.children}
</Text>
);
}
}
class TextLegend extends React.Component<*, *> {
state = {
textMetrics: [],
language: 'english',
};
render() {
const PANGRAMS = {
arabic:
'صِف خَلقَ خَودِ كَمِثلِ الشَمسِ إِذ بَزَغَت — يَحظى الضَجيعُ بِها نَجلاءَ مِعطارِ',
chinese: 'Innovation in China 中国智造,慧及全球 0123456789',
english: 'The quick brown fox jumps over the lazy dog.',
emoji: '🙏🏾🚗💩😍🤯👩🏽‍🔧🇨🇦💯',
german: 'Falsches Üben von Xylophonmusik quält jeden größeren Zwerg',
greek: 'Ταχίστη αλώπηξ βαφής ψημένη γη, δρασκελίζει υπέρ νωθρού κυνός',
hebrew: 'דג סקרן שט בים מאוכזב ולפתע מצא חברה',
hindi:
'ऋषियों को सताने वाले दुष्ट राक्षसों के राजा रावण का सर्वनाश करने वाले विष्णुवतार भगवान श्रीराम, अयोध्या के महाराज दशरथ के बड़े सपुत्र थे।',
igbo:
'Nne, nna, wepụ helụjọ dum nime ọzụzụ ụmụ, vufesi obi nye Chukwu, ṅụrịanụ, gbakọọnụ kpaa, kwee ya ka o guzoshie ike; ọ ghaghị ito, nwapụta ezi agwa',
irish:
'Dfhuascail Íosa Úrmhac na hÓighe Beannaithe pór Éava agus Ádhaimh',
japanese:
'色は匂へど 散りぬるを 我が世誰ぞ 常ならむ 有為の奥山 今日越えて 浅き夢見じ 酔ひもせず',
korean:
'키스의 고유조건은 입술끼리 만나야 하고 특별한 기술은 필요치 않다',
norwegian:
'Vår sære Zulu fra badeøya spilte jo whist og quickstep i min taxi.',
polish: 'Jeżu klątw, spłódź Finom część gry hańb!',
romanian: 'Muzicologă în bej vând whisky și tequila, preț fix.',
russian: 'Эх, чужак, общий съём цен шляп (юфть) вдрызг!',
swedish: 'Yxskaftbud, ge vår WC-zonmö IQ-hjälp.',
thai:
'เป็นมนุษย์สุดประเสริฐเลิศคุณค่า กว่าบรรดาฝูงสัตว์เดรัจฉาน จงฝ่าฟันพัฒนาวิชาการ อย่าล้างผลาญฤๅเข่นฆ่าบีฑาใคร ไม่ถือโทษโกรธแช่งซัดฮึดฮัดด่า หัดอภัยเหมือนกีฬาอัชฌาสัย ปฏิบัติประพฤติกฎกำหนดใจ พูดจาให้จ๊ะๆ จ๋าๆ น่าฟังเอยฯ',
};
return (
<View>
<Picker
selectedValue={this.state.language}
onValueChange={itemValue => this.setState({language: itemValue})}>
{Object.keys(PANGRAMS).map(x => (
<Picker.Item
label={x[0].toUpperCase() + x.substring(1)}
key={x}
value={x}
/>
))}
</Picker>
<View>
{this.state.textMetrics.map(
({
x,
y,
width,
height,
capHeight,
ascender,
descender,
xHeight,
}) => {
return [
<View
key="baseline view"
style={{
top: y + ascender,
height: 1,
left: 0,
right: 0,
position: 'absolute',
backgroundColor: 'red',
}}
/>,
<Text
key="baseline text"
style={{
top: y + ascender,
right: 0,
position: 'absolute',
color: 'red',
}}>
Baseline
</Text>,
<View
key="capheight view"
style={{
top: y + ascender - capHeight,
height: 1,
left: 0,
right: 0,
position: 'absolute',
backgroundColor: 'green',
}}
/>,
<Text
key="capheight text"
style={{
top: y + ascender - capHeight,
right: 0,
position: 'absolute',
color: 'green',
}}>
Capheight
</Text>,
<View
key="xheight view"
style={{
top: y + ascender - xHeight,
height: 1,
left: 0,
right: 0,
position: 'absolute',
backgroundColor: 'blue',
}}
/>,
<Text
key="xheight text"
style={{
top: y + ascender - xHeight,
right: 0,
position: 'absolute',
color: 'blue',
}}>
X-height
</Text>,
<View
key="descender view"
style={{
top: y + ascender + descender,
height: 1,
left: 0,
right: 0,
position: 'absolute',
backgroundColor: 'orange',
}}
/>,
<Text
key="descender text"
style={{
top: y + ascender + descender,
right: 0,
position: 'absolute',
color: 'orange',
}}>
Descender
</Text>,
<View
key="end of text view"
style={{
top: y,
height: height,
width: 1,
left: x + width,
position: 'absolute',
backgroundColor: 'brown',
}}
/>,
<Text
key="end of text text"
style={{
top: y,
left: x + width + 5,
position: 'absolute',
color: 'brown',
}}>
End of text
</Text>,
];
},
)}
<Text
onTextLayout={event =>
this.setState({textMetrics: event.nativeEvent.lines})
}
style={{fontSize: 50}}>
{PANGRAMS[this.state.language]}
</Text>
</View>
</View>
);
}
}
exports.title = '<Text>';
exports.description = 'Base component for rendering styled text.';
exports.displayName = 'TextExample';
@ -290,6 +603,24 @@ exports.examples = [
);
},
},
{
title: 'Text metrics',
render: function() {
return <TextRenderInfoExample />;
},
},
{
title: 'Text metrics legend',
render: () => <TextLegend />,
},
{
title: 'Baseline capheight box',
render: () => (
<View style={{backgroundColor: 'red'}}>
<TextWithCapBaseBox>Some example text.</TextWithCapBaseBox>
</View>
),
},
{
title: 'Padding',
render: function() {