From 64a52532fe88b482ae4b998133d340e6e1141a0f Mon Sep 17 00:00:00 2001 From: Mehdi Mulani Date: Fri, 24 Aug 2018 13:28:36 -0700 Subject: [PATCH] Text: send metrics after rendering (iOS) Summary: This adds a callback for 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 --- Libraries/Text/Text.js | 6 + Libraries/Text/Text/RCTTextShadowView.h | 1 + Libraries/Text/Text/RCTTextShadowView.m | 29 ++ Libraries/Text/Text/RCTTextView.h | 2 + Libraries/Text/Text/RCTTextViewManager.m | 2 + RNTester/js/TextExample.ios.js | 333 ++++++++++++++++++++++- 6 files changed, 372 insertions(+), 1 deletion(-) diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index 2a0a939f87..66dfc16f53 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -64,6 +64,12 @@ const viewConfig = { adjustsFontSizeToFit: true, minimumFontScale: true, textBreakStrategy: true, + onTextLayout: true, + }, + directEventTypes: { + topTextLayout: { + registrationName: 'onTextLayout', + }, }, uiViewClassName: 'RCTText', }; diff --git a/Libraries/Text/Text/RCTTextShadowView.h b/Libraries/Text/Text/RCTTextShadowView.h index d62748df81..b434ccf718 100644 --- a/Libraries/Text/Text/RCTTextShadowView.h +++ b/Libraries/Text/Text/RCTTextShadowView.h @@ -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; diff --git a/Libraries/Text/Text/RCTTextShadowView.m b/Libraries/Text/Text/RCTTextShadowView.m index 1266f8afd7..57a7f759a6 100644 --- a/Libraries/Text/Text/RCTTextShadowView.m +++ b/Libraries/Text/Text/RCTTextShadowView.m @@ -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 diff --git a/Libraries/Text/Text/RCTTextView.h b/Libraries/Text/Text/RCTTextView.h index b8923b1ef5..b3f4b32809 100644 --- a/Libraries/Text/Text/RCTTextView.h +++ b/Libraries/Text/Text/RCTTextView.h @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +#import + #import NS_ASSUME_NONNULL_BEGIN diff --git a/Libraries/Text/Text/RCTTextViewManager.m b/Libraries/Text/Text/RCTTextViewManager.m index a729e64489..7dafffc4aa 100644 --- a/Libraries/Text/Text/RCTTextViewManager.m +++ b/Libraries/Text/Text/RCTTextViewManager.m @@ -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 diff --git a/RNTester/js/TextExample.ios.js b/RNTester/js/TextExample.ios.js index b0a1ccd696..96a5325e44 100644 --- a/RNTester/js/TextExample.ios.js +++ b/RNTester/js/TextExample.ios.js @@ -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 ( + + + + + { + 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(' ')} + + + + this.setState({ + numberOfTextBlocks: this.state.numberOfTextBlocks + 1, + }) + }> + More text + + this.setState({fontSize: this.state.fontSize + 1})}> + Increase size + + this.setState({fontSize: this.state.fontSize - 1})}> + Decrease size + + + ); + } +} + +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 ( + { + 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} + + ); + } +} + +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ụ he’l’ụjọ dum n’ime ọ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: + 'D’fhuascail Í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 ( + + this.setState({language: itemValue})}> + {Object.keys(PANGRAMS).map(x => ( + + ))} + + + {this.state.textMetrics.map( + ({ + x, + y, + width, + height, + capHeight, + ascender, + descender, + xHeight, + }) => { + return [ + , + + Baseline + , + , + + Capheight + , + , + + X-height + , + , + + Descender + , + , + + End of text + , + ]; + }, + )} + + this.setState({textMetrics: event.nativeEvent.lines}) + } + style={{fontSize: 50}}> + {PANGRAMS[this.state.language]} + + + + ); + } +} exports.title = ''; exports.description = 'Base component for rendering styled text.'; exports.displayName = 'TextExample'; @@ -290,6 +603,24 @@ exports.examples = [ ); }, }, + { + title: 'Text metrics', + render: function() { + return ; + }, + }, + { + title: 'Text metrics legend', + render: () => , + }, + { + title: 'Baseline capheight box', + render: () => ( + + Some example text. + + ), + }, { title: 'Padding', render: function() {