Skip to content

Commit 15d7e8f

Browse files
committed
Add ceilToClosestPixel option; refactor duplicate code on Android
1 parent 6f6d712 commit 15d7e8f

File tree

5 files changed

+87
-62
lines changed

5 files changed

+87
-62
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ fontStyle | string | 'normal' | One of "normal" or "italic".
100100
lineHeight | number | (none) | The line height of each line. Defaults to the font size.
101101
numberOfLines | number | (none) | Limit the number of lines the text can render on
102102
fontVariant | array | (none) | _iOS only_
103+
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.
103104
allowFontScaling | boolean | true | To respect the user' setting of large fonts (i.e. use SP units).
104105
letterSpacing | number | (none) | Additional spacing between characters (aka `tracking`).<br>**Note:** In iOS a zero cancels automatic kerning.<br>_All iOS, Android with API 21+_
105106
includeFontPadding | boolean | true | Include additional top and bottom padding, to avoid clipping certain characters.<br>_Android only_

android/src/main/java/com/github/amarcruz/rntextsize/RNTextSizeModule.java

Lines changed: 39 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -89,25 +89,26 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) {
8989
return;
9090
}
9191

92-
final SpannableString text = (SpannableString) RNTextSizeSpannedText
93-
.spannedFromSpecsAndText(mReactContext, conf, new SpannableString(_text));
92+
final SpannableStringBuilder sb = new SpannableStringBuilder(_text);
93+
RNTextSizeSpannedText.spannedFromSpecsAndText(mReactContext, conf, sb);
94+
9495

9596
final TextPaint textPaint = new TextPaint(TextPaint.ANTI_ALIAS_FLAG);
9697
Layout layout = null;
9798
try {
98-
final BoringLayout.Metrics boring = BoringLayout.isBoring(text, textPaint);
99+
final BoringLayout.Metrics boring = BoringLayout.isBoring(sb, textPaint);
99100
int hintWidth = (int) width;
100101

101102
if (boring == null) {
102103
// Not boring, ie. the text is multiline or contains unicode characters.
103-
final float desiredWidth = Layout.getDesiredWidth(text, textPaint);
104+
final float desiredWidth = Layout.getDesiredWidth(sb, textPaint);
104105
if (desiredWidth <= width) {
105106
hintWidth = (int) Math.ceil(desiredWidth);
106107
}
107108
} else if (boring.width <= width) {
108109
// Single-line and width unknown or bigger than the width of the text.
109110
layout = BoringLayout.make(
110-
text,
111+
sb,
111112
textPaint,
112113
boring.width,
113114
Layout.Alignment.ALIGN_NORMAL,
@@ -118,29 +119,7 @@ public void measure(@Nullable final ReadableMap specs, final Promise promise) {
118119
}
119120

120121
if (layout == null) {
121-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
122-
StaticLayout.Builder builder = StaticLayout.Builder.obtain(text, 0, text.length(), textPaint, hintWidth)
123-
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
124-
.setBreakStrategy(conf.getTextBreakStrategy())
125-
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
126-
.setIncludePad(includeFontPadding)
127-
.setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER);
128-
if (conf.numberOfLines != null) {
129-
builder = builder.setMaxLines(conf.numberOfLines)
130-
.setEllipsize(TextUtils.TruncateAt.END);
131-
}
132-
layout = builder.build();
133-
} else {
134-
layout = new StaticLayout(
135-
text,
136-
textPaint,
137-
hintWidth,
138-
Layout.Alignment.ALIGN_NORMAL,
139-
SPACING_MULTIPLIER,
140-
SPACING_ADDITION,
141-
includeFontPadding
142-
);
143-
}
122+
layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, hintWidth);
144123
}
145124

