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