From 4472a0714f9fb79b4c4c078989fbeec912908bf6 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Wed, 30 Mar 2022 01:38:30 -0700 Subject: [PATCH 01/12] Add numberOfLines parameter to limit number of lines --- README.md | 3 +++ .../amarcruz/rntextsize/RNTextSizeConf.java | 5 +++++ .../amarcruz/rntextsize/RNTextSizeModule.java | 21 +++++++++++++------ index.d.ts | 2 ++ index.js.flow | 2 ++ ios/RNTextSize.m | 18 ++++++++++++---- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9f644fc..152f580 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ In both functions, the text to be measured is required, but the rest of the para - `textBreakStrategy` (Android) - `letterSpacing` - `allowFontScaling` +- `numberOfLines` - `width`: Constraint for automatic line-break based on text-break strategy. In addition, the library includes functions to obtain information about the fonts visible to the App. @@ -100,6 +101,7 @@ allowFontScaling | boolean | true | To respect the user' setting of large letterSpacing | number | (none) | Additional spacing between characters (aka `tracking`).
**Note:** In iOS a zero cancels automatic kerning.
_All iOS, Android with API 21+_ includeFontPadding | boolean | true | Include additional top and bottom padding, to avoid clipping certain characters.
_Android only_ textBreakStrategy | string | 'highQuality' | One of 'simple', 'balanced', or 'highQuality'.
_Android only, with API 23+_ +numberOfLines | number | (none) | Limit the number of lines the text can render on width | number | MAX_INT | Restrict the width. The resulting height will vary depending on the automatic flow of the text. usePreciseWidth | boolean | false | If `true`, the result will include an exact `width` and the `lastLineWidth` property.
You can see the effect of this flag in the [sample App][sample-app]. lineInfoForLine | number | (none) | If `>=0`, the result will include a [lineInfo](#lineinfo) property with information for the required line number. @@ -228,6 +230,7 @@ allowFontScaling | boolean | true letterSpacing | number | (none) includeFontPadding | boolean | true textBreakStrategy | string | 'highQuality' +numberOfLines | number | (none) The result is a Promise that resolves to an array with the height of each block (in _SP_), in the same order in which the blocks were received. diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java index 462ecd9..e42fb70 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java @@ -69,6 +69,7 @@ static boolean supportUpperCaseTransform() { final int fontStyle; final boolean includeFontPadding; final float letterSpacing; + final @Nullable Integer numberOfLines; /** * Proccess the user specs. Set both `allowFontScaling` & `includeFontPadding` to the user @@ -88,6 +89,10 @@ static boolean supportUpperCaseTransform() { // letterSpacing is supported in RN 0.55+ letterSpacing = supportLetterSpacing() ? getFloatOrNaN("letterSpacing") : Float.NaN; + + Integer rawNumberOfLines = getIntOrNull("numberOfLines"); + if (rawNumberOfLines != null && rawNumberOfLines < 0) rawNumberOfLines = null; + numberOfLines = rawNumberOfLines; } boolean has(@Nonnull final String name) { diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java index 088c294..8add736 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java @@ -10,6 +10,7 @@ import android.text.SpannableStringBuilder; import android.text.StaticLayout; import android.text.TextPaint; +import android.text.TextUtils; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -118,13 +119,17 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) { if (layout == null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - layout = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth) + StaticLayout.Builder builder = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setBreakStrategy(conf.getTextBreakStrategy()) .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) .setIncludePad(includeFontPadding) - .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER) - .build(); + .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER); + if (conf.numberOfLines != null) { + builder = builder.setMaxLines(conf.numberOfLines) + .setEllipsize(TextUtils.TruncateAt.END); + } + layout = builder.build(); } else { layout = new StaticLayout( text, @@ -228,13 +233,17 @@ public void flatHeights(@Nullable final ReadableMap specs, final Promise promise sb.replace(0, sb.length(), text); if (Build.VERSION.SDK_INT >= 23) { - layout = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, (int) width) + StaticLayout.Builder builder = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, (int) width) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setBreakStrategy(textBreakStrategy) .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) .setIncludePad(includeFontPadding) - .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER) - .build(); + .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER); + if (conf.numberOfLines != null) { + builder = builder.setMaxLines(conf.numberOfLines) + .setEllipsize(TextUtils.TruncateAt.END); + } + layout = builder.build(); } else { layout = new StaticLayout( sb, diff --git a/index.d.ts b/index.d.ts index 1cb2b53..7a3c847 100644 --- a/index.d.ts +++ b/index.d.ts @@ -72,6 +72,8 @@ declare module "react-native-text-size" { includeFontPadding?: boolean; /** @platform android (SDK 23+) */ textBreakStrategy?: TSTextBreakStrategy; + /** Number of lines to limit the text to */ + numberOfLines?: number; } export type TSFontForStyle = { diff --git a/index.js.flow b/index.js.flow index 3546b14..6f459b0 100644 --- a/index.js.flow +++ b/index.js.flow @@ -72,6 +72,8 @@ export type TSFontSpecs = { includeFontPadding?: boolean, /** @platform android (SDK 23+) */ textBreakStrategy?: TSTextBreakStrategy, + /** Number of lines to limit the text to */ + numberOfLines?: number, } export type TSFontForStyle = { diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 422dd71..2a2e9da 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -6,8 +6,8 @@ #import #else #import "React/RCTConvert.h" // Required when used as a Pod in a Swift project -#import "React/RCTFont.h" -#import "React/RCTUtils.h" +#import +#import #endif #import @@ -99,7 +99,12 @@ - (dispatch_queue_t)methodQueue { NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; textContainer.lineFragmentPadding = 0.0; - textContainer.lineBreakMode = NSLineBreakByClipping; // no maxlines support + textContainer.lineBreakMode = NSLineBreakByClipping; + + const NSInteger numberOfLines = [RCTConvert NSInteger:options[@"numberOfLines"]]; + if (numberOfLines > 0) { + textContainer.maximumNumberOfLines = numberOfLines; + } NSLayoutManager *layoutManager = [NSLayoutManager new]; [layoutManager addTextContainer:textContainer]; @@ -178,7 +183,12 @@ - (dispatch_queue_t)methodQueue { NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; textContainer.lineFragmentPadding = 0.0; - textContainer.lineBreakMode = NSLineBreakByClipping; // no maxlines support + textContainer.lineBreakMode = NSLineBreakByClipping; + + const NSInteger numberOfLines = [RCTConvert NSInteger:options[@"numberOfLines"]]; + if (numberOfLines > 0) { + textContainer.maximumNumberOfLines = numberOfLines; + } NSLayoutManager *layoutManager = [NSLayoutManager new]; [layoutManager addTextContainer:textContainer]; From 748f86b3aca35a4f836c7d45f216d53f479e7471 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Wed, 30 Mar 2022 16:12:28 -0700 Subject: [PATCH 02/12] Add support for lineHeight --- README.md | 6 +- .../rntextsize/RNTextSizeSpannedText.java | 9 ++ index.d.ts | 8 +- index.js.flow | 8 +- ios/RNTextSize.m | 103 ++++++++++++------ 5 files changed, 93 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 152f580..5ed0a67 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,13 @@ In both functions, the text to be measured is required, but the rest of the para - `fontSize` - `fontWeight` - `fontStyle` +- `lineHeight` +- `numberOfLines` - `fontVariant` (iOS) - `includeFontPadding` (Android) - `textBreakStrategy` (Android) - `letterSpacing` - `allowFontScaling` -- `numberOfLines` - `width`: Constraint for automatic line-break based on text-break strategy. In addition, the library includes functions to obtain information about the fonts visible to the App. @@ -96,12 +97,13 @@ fontFamily | string | OS dependent | The default is the same applied by fontWeight | string | 'normal' | On android, numeric ranges has no granularity and '500' to '900' becomes 'bold', but you can use a `fontFamily` of specific weight ("sans-serif-thin", "sans-serif-medium", etc). fontSize | number | 14 | The default font size comes from RN. fontStyle | string | 'normal' | One of "normal" or "italic". +lineHeight | number | (none) | The line height of each line. Defaults to the font size. +numberOfLines | number | (none) | Limit the number of lines the text can render on fontVariant | array | (none) | _iOS only_ allowFontScaling | boolean | true | To respect the user' setting of large fonts (i.e. use SP units). letterSpacing | number | (none) | Additional spacing between characters (aka `tracking`).
**Note:** In iOS a zero cancels automatic kerning.
_All iOS, Android with API 21+_ includeFontPadding | boolean | true | Include additional top and bottom padding, to avoid clipping certain characters.
_Android only_ textBreakStrategy | string | 'highQuality' | One of 'simple', 'balanced', or 'highQuality'.
_Android only, with API 23+_ -numberOfLines | number | (none) | Limit the number of lines the text can render on width | number | MAX_INT | Restrict the width. The resulting height will vary depending on the automatic flow of the text. usePreciseWidth | boolean | false | If `true`, the result will include an exact `width` and the `lastLineWidth` property.
You can see the effect of this flag in the [sample App][sample-app]. lineInfoForLine | number | (none) | If `>=0`, the result will include a [lineInfo](#lineinfo) property with information for the required line number. diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeSpannedText.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeSpannedText.java index 86fce3e..5a37574 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeSpannedText.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeSpannedText.java @@ -9,6 +9,8 @@ import android.text.style.MetricAffectingSpan; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.views.text.CustomLineHeightSpan; +import com.facebook.react.views.text.TextAttributes; import javax.annotation.Nonnull; @@ -49,6 +51,13 @@ static Spannable spannedFromSpecsAndText( new CustomStyleSpan(RNTextSizeConf.getFont(context, conf.fontFamily, conf.fontStyle))); } + if (!Float.isNaN(conf.lineHeight)) { + priority++; + final TextAttributes textAttributes = new TextAttributes(); + textAttributes.setLineHeight(conf.lineHeight); + setSpanOperation(text, end, priority, new CustomLineHeightSpan(textAttributes.getEffectiveLineHeight())); + } + return text; } diff --git a/index.d.ts b/index.d.ts index 7a3c847..bb41cd0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -64,6 +64,12 @@ declare module "react-native-text-size" { fontSize?: number; fontStyle?: TSFontStyle; fontWeight?: TSFontWeight; + lineHeight?: number; + /** + * Number of lines to limit the text to. Corresponds to the `numberOfLines` + * prop on `` + */ + numberOfLines?: number; /** @platform ios */ fontVariant?: Array; /** iOS all, Android SDK 21+ with RN 0.55+ */ @@ -72,8 +78,6 @@ declare module "react-native-text-size" { includeFontPadding?: boolean; /** @platform android (SDK 23+) */ textBreakStrategy?: TSTextBreakStrategy; - /** Number of lines to limit the text to */ - numberOfLines?: number; } export type TSFontForStyle = { diff --git a/index.js.flow b/index.js.flow index 6f459b0..5b7985b 100644 --- a/index.js.flow +++ b/index.js.flow @@ -64,6 +64,12 @@ export type TSFontSpecs = { fontSize?: number, fontStyle?: TSFontStyle, fontWeight?: TSFontWeight, + lineHeight?: number; + /** + * Number of lines to limit the text to. Corresponds to the `numberOfLines` + * prop on `` + */ + numberOfLines?: number, /** @platform ios */ fontVariant?: Array, /** iOS all, Android SDK 21+ with RN 0.55+ */ @@ -72,8 +78,6 @@ export type TSFontSpecs = { includeFontPadding?: boolean, /** @platform android (SDK 23+) */ textBreakStrategy?: TSTextBreakStrategy, - /** Number of lines to limit the text to */ - numberOfLines?: number, } export type TSFontForStyle = { diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 2a2e9da..2382f9e 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -86,25 +86,15 @@ - (dispatch_queue_t)methodQueue { return; } - // Allow the user to specify the width or height (both optionals). - const CGFloat optWidth = CGFloatValueFrom(options[@"width"]); - const CGFloat maxWidth = isnan(optWidth) || isinf(optWidth) ? CGFLOAT_MAX : optWidth; - const CGSize maxSize = CGSizeMake(maxWidth, CGFLOAT_MAX); - - // Create attributes for the font and the optional letter spacing. + const CGSize maxSize = [self maxSizeFromOptions:options]; const CGFloat letterSpacing = CGFloatValueFrom(options[@"letterSpacing"]); - NSDictionary *const attributes = isnan(letterSpacing) - ? @{NSFontAttributeName: font} - : @{NSFontAttributeName: font, NSKernAttributeName: @(letterSpacing)}; - - NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; - textContainer.lineFragmentPadding = 0.0; - textContainer.lineBreakMode = NSLineBreakByClipping; - const NSInteger numberOfLines = [RCTConvert NSInteger:options[@"numberOfLines"]]; - if (numberOfLines > 0) { - textContainer.maximumNumberOfLines = numberOfLines; - } + NSTextContainer *const textContainer = + [self textContainerFromOptions:options withMaxSize:maxSize]; + NSDictionary *const attributes = + [self textStorageAttributesFromOptions:options + withFont:font + withLetterSpacing:letterSpacing]; NSLayoutManager *layoutManager = [NSLayoutManager new]; [layoutManager addTextContainer:textContainer]; @@ -171,24 +161,15 @@ - (dispatch_queue_t)methodQueue { return; } - const CGFloat optWidth = CGFloatValueFrom(options[@"width"]); - const CGFloat maxWidth = isnan(optWidth) || isinf(optWidth) ? CGFLOAT_MAX : optWidth; - const CGSize maxSize = CGSizeMake(maxWidth, CGFLOAT_MAX); - - // Create attributes for the font and the optional letter spacing. + const CGSize maxSize = [self maxSizeFromOptions:options]; const CGFloat letterSpacing = CGFloatValueFrom(options[@"letterSpacing"]); - NSDictionary *const attributes = isnan(letterSpacing) - ? @{NSFontAttributeName: font} - : @{NSFontAttributeName: font, NSKernAttributeName: @(letterSpacing)}; - NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; - textContainer.lineFragmentPadding = 0.0; - textContainer.lineBreakMode = NSLineBreakByClipping; - - const NSInteger numberOfLines = [RCTConvert NSInteger:options[@"numberOfLines"]]; - if (numberOfLines > 0) { - textContainer.maximumNumberOfLines = numberOfLines; - } + NSTextContainer *const textContainer = + [self textContainerFromOptions:options withMaxSize:maxSize]; + NSDictionary *const attributes = + [self textStorageAttributesFromOptions:options + withFont:font + withLetterSpacing:letterSpacing]; NSLayoutManager *layoutManager = [NSLayoutManager new]; [layoutManager addTextContainer:textContainer]; @@ -506,7 +487,7 @@ - (NSDictionary *)fontInfoFromUIFont:(const UIFont *)font * of the weight in multiples of "100", as expected by RN, or one of the words * "bold" or "normal" if appropiate. * - * @param trais NSDictionary with the traits of the font. + * @param traits NSDictionary with the traits of the font. * @return NSString with the weight of the font. */ - (NSString *)fontWeightFromTraits:(const NSDictionary *)traits @@ -527,7 +508,7 @@ - (NSString *)fontWeightFromTraits:(const NSDictionary *)traits /** * Returns a string with the style found in the trait, either "normal" or "italic". * - * @param trais NSDictionary with the traits of the font. + * @param traits NSDictionary with the traits of the font. * @return NSString with the style. */ - (NSString *)fontStyleFromTraits:(const NSDictionary *)traits @@ -591,4 +572,56 @@ - (NSString *)fontStyleFromTraits:(const NSDictionary *)traits return count ? [NSArray arrayWithObjects:outArr count:count] : nil; } +- (CGSize)maxSizeFromOptions:(NSDictionary * _Nullable)options +{ + const CGFloat optWidth = CGFloatValueFrom(options[@"width"]); + const CGFloat maxWidth = isnan(optWidth) || isinf(optWidth) ? CGFLOAT_MAX : optWidth; + const CGSize maxSize = CGSizeMake(maxWidth, CGFLOAT_MAX); + return maxSize; +} + +/** + * Creates a textContainer with the width and numberOfLines from options. + */ +- (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options + withMaxSize:(CGSize)maxSize +{ + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; + textContainer.lineFragmentPadding = 0.0; + textContainer.lineBreakMode = NSLineBreakByClipping; + + const NSInteger numberOfLines = [RCTConvert NSInteger:options[@"numberOfLines"]]; + if (numberOfLines > 0) { + textContainer.maximumNumberOfLines = numberOfLines; + } + + return textContainer; +} + +/** + * Creates attributes that should be passed into the TextStorage based on + * parameters and the options the user passes in. + */ +- (NSDictionary *const)textStorageAttributesFromOptions:(NSDictionary * _Nullable)options + withFont:(UIFont *const _Nullable)font + withLetterSpacing:(CGFloat)letterSpacing +{ + NSMutableDictionary *const attributes = [[NSMutableDictionary alloc] init]; + [attributes setObject:font forKey:NSFontAttributeName]; + + if (!isnan(letterSpacing)) { + [attributes setObject:@(letterSpacing) forKey:NSKernAttributeName]; + } + + const CGFloat lineHeight = CGFloatValueFrom(options[@"lineHeight"]); + if (!isnan(lineHeight)) { + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + [style setMinimumLineHeight:lineHeight]; + [style setMaximumLineHeight:lineHeight]; + [attributes setObject:style forKey:NSParagraphStyleAttributeName]; + } + + return attributes; +} + @end From 981ab97193db8806bee751a4d41ef0def6dab5fd Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Wed, 30 Mar 2022 16:31:19 -0700 Subject: [PATCH 03/12] Add missing config change on Android for lineHeight support --- .../java/com/github/amarcruz/rntextsize/RNTextSizeConf.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java index e42fb70..c1be1bd 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeConf.java @@ -67,6 +67,7 @@ static boolean supportUpperCaseTransform() { final String fontFamily; final float fontSize; final int fontStyle; + final float lineHeight; final boolean includeFontPadding; final float letterSpacing; final @Nullable Integer numberOfLines; @@ -85,6 +86,7 @@ static boolean supportUpperCaseTransform() { fontFamily = getString("fontFamily"); fontSize = getFontSizeOrDefault(); fontStyle = getFontStyle(); + lineHeight = getFloatOrNaN("lineHeight"); includeFontPadding = forText && getBooleanOrTrue("includeFontPadding"); // letterSpacing is supported in RN 0.55+ From d2e2dd59f3de7e135fd0bfc2d78031c6331aae77 Mon Sep 17 00:00:00 2001 From: Tony Du Date: Tue, 5 Apr 2022 10:25:41 -0700 Subject: [PATCH 04/12] Scale lineHeight --- ios/RNTextSize.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 2382f9e..427863c 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -616,8 +616,9 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options const CGFloat lineHeight = CGFloatValueFrom(options[@"lineHeight"]); if (!isnan(lineHeight)) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - [style setMinimumLineHeight:lineHeight]; - [style setMaximumLineHeight:lineHeight]; + const CGFloat scaleMultiplier = _bridge ? _bridge.accessibilityManager.multiplier : 1.0; + [style setMinimumLineHeight:lineHeight * scaleMultiplier]; + [style setMaximumLineHeight:lineHeight * scaleMultiplier]; [attributes setObject:style forKey:NSParagraphStyleAttributeName]; } From 7372b985ed78f6c87263e6e4a171ad734f6accd3 Mon Sep 17 00:00:00 2001 From: Tony Du Date: Tue, 5 Apr 2022 12:14:17 -0700 Subject: [PATCH 05/12] Fix default scaling epsilon --- ios/RNTextSize.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 427863c..7d041df 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -178,7 +178,9 @@ - (dispatch_queue_t)methodQueue { [textStorage addLayoutManager:layoutManager]; NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:texts.count]; - const CGFloat epsilon = 0.001; + + const CGFloat scaleMultiplier = _bridge ? _bridge.accessibilityManager.multiplier : 1.0; + const CGFloat epsilon = scaleMultiplier != 1.0 ? 0.001 : 0; for (int ix = 0; ix < texts.count; ix++) { NSString *text = texts[ix]; From 6f6d712bb878a9c719d254aed58cb07622972251 Mon Sep 17 00:00:00 2001 From: Tony Du Date: Tue, 5 Apr 2022 14:23:26 -0700 Subject: [PATCH 06/12] Add comments to epsilon enabling --- ios/RNTextSize.m | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 7d041df..83e10e3 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -6,8 +6,8 @@ #import #else #import "React/RCTConvert.h" // Required when used as a Pod in a Swift project -#import -#import +#import "React/RCTFont.h" +#import "React/RCTUtils.h" #endif #import @@ -179,8 +179,9 @@ - (dispatch_queue_t)methodQueue { NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:texts.count]; - const CGFloat scaleMultiplier = _bridge ? _bridge.accessibilityManager.multiplier : 1.0; - const CGFloat epsilon = scaleMultiplier != 1.0 ? 0.001 : 0; + // When there's no font scaling, adding epsilon offsets the calculation + // by a bit, and when there is, it's required. This was tested empirically. + const CGFloat epsilon = [self fontScaleMultiplier] != 1.0 ? 0.001 : 0; for (int ix = 0; ix < texts.count; ix++) { NSString *text = texts[ix]; @@ -618,7 +619,7 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options const CGFloat lineHeight = CGFloatValueFrom(options[@"lineHeight"]); if (!isnan(lineHeight)) { NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; - const CGFloat scaleMultiplier = _bridge ? _bridge.accessibilityManager.multiplier : 1.0; + const CGFloat scaleMultiplier = [self fontScaleMultiplier]; [style setMinimumLineHeight:lineHeight * scaleMultiplier]; [style setMaximumLineHeight:lineHeight * scaleMultiplier]; [attributes setObject:style forKey:NSParagraphStyleAttributeName]; @@ -627,4 +628,8 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options return attributes; } +- (CGFloat)fontScaleMultiplier { + return _bridge ? _bridge.accessibilityManager.multiplier : 1.0; +} + @end From 15d7e8f9db6270bc42f9c1af1991833c65e635e1 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Tue, 12 Apr 2022 09:26:13 -0700 Subject: [PATCH 07/12] Add ceilToClosestPixel option; refactor duplicate code on Android --- README.md | 1 + .../amarcruz/rntextsize/RNTextSizeModule.java | 92 ++++++++----------- index.d.ts | 8 ++ index.js.flow | 8 ++ ios/RNTextSize.m | 40 ++++++-- 5 files changed, 87 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 5ed0a67..1dc9987 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ fontStyle | string | 'normal' | One of "normal" or "italic". lineHeight | number | (none) | The line height of each line. Defaults to the font size. numberOfLines | number | (none) | Limit the number of lines the text can render on fontVariant | array | (none) | _iOS only_ +ceilToClosestPixel | boolean | true | _iOS only_. If true, we ceil the output to the closest pixel. This is React Native's default behavior, but can be disabled if you're trying to measure text in a native component that doesn't respect this. allowFontScaling | boolean | true | To respect the user' setting of large fonts (i.e. use SP units). letterSpacing | number | (none) | Additional spacing between characters (aka `tracking`).
**Note:** In iOS a zero cancels automatic kerning.
_All iOS, Android with API 21+_ includeFontPadding | boolean | true | Include additional top and bottom padding, to avoid clipping certain characters.
_Android only_ diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java index 8add736..b83f693 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java @@ -89,25 +89,26 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) { return; } - final SpannableString text = (SpannableString) RNTextSizeSpannedText - .spannedFromSpecsAndText(mReactContext, conf, new SpannableString(_text)); + final SpannableStringBuilder sb = new SpannableStringBuilder(_text); + RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb); + final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG); Layout layout = null; try { - final BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint); + final BoringLayout.Metrics boring = BoringLayout.isBoring(sb, textPaint); int hintWidth = (int) width; if (boring == null) { // Not boring, ie. the text is multiline or contains unicode characters. - final float desiredWidth = Layout.getDesiredWidth(text, textPaint); + final float desiredWidth = Layout.getDesiredWidth(sb, textPaint); if (desiredWidth <= width) { hintWidth = (int) Math.ceil(desiredWidth); } } else if (boring.width <= width) { // Single-line and width unknown or bigger than the width of the text. layout = BoringLayout.make( - text, + sb, textPaint, boring.width, Layout.Alignment.ALIGN_NORMAL, @@ -118,29 +119,7 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) { } if (layout == null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - StaticLayout.Builder builder = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setBreakStrategy(conf.getTextBreakStrategy()) - .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) - .setIncludePad(includeFontPadding) - .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER); - if (conf.numberOfLines != null) { - builder = builder.setMaxLines(conf.numberOfLines) - .setEllipsize(TextUtils.TruncateAt.END); - } - layout = builder.build(); - } else { - layout = new StaticLayout( - text, - textPaint, - hintWidth, - Layout.Alignment.ALIGN_NORMAL, - SPACING_MULTIPLIER, - SPACING_ADDITION, - includeFontPadding - ); - } + layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, hintWidth); } final int lineCount = layout.getLineCount(); @@ -231,31 +210,7 @@ public void flatHeights(@Nullable final ReadableMap specs, final Promise promise // Reset the SB text, the attrs will expand to its full length sb.replace(0, sb.length(), text); - - if (Build.VERSION.SDK_INT >= 23) { - StaticLayout.Builder builder = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, (int) width) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setBreakStrategy(textBreakStrategy) - .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) - .setIncludePad(includeFontPadding) - .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER); - if (conf.numberOfLines != null) { - builder = builder.setMaxLines(conf.numberOfLines) - .setEllipsize(TextUtils.TruncateAt.END); - } - layout = builder.build(); - } else { - layout = new StaticLayout( - sb, - textPaint, - (int) width, - Layout.Alignment.ALIGN_NORMAL, - SPACING_MULTIPLIER, - SPACING_ADDITION, - includeFontPadding - ); - } - + layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, (int) width); result.pushDouble(layout.getHeight() / density); } @@ -499,4 +454,35 @@ private void addFamilyToArray( } } } + + /** Builds the staticLayout from the configuration */ + private Layout buildStaticLayout( + RNTextSizeConf conf, boolean includeFontPadding, SpannableStringBuilder sb, + TextPaint textPaint, int hintWidth) { + Layout layout; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + StaticLayout.Builder builder = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, hintWidth) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setBreakStrategy(conf.getTextBreakStrategy()) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) + .setIncludePad(includeFontPadding) + .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER); + if (conf.numberOfLines != null) { + builder = builder.setMaxLines(conf.numberOfLines) + .setEllipsize(TextUtils.TruncateAt.END); + } + layout = builder.build(); + } else { + layout = new StaticLayout( + sb, + textPaint, + hintWidth, + Layout.Alignment.ALIGN_NORMAL, + SPACING_MULTIPLIER, + SPACING_ADDITION, + includeFontPadding + ); + } + return layout; + } } diff --git a/index.d.ts b/index.d.ts index bb41cd0..db6326f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -70,6 +70,14 @@ declare module "react-native-text-size" { * prop on `` */ numberOfLines?: number; + /** + * @platform ios + * + * If true, we ceil the output to the closest pixel. This is React Native's + * default behavior, but can be disabled if you're trying to measure text in + * a native component that doesn't respect this. + */ + ceilToClosestPixel?: boolean; /** @platform ios */ fontVariant?: Array; /** iOS all, Android SDK 21+ with RN 0.55+ */ diff --git a/index.js.flow b/index.js.flow index 5b7985b..005de9b 100644 --- a/index.js.flow +++ b/index.js.flow @@ -70,6 +70,14 @@ export type TSFontSpecs = { * prop on `` */ numberOfLines?: number, + /** + * @platform ios + * + * If true, we ceil the output to the closest pixel. This is React Native's + * default behavior, but can be disabled if you're trying to measure text in + * a native component that doesn't respect this. + */ + ceilToClosestPixel?: boolean; /** @platform ios */ fontVariant?: Array, /** iOS all, Android SDK 21+ with RN 0.55+ */ diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 83e10e3..8adba35 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -109,9 +109,8 @@ - (dispatch_queue_t)methodQueue { size.width -= letterSpacing; } - const CGFloat epsilon = 0.001; - const CGFloat width = MIN(RCTCeilPixelValue(size.width + epsilon), maxSize.width); - const CGFloat height = MIN(RCTCeilPixelValue(size.height + epsilon), maxSize.height); + const CGFloat width = [self adjustMeasuredSize:size.width withOptions:options withMaxSize:maxSize.width]; + const CGFloat height = [self adjustMeasuredSize:size.height withOptions:options withMaxSize:maxSize.height]; const NSInteger lineCount = [self getLineCount:layoutManager]; NSMutableDictionary *result = [[NSMutableDictionary alloc] @@ -179,9 +178,6 @@ - (dispatch_queue_t)methodQueue { NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:texts.count]; - // When there's no font scaling, adding epsilon offsets the calculation - // by a bit, and when there is, it's required. This was tested empirically. - const CGFloat epsilon = [self fontScaleMultiplier] != 1.0 ? 0.001 : 0; for (int ix = 0; ix < texts.count; ix++) { NSString *text = texts[ix]; @@ -203,7 +199,9 @@ - (dispatch_queue_t)methodQueue { [textStorage replaceCharactersInRange:range withString:text]; CGSize size = [layoutManager usedRectForTextContainer:textContainer].size; - const CGFloat height = MIN(RCTCeilPixelValue(size.height + epsilon), maxSize.height); + const CGFloat height = [self adjustMeasuredSize:size.height + withOptions:options + withMaxSize:maxSize.height]; result[ix] = @(height); } @@ -606,8 +604,8 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options * parameters and the options the user passes in. */ - (NSDictionary *const)textStorageAttributesFromOptions:(NSDictionary * _Nullable)options - withFont:(UIFont *const _Nullable)font - withLetterSpacing:(CGFloat)letterSpacing + withFont:(UIFont *const _Nullable)font + withLetterSpacing:(CGFloat)letterSpacing { NSMutableDictionary *const attributes = [[NSMutableDictionary alloc] init]; [attributes setObject:font forKey:NSFontAttributeName]; @@ -632,4 +630,28 @@ - (CGFloat)fontScaleMultiplier { return _bridge ? _bridge.accessibilityManager.multiplier : 1.0; } +/** + * React Native ceils sizes to the nearest pixels by default, so we usually + * want to adjust it to that + */ +- (CGFloat)adjustMeasuredSize:(const CGFloat)size + withOptions:(NSDictionary *)options + withMaxSize:(const CGFloat)maxSize +{ + CGFloat adjusted = size; + + NSString *const key = @"ceilToClosestPixel"; + BOOL ceilToClosestPixel = ![options objectForKey:key] || [options[key] boolValue]; + + if (ceilToClosestPixel) { + // When there's no font scaling, adding epsilon offsets the calculation + // by a bit, and when there is, it's required. This was tested empirically. + const CGFloat epsilon = [self fontScaleMultiplier] != 1.0 ? 0.001 : 0; + adjusted = RCTCeilPixelValue(size + epsilon); + } + adjusted = MIN(adjusted, maxSize); + + return size; +} + @end From a8a2ff443540028f3ec1a14813c097f060714205 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Tue, 12 Apr 2022 15:28:04 -0700 Subject: [PATCH 08/12] Remove incorrect _Nullable and add const to options arguments --- ios/RNTextSize.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 8adba35..b57c648 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -573,7 +573,7 @@ - (NSString *)fontStyleFromTraits:(const NSDictionary *)traits return count ? [NSArray arrayWithObjects:outArr count:count] : nil; } -- (CGSize)maxSizeFromOptions:(NSDictionary * _Nullable)options +- (CGSize)maxSizeFromOptions:(const NSDictionary *)options { const CGFloat optWidth = CGFloatValueFrom(options[@"width"]); const CGFloat maxWidth = isnan(optWidth) || isinf(optWidth) ? CGFLOAT_MAX : optWidth; @@ -584,7 +584,7 @@ - (CGSize)maxSizeFromOptions:(NSDictionary * _Nullable)options /** * Creates a textContainer with the width and numberOfLines from options. */ -- (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options +- (NSTextContainer *)textContainerFromOptions:(const NSDictionary *)options withMaxSize:(CGSize)maxSize { NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:maxSize]; @@ -603,7 +603,7 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options * Creates attributes that should be passed into the TextStorage based on * parameters and the options the user passes in. */ -- (NSDictionary *const)textStorageAttributesFromOptions:(NSDictionary * _Nullable)options +- (NSDictionary *const)textStorageAttributesFromOptions:(const NSDictionary *)options withFont:(UIFont *const _Nullable)font withLetterSpacing:(CGFloat)letterSpacing { @@ -634,9 +634,9 @@ - (CGFloat)fontScaleMultiplier { * React Native ceils sizes to the nearest pixels by default, so we usually * want to adjust it to that */ -- (CGFloat)adjustMeasuredSize:(const CGFloat)size - withOptions:(NSDictionary *)options - withMaxSize:(const CGFloat)maxSize +- (CGFloat)adjustMeasuredSize:(CGFloat)size + withOptions:(const NSDictionary *)options + withMaxSize:(CGFloat)maxSize { CGFloat adjusted = size; From 7f495d16afb5e2c477741cd3d48b72f29bd21593 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Wed, 13 Apr 2022 08:19:51 -0700 Subject: [PATCH 09/12] Fix adjustMeasuredSize to return correct output --- ios/RNTextSize.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index b57c648..c75fe95 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -651,7 +651,7 @@ - (CGFloat)adjustMeasuredSize:(CGFloat)size } adjusted = MIN(adjusted, maxSize); - return size; + return adjusted; } @end From 0e161a9f580da9395c09fe3d361cc5bc5830b860 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sat, 30 Apr 2022 23:51:16 -0700 Subject: [PATCH 10/12] Create flatSizes function that also retrieves widths --- CHANGELOG.md | 4 +- README.md | 7 +- .../amarcruz/rntextsize/RNTextSizeModule.java | 140 +++++++++------ index.d.ts | 14 +- index.js.flow | 6 + ios/RNTextSize.m | 163 +++++++++++------- 6 files changed, 210 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c4c62..4ddb93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Added -- Updated README.md with example for flatHeights - Thanks to @donni106 +- Update README.md with example for flatHeights - Thanks to @donni106 +- Create new flatSizes function that also gets the widths +- Add support for `numberOfLines` and `maxWidth/maxHeight` dimensions. ### Changed diff --git a/README.md b/README.md index 1dc9987..eca4bc8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ See [Manual Installation][2] on the Wiki as an alternative if you have problems - [`measure`](#measure) -- [`flatHeights`](#flatheights) +- [`flatHeights` and `flatSizes`](#flatheights) - [`specsForTextStyles`](#specsfortextstyles) @@ -199,9 +199,10 @@ class Test extends Component { ```ts flatHeights(options: TSHeightsParams): Promise +flatSizes(options: TSHeightsParams): Promise ``` -Calculate the height of each of the strings in an array. +Calculate the height (or sizes) of each of the strings in an array. This is an alternative to `measure` designed for cases in which you have to calculate the height of numerous text blocks with common characteristics (width, font, etc), a typical use case with `` or `` components. @@ -214,6 +215,8 @@ I did tests on 5,000 random text blocks and these were the results (ms): Android | 49,624 | 1,091 iOS | 1,949 | 732 +Note that `flatSizes` is somewhat slower than `flatHeights` on Android since it needs to iterate through lines to find the maximum line length. + In the future I will prepare an example of its use with FlatList and multiple styles on the same card. ### TSHeightsParams diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java index b83f693..9c00147 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java @@ -163,61 +163,26 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) { } } - // https://stackoverflow.com/questions/3654321/measuring-text-height-to-be-drawn-on-canvas-android + /** + * Retrieves sizes of each entry in an array of strings rendered with the same style. + * + * https://stackoverflow.com/questions/3654321/measuring-text-height-to-be-drawn-on-canvas-android + */ @SuppressWarnings("unused") @ReactMethod - public void flatHeights(@Nullable final ReadableMap specs, final Promise promise) { - final RNTextSizeConf conf = getConf(specs, promise, true); - if (conf == null) { - return; - } - - final ReadableArray texts = conf.getArray("text"); - if (texts == null) { - promise.reject(E_MISSING_TEXT, "Missing required text, must be an array."); - return; - } - - final float density = getCurrentDensity(); - final float width = conf.getWidth(density); - final boolean includeFontPadding = conf.includeFontPadding; - final int textBreakStrategy = conf.getTextBreakStrategy(); - - final WritableArray result = Arguments.createArray(); - - final SpannableStringBuilder sb = new SpannableStringBuilder(" "); - RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb); - - final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG); - Layout layout; - try { - - for (int ix = 0; ix < texts.size(); ix++) { - - // If this element is `null` or another type, return zero - if (texts.getType(ix) != ReadableType.String) { - result.pushInt(0); - continue; - } - - final String text = texts.getString(ix); - - // If empty, return the minimum height of components - if (text.isEmpty()) { - result.pushDouble(minimalHeight(density, includeFontPadding)); - continue; - } - - // Reset the SB text, the attrs will expand to its full length - sb.replace(0, sb.length(), text); - layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, (int) width); - result.pushDouble(layout.getHeight() / density); - } + public void flatSizes(@Nullable final ReadableMap specs, final Promise promise) { + flatHeightsInner(specs, promise, true); + } - promise.resolve(result); - } catch (Exception e) { - promise.reject(E_UNKNOWN_ERROR, e); - } + /** + * Retrieves heights of each entry in an array of strings rendered with the same style. + * + * https://stackoverflow.com/questions/3654321/measuring-text-height-to-be-drawn-on-canvas-android + */ + @SuppressWarnings("unused") + @ReactMethod + public void flatHeights(@Nullable final ReadableMap specs, final Promise promise) { + flatHeightsInner(specs, promise, false); } /** @@ -313,6 +278,77 @@ public void fontNamesForFamilyName(final String ignored, final Promise promise) // // ============================================================================ + private void flatHeightsInner(@Nullable final ReadableMap specs, final Promise promise, boolean includeWidths) { + final RNTextSizeConf conf = getConf(specs, promise, true); + if (conf == null) { + return; + } + + final ReadableArray texts = conf.getArray("text"); + if (texts == null) { + promise.reject(E_MISSING_TEXT, "Missing required text, must be an array."); + return; + } + + final float density = getCurrentDensity(); + final float width = conf.getWidth(density); + final boolean includeFontPadding = conf.includeFontPadding; + final int textBreakStrategy = conf.getTextBreakStrategy(); + + final WritableArray heights = Arguments.createArray(); + final WritableArray widths = Arguments.createArray(); + + final SpannableStringBuilder sb = new SpannableStringBuilder(" "); + RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb); + + final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG); + Layout layout; + try { + + for (int ix = 0; ix < texts.size(); ix++) { + + // If this element is `null` or another type, return zero + if (texts.getType(ix) != ReadableType.String) { + heights.pushInt(0); + continue; + } + + final String text = texts.getString(ix); + + // If empty, return the minimum height of components + if (text.isEmpty()) { + heights.pushDouble(minimalHeight(density, includeFontPadding)); + continue; + } + + // Reset the SB text, the attrs will expand to its full length + sb.replace(0, sb.length(), text); + layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, (int) width); + heights.pushDouble(layout.getHeight() / density); + + if (includeWidths) { + final int lineCount = layout.getLineCount(); + float measuredWidth = 0; + for (int i = 0; i < lineCount; i++) { + measuredWidth = Math.max(measuredWidth, layout.getLineMax(i)); + } + widths.pushDouble(measuredWidth / density); + } + } + + if (includeWidths) { + final WritableMap output = Arguments.createMap(); + output.putArray("widths", widths); + output.putArray("heights", heights); + promise.resolve(output); + } else { + promise.resolve(heights); + } + } catch (Exception e) { + promise.reject(E_UNKNOWN_ERROR, e); + } + } + @Nullable private RNTextSizeConf getConf(final ReadableMap specs, final Promise promise, boolean forText) { if (specs == null) { diff --git a/index.d.ts b/index.d.ts index db6326f..4fb9245 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,7 @@ declare module "react-native-text-size" { export type TSFontVariant = 'small-caps' | 'oldstyle-nums' | 'lining-nums' | 'tabular-nums' | 'proportional-nums' export type TSTextBreakStrategy = 'simple' | 'highQuality' | 'balanced' - export type TSFontSize = { + export interface TSFontSize { readonly default: number, readonly button: number, readonly label: number, @@ -41,7 +41,7 @@ declare module "react-native-text-size" { | 'title2' | 'title3' - export type TSFontInfo = { + export interface TSFontInfo { fontFamily: string | null, fontName?: string | null, fontWeight: TSFontWeight, @@ -88,7 +88,7 @@ declare module "react-native-text-size" { textBreakStrategy?: TSTextBreakStrategy; } - export type TSFontForStyle = { + export interface TSFontForStyle { fontFamily: string, /** Unscaled font size, untits are SP in Android, points in iOS */ fontSize: number, @@ -143,7 +143,7 @@ declare module "react-native-text-size" { lineInfoForLine?: number; } - export type TSMeasureResult = { + export interface TSMeasureResult { /** * Total used width. It may be less or equal to the `width` option. * @@ -185,8 +185,14 @@ declare module "react-native-text-size" { }; } + export interface TSFlatSizes { + widths: number[]; + heights: number[]; + } + interface TextSizeStatic { measure(params: TSMeasureParams): Promise; + flatSizes(params: TSHeightsParams): Promise; flatHeights(params: TSHeightsParams): Promise; specsForTextStyles(): Promise<{ [key: string]: TSFontForStyle }>; fontFromSpecs(specs?: TSFontSpecs): Promise; diff --git a/index.js.flow b/index.js.flow index 005de9b..51eed63 100644 --- a/index.js.flow +++ b/index.js.flow @@ -184,8 +184,14 @@ export interface TSMeasureResult { } } +export interface TSFlatSizes { + widths: number[]; + heights: number[]; +} + declare interface TextSizeStatic { measure(params: TSMeasureParams): Promise; + flatSizes(params: TSHeightsParams): Promise; flatHeights(params: TSHeightsParams): Promise; specsForTextStyles(): Promise<{ [string]: TSFontForStyle }>; fontFromSpecs(specs: TSFontSpecs): Promise; diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index c75fe95..4601532 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -6,8 +6,8 @@ #import #else #import "React/RCTConvert.h" // Required when used as a Pod in a Swift project -#import "React/RCTFont.h" -#import "React/RCTUtils.h" +#import +#import #endif #import @@ -139,73 +139,33 @@ - (dispatch_queue_t)methodQueue { } /** - * Gets the width, height, line count and last line width for the provided text - * font specifications. + * Given a set of text and styling for it, fetches all the height/widths of + * the bounding box for that text. + * * Based on `RCTTextShadowViewMeasure` of Libraries/Text/Text/RCTTextShadowView.m */ -RCT_EXPORT_METHOD(flatHeights:(NSDictionary * _Nullable)options - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXPORT_METHOD(flatSizes:(NSDictionary * _Nullable)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { - // Don't use NSStringArray, we are handling nulls - NSArray *const _Nullable texts = [RCTConvert NSArray:options[@"text"]]; - if (isNull(texts)) { - reject(E_MISSING_TEXT, @"Missing required text, must be an array.", nil); - return; - } - - UIFont *const _Nullable font = [self scaledUIFontFromUserSpecs:options]; - if (!font) { - reject(E_INVALID_FONT_SPEC, @"Invalid font specification.", nil); - return; - } - - const CGSize maxSize = [self maxSizeFromOptions:options]; - const CGFloat letterSpacing = CGFloatValueFrom(options[@"letterSpacing"]); - - NSTextContainer *const textContainer = - [self textContainerFromOptions:options withMaxSize:maxSize]; - NSDictionary *const attributes = - [self textStorageAttributesFromOptions:options - withFont:font - withLetterSpacing:letterSpacing]; - - NSLayoutManager *layoutManager = [NSLayoutManager new]; - [layoutManager addTextContainer:textContainer]; - - NSTextStorage *textStorage = [[NSTextStorage alloc] initWithString:@" " attributes:attributes]; - [textStorage addLayoutManager:layoutManager]; - - NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:texts.count]; - - - for (int ix = 0; ix < texts.count; ix++) { - NSString *text = texts[ix]; - - // If this element is `null` or another type, return zero - if (![text isKindOfClass:[NSString class]]) { - result[ix] = @0; - continue; - } - - // If empty, return the minimum height of components - if (!text.length) { - result[ix] = @14; - continue; - } - - // Reset the textStorage, the attrs will expand to its new length - NSRange range = NSMakeRange(0, textStorage.length); - [textStorage replaceCharactersInRange:range withString:text]; - CGSize size = [layoutManager usedRectForTextContainer:textContainer].size; - - const CGFloat height = [self adjustMeasuredSize:size.height - withOptions:options - withMaxSize:maxSize.height]; - result[ix] = @(height); - } + NSDictionary *const _Nullable sizes = [self flatSizesInner:options rejecter:reject]; + if (sizes == nil) return; + resolve(sizes); +} - resolve(result); +/** + * Given a set of text and styling for it, fetches all the height of + * the bounding box for that text. + * + * Based on `RCTTextShadowViewMeasure` of Libraries/Text/Text/RCTTextShadowView.m + */ +RCT_EXPORT_METHOD(flatHeights:(NSDictionary * _Nullable)options + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSDictionary *const _Nullable sizes = [self flatSizesInner:options rejecter:reject]; + if (sizes == nil) return; + resolve(sizes[@"heights"]); } /** @@ -363,6 +323,79 @@ - (dispatch_queue_t)methodQueue { // ============================================================================ // +/** + * Given a set of text and styling for it, fetches all the height/widths of + * the bounding box for that text. + */ +- (NSDictionary * _Nullable)flatSizesInner:(NSDictionary * _Nullable)options + rejecter:(RCTPromiseRejectBlock)reject { + // Don't use NSStringArray, we are handling nulls + NSArray *const _Nullable texts = [RCTConvert NSArray:options[@"text"]]; + if (isNull(texts)) { + reject(E_MISSING_TEXT, @"Missing required text, must be an array.", nil); + return nil; + } + + UIFont *const _Nullable font = [self scaledUIFontFromUserSpecs:options]; + if (!font) { + reject(E_INVALID_FONT_SPEC, @"Invalid font specification.", nil); + return nil; + } + + const CGSize maxSize = [self maxSizeFromOptions:options]; + const CGFloat letterSpacing = CGFloatValueFrom(options[@"letterSpacing"]); + + NSTextContainer *const textContainer = + [self textContainerFromOptions:options withMaxSize:maxSize]; + NSDictionary *const attributes = + [self textStorageAttributesFromOptions:options + withFont:font + withLetterSpacing:letterSpacing]; + + NSLayoutManager *layoutManager = [NSLayoutManager new]; + [layoutManager addTextContainer:textContainer]; + + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithString:@" " attributes:attributes]; + [textStorage addLayoutManager:layoutManager]; + + NSMutableArray *widths = [[NSMutableArray alloc] initWithCapacity:texts.count]; + NSMutableArray *heights = [[NSMutableArray alloc] initWithCapacity:texts.count]; + + + for (int ix = 0; ix < texts.count; ix++) { + NSString *text = texts[ix]; + + // If this element is `null` or another type, return zero + if (![text isKindOfClass:[NSString class]]) { + heights[ix] = @0; + continue; + } + + // If empty, return the minimum height of components + if (!text.length) { + heights[ix] = @14; + continue; + } + + // Reset the textStorage, the attrs will expand to its new length + NSRange range = NSMakeRange(0, textStorage.length); + [textStorage replaceCharactersInRange:range withString:text]; + CGSize size = [layoutManager usedRectForTextContainer:textContainer].size; + + const CGFloat height = [self adjustMeasuredSize:size.height + withOptions:options + withMaxSize:maxSize.height]; + const CGFloat width = [self adjustMeasuredSize:size.width + withOptions:options + withMaxSize:maxSize.width]; + + heights[ix] = @(height); + widths[ix] = @(width); + } + + return @{ @"heights": heights, @"widths": widths }; +} + /** * Get extended info for a given line number. * @since v2.1.0 From 41332e403a111ae8e3211e5f63c65e6d0feadd96 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Sun, 15 May 2022 19:48:50 -0700 Subject: [PATCH 11/12] Fix NSRangeException when adding widths --- ios/RNTextSize.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/RNTextSize.m b/ios/RNTextSize.m index 4601532..28f4b79 100644 --- a/ios/RNTextSize.m +++ b/ios/RNTextSize.m @@ -361,19 +361,20 @@ - (NSDictionary * _Nullable)flatSizesInner:(NSDictionary * _Nullable)options NSMutableArray *widths = [[NSMutableArray alloc] initWithCapacity:texts.count]; NSMutableArray *heights = [[NSMutableArray alloc] initWithCapacity:texts.count]; - for (int ix = 0; ix < texts.count; ix++) { NSString *text = texts[ix]; // If this element is `null` or another type, return zero if (![text isKindOfClass:[NSString class]]) { heights[ix] = @0; + widths[ix] = @0; continue; } // If empty, return the minimum height of components if (!text.length) { heights[ix] = @14; + widths[ix] = @0; continue; } From e6baa3799aa2fa8ce01c0660f93b16bcb53fae45 Mon Sep 17 00:00:00 2001 From: Harry Yu Date: Thu, 3 Apr 2025 07:34:16 +0200 Subject: [PATCH 12/12] Change flatHeights on Android to account for when text gets truncated (#1) --- .../amarcruz/rntextsize/RNTextSizeModule.java | 80 ++++++++++++++----- index.d.ts | 21 +++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java index 9c00147..d0ebb21 100644 --- a/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java +++ b/android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java @@ -6,7 +6,6 @@ import android.os.Build; import android.text.BoringLayout; import android.text.Layout; -import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.StaticLayout; import android.text.TextPaint; @@ -171,7 +170,7 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) { @SuppressWarnings("unused") @ReactMethod public void flatSizes(@Nullable final ReadableMap specs, final Promise promise) { - flatHeightsInner(specs, promise, true); + flatHeightsInner(specs, promise, FlatHeightsMode.sizes); } /** @@ -182,7 +181,7 @@ public void flatSizes(@Nullable final ReadableMap specs, final Promise promise) @SuppressWarnings("unused") @ReactMethod public void flatHeights(@Nullable final ReadableMap specs, final Promise promise) { - flatHeightsInner(specs, promise, false); + flatHeightsInner(specs, promise, FlatHeightsMode.heights); } /** @@ -278,7 +277,12 @@ public void fontNamesForFamilyName(final String ignored, final Promise promise) // // ============================================================================ - private void flatHeightsInner(@Nullable final ReadableMap specs, final Promise promise, boolean includeWidths) { + private enum FlatHeightsMode { + heights, + sizes, + } + + private void flatHeightsInner(@Nullable final ReadableMap specs, final Promise promise, FlatHeightsMode mode) { final RNTextSizeConf conf = getConf(specs, promise, true); if (conf == null) { return; @@ -293,7 +297,6 @@ private void flatHeightsInner(@Nullable final ReadableMap specs, final Promise p final float density = getCurrentDensity(); final float width = conf.getWidth(density); final boolean includeFontPadding = conf.includeFontPadding; - final int textBreakStrategy = conf.getTextBreakStrategy(); final WritableArray heights = Arguments.createArray(); final WritableArray widths = Arguments.createArray(); @@ -324,25 +327,66 @@ private void flatHeightsInner(@Nullable final ReadableMap specs, final Promise p // Reset the SB text, the attrs will expand to its full length sb.replace(0, sb.length(), text); layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, (int) width); - heights.pushDouble(layout.getHeight() / density); - if (includeWidths) { + float height = layout.getHeight() / density; + + if (conf.numberOfLines != null || mode == FlatHeightsMode.sizes) { final int lineCount = layout.getLineCount(); - float measuredWidth = 0; - for (int i = 0; i < lineCount; i++) { - measuredWidth = Math.max(measuredWidth, layout.getLineMax(i)); + + if (conf.numberOfLines != null) { + boolean lastLineHasEllipsis = layout.getEllipsisCount(lineCount - 1) > 0; + // For unknown reasons, the text will be 2 subpixels shorter if truncated + // due to numberOfLines. See the lines mentioning `numberOfLines` in the + // TextHeights stories: this logic was created for those cases. + if (lastLineHasEllipsis) { + height -= 2 / density; + } + } + + if (mode == FlatHeightsMode.sizes) { + float measuredWidth = 0; + for (int i = 0; i < lineCount; i++) { + measuredWidth = Math.max(measuredWidth, layout.getLineMax(i)); + } + widths.pushDouble(measuredWidth / density); } - widths.pushDouble(measuredWidth / density); } + + heights.pushDouble(height); } - if (includeWidths) { - final WritableMap output = Arguments.createMap(); - output.putArray("widths", widths); - output.putArray("heights", heights); - promise.resolve(output); - } else { - promise.resolve(heights); + switch (mode) { + case sizes: { + final WritableMap output = Arguments.createMap(); + // We output an object with 3 arrays instead of an array of + // objects because it's much faster. + // + // Changing this output to arrays of objects quadrupled the + // running time of flatSizes: 1000 iterations of the + // following code went from 13ms to 47ms each. + // + // ```ts + // const heightsParams: TSHeightsParams = { + // text: _.times( + // 20, + // () => + // 'This is some text that is quite long. It should wrap onto a few lines', + // ), + // ...defaultTextStyle, + // width: 150, + // }; + // + // await TextSize.flatSizes(heightsParams); + // ``` + output.putArray("widths", widths); + output.putArray("heights", heights); + promise.resolve(output); + break; + } + case heights: { + promise.resolve(heights); + break; + } } } catch (Exception e) { promise.reject(E_UNKNOWN_ERROR, e); diff --git a/index.d.ts b/index.d.ts index 4fb9245..e4d8cc9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -192,8 +192,29 @@ declare module "react-native-text-size" { interface TextSizeStatic { measure(params: TSMeasureParams): Promise; + + /** + * On Android, we benchmarked this to take 1.55x the time of flatHeights. + * Measuring the sizes for the following input 1000 times took 13.38ms vs. + * 8.6ms. + * + * On iOS, this should run at roughly the same speed as flatHeights. + * + * ``` + * { + * text: _.times( + * 20, + * () => + * 'This is some text that is quite long. It should wrap onto a few lines', + * ), + * ...defaultTextStyle, + * width: 150, + * }; + * ``` + */ flatSizes(params: TSHeightsParams): Promise; flatHeights(params: TSHeightsParams): Promise; + specsForTextStyles(): Promise<{ [key: string]: TSFontForStyle }>; fontFromSpecs(specs?: TSFontSpecs): Promise; fontFamilyNames(): Promise;