Skip to content

Commit 1fe3ff8

Browse files
NickGerlemanfacebook-github-bot
authored andcommitted
Fix more text rounding bugs (#51303)
Summary: Pull Request resolved: #51303 We do a lot of incorrect `cei()` in text measurement, compensated later by other incorrect bits. There are a couple of interesting bits here: A "desired width" is how large a hypothetical text layout would like to be. It is a floating point value, and to avoid truncation, a container must be larger than the desired width. So ceiling this is correct. We are also passed available width, which may be an exact specification, not at a subpixel boundary. Ceiling this is totally incorrect, since Yoga will disregard our ceiled version, and we just created a layout, larger than our actual measure will be. We must instead floor `availableWidth`, and we create our layout based off of that, to not give an extra physical pixel, that may not be given to us, if later layout rounding doesn't go our way. We then ceil `desiredWidth` earlier. Finally, when we have an exact measuremode, we create the layout so that it uses guaranteedWidth`, but act as if it takes the full available space, per-contract, which may be a subpixel size larger. This means we cannot create layouts which cause truncation. I used this change as an opportunity to clean up `createLayout()`, since we are gating anyways, to remove the duplicated paths, and to avoid the unnecessary `TextDirectionHeuristic` work for BoringLayout case, and since `StaticLayoutBuilder` already defaults to the heuristic we are manually setting. Changelog: [Android][Fixed] - Fix more text rounding bugs Reviewed By: joevilches Differential Revision: D74685353 fbshipit-source-id: 3700df657958c6efb46bfbbe674051d16a2b7c26
1 parent 3391959 commit 1fe3ff8

21 files changed

Lines changed: 290 additions & 66 deletions

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<255deaad99bac2448e0a11bad50a770b>>
7+
* @generated SignedSource<<e9a109fd77667dd0cb945ef6ef9737f2>>
88
*/
99

1010
/**
@@ -42,6 +42,12 @@ public object ReactNativeFeatureFlags {
4242
@JvmStatic
4343
public fun animatedShouldSignalBatch(): Boolean = accessor.animatedShouldSignalBatch()
4444

45+
/**
46+
* Do not incorrectly ceil the available width of an Android text layout
47+
*/
48+
@JvmStatic
49+
public fun avoidCeilingAvailableAndroidTextWidth(): Boolean = accessor.avoidCeilingAvailableAndroidTextWidth()
50+
4551
/**
4652
* Use a C++ implementation of Native Animated instead of the platform implementation.
4753
*/

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<de51ff5f161363975ef67549e717f010>>
7+
* @generated SignedSource<<1a5cd689229d4e0c31070123045e84da>>
88
*/
99

1010
/**
@@ -22,6 +22,7 @@ package com.facebook.react.internal.featureflags
2222
internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAccessor {
2323
private var commonTestFlagCache: Boolean? = null
2424
private var animatedShouldSignalBatchCache: Boolean? = null
25+
private var avoidCeilingAvailableAndroidTextWidthCache: Boolean? = null
2526
private var cxxNativeAnimatedEnabledCache: Boolean? = null
2627
private var disableMainQueueSyncDispatchIOSCache: Boolean? = null
2728
private var disableMountItemReorderingAndroidCache: Boolean? = null
@@ -86,6 +87,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
8687
return cached
8788
}
8889

90+
override fun avoidCeilingAvailableAndroidTextWidth(): Boolean {
91+
var cached = avoidCeilingAvailableAndroidTextWidthCache
92+
if (cached == null) {
93+
cached = ReactNativeFeatureFlagsCxxInterop.avoidCeilingAvailableAndroidTextWidth()
94+
avoidCeilingAvailableAndroidTextWidthCache = cached
95+
}
96+
return cached
97+
}
98+
8999
override fun cxxNativeAnimatedEnabled(): Boolean {
90100
var cached = cxxNativeAnimatedEnabledCache
91101
if (cached == null) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<7a0e83bca17b862a0454985d75131959>>
7+
* @generated SignedSource<<c39d0c1834797b3309b4fc5ce814280c>>
88
*/
99

1010
/**
@@ -32,6 +32,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
3232

3333
@DoNotStrip @JvmStatic public external fun animatedShouldSignalBatch(): Boolean
3434

35+
@DoNotStrip @JvmStatic public external fun avoidCeilingAvailableAndroidTextWidth(): Boolean
36+
3537
@DoNotStrip @JvmStatic public external fun cxxNativeAnimatedEnabled(): Boolean
3638

3739
@DoNotStrip @JvmStatic public external fun disableMainQueueSyncDispatchIOS(): Boolean

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<918f59b35fc1af953f615a6e7eace76e>>
7+
* @generated SignedSource<<7f357475254104729cc7910c14e1c1fb>>
88
*/
99

1010
/**
@@ -27,6 +27,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
2727

2828
override fun animatedShouldSignalBatch(): Boolean = false
2929

30+
override fun avoidCeilingAvailableAndroidTextWidth(): Boolean = true
31+
3032
override fun cxxNativeAnimatedEnabled(): Boolean = false
3133

3234
override fun disableMainQueueSyncDispatchIOS(): Boolean = false

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<1b685268279bbfd65820e45f5028d4dd>>
7+
* @generated SignedSource<<14cd1a58bd153dedda045a72c1494caa>>
88
*/
99

1010
/**
@@ -26,6 +26,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
2626

2727
private var commonTestFlagCache: Boolean? = null
2828
private var animatedShouldSignalBatchCache: Boolean? = null
29+
private var avoidCeilingAvailableAndroidTextWidthCache: Boolean? = null
2930
private var cxxNativeAnimatedEnabledCache: Boolean? = null
3031
private var disableMainQueueSyncDispatchIOSCache: Boolean? = null
3132
private var disableMountItemReorderingAndroidCache: Boolean? = null
@@ -92,6 +93,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
9293
return cached
9394
}
9495

96+
override fun avoidCeilingAvailableAndroidTextWidth(): Boolean {
97+
var cached = avoidCeilingAvailableAndroidTextWidthCache
98+
if (cached == null) {
99+
cached = currentProvider.avoidCeilingAvailableAndroidTextWidth()
100+
accessedFeatureFlags.add("avoidCeilingAvailableAndroidTextWidth")
101+
avoidCeilingAvailableAndroidTextWidthCache = cached
102+
}
103+
return cached
104+
}
105+
95106
override fun cxxNativeAnimatedEnabled(): Boolean {
96107
var cached = cxxNativeAnimatedEnabledCache
97108
if (cached == null) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<e9d98230a39a243ea748105a77ebd1e0>>
7+
* @generated SignedSource<<593b1d64dc31038140032a6b0a439700>>
88
*/
99

1010
/**
@@ -27,6 +27,8 @@ public interface ReactNativeFeatureFlagsProvider {
2727

2828
@DoNotStrip public fun animatedShouldSignalBatch(): Boolean
2929

30+
@DoNotStrip public fun avoidCeilingAvailableAndroidTextWidth(): Boolean
31+
3032
@DoNotStrip public fun cxxNativeAnimatedEnabled(): Boolean
3133

3234
@DoNotStrip public fun disableMainQueueSyncDispatchIOS(): Boolean

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,108 @@ private static Layout createLayout(
381381
@Nullable TextUtils.TruncateAt ellipsizeMode,
382382
int maxNumberOfLines,
383383
TextPaint paint) {
384+
if (ReactNativeFeatureFlags.avoidCeilingAvailableAndroidTextWidth()) {
385+
return createLayoutWithCorrectRounding(
386+
text,
387+
boring,
388+
width,
389+
widthYogaMeasureMode,
390+
includeFontPadding,
391+
textBreakStrategy,
392+
hyphenationFrequency,
393+
alignment,
394+
justificationMode,
395+
ellipsizeMode,
396+
maxNumberOfLines,
397+
paint);
398+
} else {
399+
return createLayoutWithBuggedRounding(
400+
text,
401+
boring,
402+
width,
403+
widthYogaMeasureMode,
404+
includeFontPadding,
405+
textBreakStrategy,
406+
hyphenationFrequency,
407+
alignment,
408+
justificationMode,
409+
ellipsizeMode,
410+
maxNumberOfLines,
411+
paint);
412+
}
413+
}
414+
415+
private static Layout createLayoutWithCorrectRounding(
416+
Spannable text,
417+
BoringLayout.Metrics boring,
418+
float width,
419+
YogaMeasureMode widthYogaMeasureMode,
420+
boolean includeFontPadding,
421+
int textBreakStrategy,
422+
int hyphenationFrequency,
423+
Layout.Alignment alignment,
424+
int justificationMode,
425+
@Nullable TextUtils.TruncateAt ellipsizeMode,
426+
int maxNumberOfLines,
427+
TextPaint paint) {
428+
// If our text is boring, and fully fits in the available space, we can represent the text
429+
// layout as a BoringLayout
430+
if (boring != null
431+
&& (widthYogaMeasureMode == YogaMeasureMode.UNDEFINED
432+
|| boring.width <= Math.floor(width))) {
433+
int layoutWidth =
434+
widthYogaMeasureMode == YogaMeasureMode.EXACTLY ? (int) Math.floor(width) : boring.width;
435+
return BoringLayout.make(
436+
text, paint, layoutWidth, alignment, 1.f, 0.f, boring, includeFontPadding);
437+
}
438+
439+
int desiredWidth = (int) Math.ceil(Layout.getDesiredWidth(text, paint));
440+
441+
int layoutWidth =
442+
widthYogaMeasureMode == YogaMeasureMode.EXACTLY
443+
? (int) Math.floor(width)
444+
: widthYogaMeasureMode == YogaMeasureMode.UNDEFINED
445+
? desiredWidth
446+
: Math.min(desiredWidth, (int) Math.floor(width));
447+
448+
StaticLayout.Builder builder =
449+
StaticLayout.Builder.obtain(text, 0, text.length(), paint, layoutWidth)
450+
.setAlignment(alignment)
451+
.setLineSpacing(0.f, 1.f)
452+
.setIncludePad(includeFontPadding)
453+
.setBreakStrategy(textBreakStrategy)
454+
.setHyphenationFrequency(hyphenationFrequency);
455+
456+
if (ReactNativeFeatureFlags.incorporateMaxLinesDuringAndroidLayout()) {
457+
if (maxNumberOfLines != ReactConstants.UNSET && maxNumberOfLines != 0) {
458+
builder.setEllipsize(ellipsizeMode).setMaxLines(maxNumberOfLines);
459+
}
460+
}
461+
462+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
463+
builder.setJustificationMode(justificationMode);
464+
}
465+
466+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
467+
builder.setUseLineSpacingFromFallbacks(true);
468+
}
469+
470+
return builder.build();
471+
}
472+
473+
private static Layout createLayoutWithBuggedRounding(
474+
Spannable text,
475+
BoringLayout.Metrics boring,
476+
float width,
477+
YogaMeasureMode widthYogaMeasureMode,
478+
boolean includeFontPadding,
479+
int textBreakStrategy,
480+
int hyphenationFrequency,
481+
Layout.Alignment alignment,
482+
int justificationMode,
483+
@Nullable TextUtils.TruncateAt ellipsizeMode,
484+
int maxNumberOfLines,
485+
TextPaint paint) {
384486
Layout layout;
385487

386488
int spanLength = text.length();
@@ -788,6 +890,12 @@ private static float calculateWidth(
788890
YogaMeasureMode widthYogaMeasureMode,
789891
int calculatedLineCount) {
790892
if (ReactNativeFeatureFlags.useAndroidTextLayoutWidthDirectly()) {
893+
// Our layout must be created at a physical pixel boundary, so may be sized smaller by a
894+
// subpixel compared to the assigned layout width.
895+
if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) {
896+
return width;
897+
}
898+
791899
return layout.getWidth();
792900
}
793901

@@ -829,7 +937,7 @@ private static float calculateWidth(
829937
// where the container is measured smaller than text. Math.ceil prevents it
830938
// See T136756103 for investigation
831939
if (android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
832-
calculatedWidth = (float) Math.ceil(calculatedWidth);
940+
calculatedWidth = Math.min((float) Math.ceil(calculatedWidth), width);
833941
}
834942
return calculatedWidth;
835943
}

packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<7199b7c0c5d3b389e504f615a813b6c5>>
7+
* @generated SignedSource<<d74dc8182a14fddbd7118149298515bd>>
88
*/
99

1010
/**
@@ -51,6 +51,12 @@ class ReactNativeFeatureFlagsJavaProvider
5151
return method(javaProvider_);
5252
}
5353

54+
bool avoidCeilingAvailableAndroidTextWidth() override {
55+
static const auto method =
56+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("avoidCeilingAvailableAndroidTextWidth");
57+
return method(javaProvider_);
58+
}
59+
5460
bool cxxNativeAnimatedEnabled() override {
5561
static const auto method =
5662
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("cxxNativeAnimatedEnabled");
@@ -335,6 +341,11 @@ bool JReactNativeFeatureFlagsCxxInterop::animatedShouldSignalBatch(
335341
return ReactNativeFeatureFlags::animatedShouldSignalBatch();
336342
}
337343

344+
bool JReactNativeFeatureFlagsCxxInterop::avoidCeilingAvailableAndroidTextWidth(
345+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
346+
return ReactNativeFeatureFlags::avoidCeilingAvailableAndroidTextWidth();
347+
}
348+
338349
bool JReactNativeFeatureFlagsCxxInterop::cxxNativeAnimatedEnabled(
339350
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
340351
return ReactNativeFeatureFlags::cxxNativeAnimatedEnabled();
@@ -597,6 +608,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
597608
makeNativeMethod(
598609
"animatedShouldSignalBatch",
599610
JReactNativeFeatureFlagsCxxInterop::animatedShouldSignalBatch),
611+
makeNativeMethod(
612+
"avoidCeilingAvailableAndroidTextWidth",
613+
JReactNativeFeatureFlagsCxxInterop::avoidCeilingAvailableAndroidTextWidth),
600614
makeNativeMethod(
601615
"cxxNativeAnimatedEnabled",
602616
JReactNativeFeatureFlagsCxxInterop::cxxNativeAnimatedEnabled),

packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<3d383541d829ce105ac5e97a5d35ded1>>
7+
* @generated SignedSource<<a5ab0022f6a01bd6e929d36d9d87db10>>
88
*/
99

1010
/**
@@ -36,6 +36,9 @@ class JReactNativeFeatureFlagsCxxInterop
3636
static bool animatedShouldSignalBatch(
3737
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
3838

39+
static bool avoidCeilingAvailableAndroidTextWidth(
40+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
41+
3942
static bool cxxNativeAnimatedEnabled(
4043
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
4144

packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<33afce4bb21ad12cb920eee24ba921b2>>
7+
* @generated SignedSource<<88fdbea2f97f628187164a47a9737da0>>
88
*/
99

1010
/**
@@ -34,6 +34,10 @@ bool ReactNativeFeatureFlags::animatedShouldSignalBatch() {
3434
return getAccessor().animatedShouldSignalBatch();
3535
}
3636

37+
bool ReactNativeFeatureFlags::avoidCeilingAvailableAndroidTextWidth() {
38+
return getAccessor().avoidCeilingAvailableAndroidTextWidth();
39+
}
40+
3741
bool ReactNativeFeatureFlags::cxxNativeAnimatedEnabled() {
3842
return getAccessor().cxxNativeAnimatedEnabled();
3943
}

0 commit comments

Comments
 (0)