146125
final int lineCount = layout.getLineCount();
@@ -231,31 +210,7 @@ public void flatHeights(@Nullable final ReadableMap specs, final Promise promise
231210

232211
// Reset the SB text, the attrs will expand to its full length
233212
sb.replace(0, sb.length(), text);
234-
235-
if (Build.VERSION.SDK_INT >= 23) {
236-
StaticLayout.Builder builder = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, (int) width)
237-
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
238-
.setBreakStrategy(textBreakStrategy)
239-
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
240-
.setIncludePad(includeFontPadding)
241-
.setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER);
242-
if (conf.numberOfLines != null) {
243-
builder = builder.setMaxLines(conf.numberOfLines)
244-
.setEllipsize(TextUtils.TruncateAt.END);
245-
}
246-
layout = builder.build();
247-
} else {
248-
layout = new StaticLayout(
249-
sb,
250-
textPaint,
251-
(int) width,
252-
Layout.Alignment.ALIGN_NORMAL,
253-
SPACING_MULTIPLIER,
254-
SPACING_ADDITION,
255-
includeFontPadding
256-
);
257-
}
258-
213+
layout = buildStaticLayout(conf, includeFontPadding, sb, textPaint, (int) width);
259214
result.pushDouble(layout.getHeight() / density);
260215
}
261216

@@ -499,4 +454,35 @@ private void addFamilyToArray(
499454
}
500455
}
501456
}
457+
458+
/** Builds the staticLayout from the configuration */
459+
private Layout buildStaticLayout(
460+
RNTextSizeConf conf, boolean includeFontPadding, SpannableStringBuilder sb,
461+
TextPaint textPaint, int hintWidth) {
462+
Layout layout;
463+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
464+
StaticLayout.Builder builder = StaticLayout.Builder.obtain(sb, 0, sb.length(), textPaint, hintWidth)
465+
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
466+
.setBreakStrategy(conf.getTextBreakStrategy())
467+
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL)
468+
.setIncludePad(includeFontPadding)
469+
.setLineSpacing(SPACING_ADDITION, SPACING_MULTIPLIER);
470+
if (conf.numberOfLines != null) {
471+
builder = builder.setMaxLines(conf.numberOfLines)
472+
.setEllipsize(TextUtils.TruncateAt.END);
473+
}
474+
layout = builder.build();
475+
} else {
476+
layout = new StaticLayout(
477+
sb,
478+
textPaint,
479+
hintWidth,
480+
Layout.Alignment.ALIGN_NORMAL,
481+
SPACING_MULTIPLIER,
482+
SPACING_ADDITION,
483+
includeFontPadding
484+
);
485+
}
486+
return layout;
487+
}
502488
}

