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 9f644fc..eca4bc8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ 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) @@ -58,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) @@ -95,7 +97,10 @@ 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_ +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_ @@ -194,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. @@ -209,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 @@ -228,6 +236,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..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,8 +67,10 @@ 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; /** * Proccess the user specs. Set both `allowFontScaling` & `includeFontPadding` to the user @@ -84,10 +86,15 @@ static boolean supportUpperCaseTransform() { fontFamily = getString("fontFamily"); fontSize = getFontSizeOrDefault(); fontStyle = getFontStyle(); + lineHeight = getFloatOrNaN("lineHeight"); includeFontPadding = forText && getBooleanOrTrue("includeFontPadding"); // 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..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,10 +6,10 @@ 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; +import android.text.TextUtils; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; @@ -88,25 +88,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, @@ -117,25 +118,7 @@ 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) - .setAlignment(Layout.Alignment.ALIGN_NORMAL) - .setBreakStrategy(conf.getTextBreakStrategy()) - .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL) - .setIncludePad(includeFontPadding) - .setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER) - .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(); @@ -179,81 +162,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); - - if (Build.VERSION.SDK_INT >= 23) { - layout = 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(); - } else { - layout = new StaticLayout( - sb, - textPaint, - (int) width, - Layout.Alignment.ALIGN_NORMAL, - SPACING_MULTIPLIER, - SPACING_ADDITION, - includeFontPadding - ); - } - - result.pushDouble(layout.getHeight() / density); - } + public void flatSizes(@Nullable final ReadableMap specs, final Promise promise) { + flatHeightsInner(specs, promise, FlatHeightsMode.sizes); + } - 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, FlatHeightsMode.heights); } /** @@ -349,6 +277,122 @@ public void fontNamesForFamilyName(final String ignored, final Promise promise) // // ============================================================================ + 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; + } + + 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 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); + + float height = layout.getHeight() / density; + + if (conf.numberOfLines != null || mode == FlatHeightsMode.sizes) { + final int lineCount = layout.getLineCount(); + + 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); + } + } + + heights.pushDouble(height); + } + + 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); + } + } + @Nullable private RNTextSizeConf getConf(final ReadableMap specs, final Promise promise, boolean forText) { if (specs == null) { @@ -490,4 +534,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/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 1cb2b53..e4d8cc9 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, @@ -64,6 +64,20 @@ 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 + * + * 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+ */ @@ -74,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, @@ -129,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. * @@ -171,9 +185,36 @@ declare module "react-native-text-size" { }; } + export interface TSFlatSizes { + widths: number[]; + heights: number[]; + } + 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; diff --git a/index.js.flow b/index.js.flow index 3546b14..51eed63 100644 --- a/index.js.flow +++ b/index.js.flow @@ -64,6 +64,20 @@ 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 + * + * 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+ */ @@ -170,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 422dd71..28f4b79 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 @@ -86,20 +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; // no maxlines support + 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]; @@ -114,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] @@ -145,75 +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 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 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; // no maxlines support - - NSLayoutManager *layoutManager = [NSLayoutManager new]; - [layoutManager addTextContainer:textContainer]; - - NSTextStorage *textStorage = [[NSTextStorage alloc] initWithString:@" " attributes:attributes]; - [textStorage addLayoutManager:layoutManager]; - - NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:texts.count]; - const CGFloat epsilon = 0.001; - - 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 = MIN(RCTCeilPixelValue(size.height + epsilon), 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"]); } /** @@ -371,6 +323,80 @@ - (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; + widths[ix] = @0; + continue; + } + + // If empty, return the minimum height of components + if (!text.length) { + heights[ix] = @14; + widths[ix] = @0; + 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 @@ -496,7 +522,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 @@ -517,7 +543,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 @@ -581,4 +607,85 @@ - (NSString *)fontStyleFromTraits:(const NSDictionary *)traits return count ? [NSArray arrayWithObjects:outArr count:count] : nil; } +- (CGSize)maxSizeFromOptions:(const NSDictionary *)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:(const NSDictionary *)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:(const NSDictionary *)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]; + const CGFloat scaleMultiplier = [self fontScaleMultiplier]; + [style setMinimumLineHeight:lineHeight * scaleMultiplier]; + [style setMaximumLineHeight:lineHeight * scaleMultiplier]; + [attributes setObject:style forKey:NSParagraphStyleAttributeName]; + } + + return attributes; +} + +- (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:(CGFloat)size + withOptions:(const NSDictionary *)options + withMaxSize:(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 adjusted; +} + @end