index.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ declare module "react-native-text-size" {
7070
* prop on `<Text>`
7171
*/
7272
numberOfLines?: number;
73+
/**
74+
* @platform ios
75+
*
76+
* If true, we ceil the output to the closest pixel. This is React Native's
77+
* default behavior, but can be disabled if you're trying to measure text in
78+
* a native component that doesn't respect this.
79+
*/
80+
ceilToClosestPixel?: boolean;
7381
/** @platform ios */
7482
fontVariant?: Array<TSFontVariant>;
7583
/** iOS all, Android SDK 21+ with RN 0.55+ */

index.js.flow

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ export type TSFontSpecs = {
7070
* prop on `<Text>`
7171
*/
7272
numberOfLines?: number,
73+
/**
74+
* @platform ios
75+
*
76+
* If true, we ceil the output to the closest pixel. This is React Native's
77+
* default behavior, but can be disabled if you're trying to measure text in
78+
* a native component that doesn't respect this.
79+
*/
80+
ceilToClosestPixel?: boolean;
7381
/** @platform ios */
7482
fontVariant?: Array<TSFontVariant>,
7583
/** iOS all, Android SDK 21+ with RN 0.55+ */

ios/RNTextSize.m

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,8 @@ - (dispatch_queue_t)methodQueue {
109109
size.width -= letterSpacing;
110110
}
111111

112-
const CGFloat epsilon = 0.001;
113-
const CGFloat width = MIN(RCTCeilPixelValue(size.width + epsilon), maxSize.width);
114-
const CGFloat height = MIN(RCTCeilPixelValue(size.height + epsilon), maxSize.height);
112+
const CGFloat width = [self adjustMeasuredSize:size.width withOptions:options withMaxSize:maxSize.width];
113+
const CGFloat height = [self adjustMeasuredSize:size.height withOptions:options withMaxSize:maxSize.height];
115114
const NSInteger lineCount = [self getLineCount:layoutManager];
116115

117116
NSMutableDictionary *result = [[NSMutableDictionary alloc]
@@ -179,9 +178,6 @@ - (dispatch_queue_t)methodQueue {
179178

180179
NSMutableArray<NSNumber *> *result = [[NSMutableArray alloc] initWithCapacity:texts.count];
181180

182-
// When there's no font scaling, adding epsilon offsets the calculation
183-
// by a bit, and when there is, it's required. This was tested empirically.
184-
const CGFloat epsilon = [self fontScaleMultiplier] != 1.0 ? 0.001 : 0;
185181

186182
for (int ix = 0; ix < texts.count; ix++) {
187183
NSString *text = texts[ix];
@@ -203,7 +199,9 @@ - (dispatch_queue_t)methodQueue {
203199
[textStorage replaceCharactersInRange:range withString:text];
204200
CGSize size = [layoutManager usedRectForTextContainer:textContainer].size;
205201

206-
const CGFloat height = MIN(RCTCeilPixelValue(size.height + epsilon), maxSize.height);
202+
const CGFloat height = [self adjustMeasuredSize:size.height
203+
withOptions:options
204+
withMaxSize:maxSize.height];
207205
result[ix] = @(height);
208206
}
209207

@@ -606,8 +604,8 @@ - (NSTextContainer *)textContainerFromOptions:(NSDictionary * _Nullable)options
606604
* parameters and the options the user passes in.
607605
*/
608606
- (NSDictionary<NSAttributedStringKey,id> *const)textStorageAttributesFromOptions:(NSDictionary * _Nullable)options
609-
withFont:(UIFont *const _Nullable)font
610-
withLetterSpacing:(CGFloat)letterSpacing
607+
withFont:(UIFont *const _Nullable)font
608+
withLetterSpacing:(CGFloat)letterSpacing
611609
{
612610
NSMutableDictionary<NSAttributedStringKey,id> *const attributes = [[NSMutableDictionary alloc] init];
613611
[attributes setObject:font forKey:NSFontAttributeName];
@@ -632,4 +630,28 @@ - (CGFloat)fontScaleMultiplier {
632630
return _bridge ? _bridge.accessibilityManager.multiplier : 1.0;
633631
}
634632

633+
/**
634+
* React Native ceils sizes to the nearest pixels by default, so we usually
635+
* want to adjust it to that
636+
*/
637+
- (CGFloat)adjustMeasuredSize:(const CGFloat)size
638+
withOptions:(NSDictionary *)options
639+
withMaxSize:(const CGFloat)maxSize
640+
{
641+
CGFloat adjusted = size;
642+
643+
NSString *const key = @"ceilToClosestPixel";
644+
BOOL ceilToClosestPixel = ![options objectForKey:key] || [options[key] boolValue];
645+
646+
if (ceilToClosestPixel) {
647+
// When there's no font scaling, adding epsilon offsets the calculation
648+
// by a bit, and when there is, it's required. This was tested empirically.
649+
const CGFloat epsilon = [self fontScaleMultiplier] != 1.0 ? 0.001 : 0;
650+
adjusted = RCTCeilPixelValue(size + epsilon);
651+
}
652+
adjusted = MIN(adjusted, maxSize);
653+
654+
return size;
655+
}
656+
635657
@end

0 commit comments

Comments
 (0)