Skip to content

Commit 92b8d7f

Browse files
committed
Fix Android text size on displays with a different density
[ANDROID] [FIXED] - Measure and draw text at the surface's display density, not the primary display's
1 parent a4733b1 commit 92b8d7f

50 files changed

Lines changed: 1038 additions & 179 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

gradle.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ react.internal.useHermesStable=false
1818
# Controls whether to use Hermes from nightly builds. This will speed up builds
1919
# but should NOT be turned on for CI or release builds.
2020
react.internal.useHermesNightly=true
21+
22+
# Enabled parallel sync for Gradle 9.4+
23+
org.gradle.tooling.parallel=true

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import android.annotation.SuppressLint;
2222
import android.content.Context;
2323
import android.graphics.Point;
24+
import android.util.DisplayMetrics;
2425
import android.os.SystemClock;
2526
import android.view.View;
2627
import android.view.accessibility.AccessibilityEvent;
@@ -105,6 +106,7 @@
105106
import java.util.Map;
106107
import java.util.Queue;
107108
import java.util.Set;
109+
import java.util.concurrent.ConcurrentHashMap;
108110
import java.util.concurrent.ConcurrentLinkedQueue;
109111
import java.util.concurrent.CopyOnWriteArrayList;
110112

@@ -185,6 +187,13 @@ public class FabricUIManager
185187

186188
private final TextEffectRegistry mTextEffectRegistry = new TextEffectRegistry();
187189

190+
/**
191+
* Memoizes the {@link DisplayMetrics} synthesized for each distinct (pointScaleFactor, fontScale)
192+
* pair seen during measurement. In practice this holds one entry per display the app has surfaces
193+
* on, but text measurement is hot enough that allocating per call is worth avoiding.
194+
*/
195+
private final Map<Long, DisplayMetrics> mSurfaceDisplayMetricsCache = new ConcurrentHashMap<>();
196+
188197
private final BatchEventDispatchedListener mBatchEventDispatchedListener;
189198

190199
private final List<UIManagerListener> mListeners = new CopyOnWriteArrayList<>();
@@ -550,21 +559,52 @@ private NativeArray measureLines(
550559
ReadableMapBuffer attributedString,
551560
ReadableMapBuffer paragraphAttributes,
552561
float width,
553-
float height) {
562+
float height,
563+
float pointScaleFactor,
564+
float fontScale) {
554565
ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS);
566+
DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale);
555567

556568
return (NativeArray)
557569
TextLayoutManager.measureLines(
558570
mReactApplicationContext.getAssets(),
559571
ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext),
560572
attributedString,
561573
paragraphAttributes,
562-
PixelUtil.toPixelFromDIP(width),
563-
PixelUtil.toPixelFromDIP(height),
574+
PixelUtil.toPixelFromDIP(width, metrics),
575+
PixelUtil.toPixelFromDIP(height, metrics),
564576
textViewManager instanceof ReactTextViewManagerCallback
565577
? (ReactTextViewManagerCallback) textViewManager
566578
: null,
567-
mTextEffectRegistry);
579+
mTextEffectRegistry,
580+
metrics);
581+
}
582+
583+
/**
584+
* The {@link DisplayMetrics} text should be measured against for the surface currently being laid
585+
* out.
586+
*
587+
* <p>{@code pointScaleFactor} and {@code fontScale} originate from the surface's {@code
588+
* LayoutContext}, which {@link com.facebook.react.runtime.ReactSurfaceImpl} derives from the
589+
* Activity's resources — i.e. from the display the surface is actually on. {@link
590+
* DisplayMetricsHolder} in contrast always describes the device's primary display, so on a
591+
* secondary display (Samsung DeX, desktop mode, an external monitor, a freeform window) the two
592+
* disagree and text is measured at one scale but mounted at another.
593+
*/
594+
private DisplayMetrics surfaceDisplayMetrics(float pointScaleFactor, float fontScale) {
595+
if (!ReactNativeFeatureFlags.enablePerSurfaceTextScaleAndroid()) {
596+
return DisplayMetricsHolder.getScreenDisplayMetrics();
597+
}
598+
599+
long key = (((long) Float.floatToRawIntBits(pointScaleFactor)) << 32) | (Float.floatToRawIntBits(fontScale) & 0xFFFFFFFFL);
600+
DisplayMetrics cached = mSurfaceDisplayMetricsCache.get(key);
601+
if (cached != null) {
602+
return cached;
603+
}
604+
605+
DisplayMetrics metrics = PixelUtil.displayMetricsFor(pointScaleFactor, fontScale);
606+
DisplayMetrics existing = mSurfaceDisplayMetricsCache.putIfAbsent(key, metrics);
607+
return existing != null ? existing : metrics;
568608
}
569609

570610
public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) {
@@ -639,24 +679,28 @@ public long measureText(
639679
float maxWidth,
640680
float minHeight,
641681
float maxHeight,
642-
@Nullable float[] attachmentsPositions) {
682+
@Nullable float[] attachmentsPositions,
683+
float pointScaleFactor,
684+
float fontScale) {
643685

644686
ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS);
687+
DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale);
645688

646689
return TextLayoutManager.measureText(
647690
mReactApplicationContext.getAssets(),
648691
ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext),
649692
attributedString,
650693
paragraphAttributes,
651-
getYogaSize(minWidth, maxWidth),
694+
getYogaSize(minWidth, maxWidth, metrics),
652695
getYogaMeasureMode(minWidth, maxWidth),
653-
getYogaSize(minHeight, maxHeight),
696+
getYogaSize(minHeight, maxHeight, metrics),
654697
getYogaMeasureMode(minHeight, maxHeight),
655698
textViewManager instanceof ReactTextViewManagerCallback
656699
? (ReactTextViewManagerCallback) textViewManager
657700
: null,
658701
attachmentsPositions,
659-
mTextEffectRegistry);
702+
mTextEffectRegistry,
703+
metrics);
660704
}
661705

662706
@AnyThread
@@ -668,22 +712,26 @@ public PreparedLayout prepareTextLayout(
668712
float minWidth,
669713
float maxWidth,
670714
float minHeight,
671-
float maxHeight) {
715+
float maxHeight,
716+
float pointScaleFactor,
717+
float fontScale) {
672718
ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS);
719+
DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale);
673720

674721
return TextLayoutManager.createPreparedLayout(
675722
mReactApplicationContext.getAssets(),
676723
ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext),
677724
attributedString,
678725
paragraphAttributes,
679-
getYogaSize(minWidth, maxWidth),
726+
getYogaSize(minWidth, maxWidth, metrics),
680727
getYogaMeasureMode(minWidth, maxWidth),
681-
getYogaSize(minHeight, maxHeight),
728+
getYogaSize(minHeight, maxHeight, metrics),
682729
getYogaMeasureMode(minHeight, maxHeight),
683730
textViewManager instanceof ReactTextViewManagerCallback
684731
? (ReactTextViewManagerCallback) textViewManager
685732
: null,
686-
mTextEffectRegistry);
733+
mTextEffectRegistry,
734+
metrics);
687735
}
688736

689737
@AnyThread
@@ -697,7 +745,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags(
697745
preparedLayout.getVerticalOffset(),
698746
reactTags,
699747
preparedLayout.getTextBreakStrategy(),
700-
preparedLayout.getJustificationMode());
748+
preparedLayout.getJustificationMode(),
749+
preparedLayout.getDisplayMetrics());
701750
}
702751

703752
@AnyThread
@@ -709,11 +758,15 @@ public float[] measurePreparedLayout(
709758
float maxWidth,
710759
float minHeight,
711760
float maxHeight) {
761+
// A prepared layout is in physical pixels of the display it was laid out on, so its constraints
762+
// have to be converted with the metrics it was prepared with rather than the primary display's.
763+
DisplayMetrics metrics = preparedLayout.getDisplayMetrics();
764+
712765
return TextLayoutManager.measurePreparedLayout(
713766
preparedLayout,
714-
getYogaSize(minWidth, maxWidth),
767+
getYogaSize(minWidth, maxWidth, metrics),
715768
getYogaMeasureMode(minWidth, maxWidth),
716-
getYogaSize(minHeight, maxHeight),
769+
getYogaSize(minHeight, maxHeight, metrics),
717770
getYogaMeasureMode(minHeight, maxHeight));
718771
}
719772

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/LayoutMetricsConversions.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package com.facebook.react.fabric.mounting
99

10+
import android.util.DisplayMetrics
1011
import android.view.View.MeasureSpec
1112
import com.facebook.react.uimanager.PixelUtil.dpToPx
1213
import com.facebook.yoga.YogaMeasureMode
@@ -40,6 +41,20 @@ internal interface LayoutMetricsConversions {
4041
maxSize.dpToPx()
4142
}
4243

44+
/**
45+
* Same as [getYogaSize], but converts using [metrics] rather than the process-wide
46+
* [com.facebook.react.uimanager.DisplayMetricsHolder], which always tracks the primary display.
47+
*/
48+
@JvmStatic
49+
fun getYogaSize(minSize: Float, maxSize: Float, metrics: DisplayMetrics): Float =
50+
if (minSize == maxSize) {
51+
maxSize.dpToPx(metrics)
52+
} else if (maxSize.isInfinite()) {
53+
Float.POSITIVE_INFINITY
54+
} else {
55+
maxSize.dpToPx(metrics)
56+
}
57+
4358
@JvmStatic
4459
fun getYogaMeasureMode(minSize: Float, maxSize: Float): YogaMeasureMode =
4560
if (minSize == maxSize) {

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<<2fc347cdb33327437d29e5fd91e24011>>
7+
* @generated SignedSource<<9d2dd4be9427f8e3bc4ffdcb61f23c76>>
88
*/
99

1010
/**
@@ -276,6 +276,12 @@ public object ReactNativeFeatureFlags {
276276
@JvmStatic
277277
public fun enableNativeCSSParsing(): Boolean = accessor.enableNativeCSSParsing()
278278

279+
/**
280+
* Measures and mounts text using the density of the display the surface is on, instead of the process-wide DisplayMetricsHolder (which always tracks the primary display).
281+
*/
282+
@JvmStatic
283+
public fun enablePerSurfaceTextScaleAndroid(): Boolean = accessor.enablePerSurfaceTextScaleAndroid()
284+
279285
/**
280286
* Enables caching text layout artifacts for later reuse
281287
*/

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<<a506d2515fce404e19bd61099cc60117>>
7+
* @generated SignedSource<<f2921974a2554c78a6a12f833e4cf5cb>>
88
*/
99

1010
/**
@@ -61,6 +61,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
6161
private var enableMountingCoordinatorPullModelAndroidCache: Boolean? = null
6262
private var enableMutationObserverByDefaultCache: Boolean? = null
6363
private var enableNativeCSSParsingCache: Boolean? = null
64+
private var enablePerSurfaceTextScaleAndroidCache: Boolean? = null
6465
private var enablePreparedTextLayoutCache: Boolean? = null
6566
private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null
6667
private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null
@@ -477,6 +478,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
477478
return cached
478479
}
479480

481+
override fun enablePerSurfaceTextScaleAndroid(): Boolean {
482+
var cached = enablePerSurfaceTextScaleAndroidCache
483+
if (cached == null) {
484+
cached = ReactNativeFeatureFlagsCxxInterop.enablePerSurfaceTextScaleAndroid()
485+
enablePerSurfaceTextScaleAndroidCache = cached
486+
}
487+
return cached
488+
}
489+
480490
override fun enablePreparedTextLayout(): Boolean {
481491
var cached = enablePreparedTextLayoutCache
482492
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<<1ef72233f02973021b83bd2e2aa1f69b>>
7+
* @generated SignedSource<<6f38ac16b33db913b5ffbde151c2fc0c>>
88
*/
99

1010
/**
@@ -110,6 +110,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
110110

111111
@DoNotStrip @JvmStatic public external fun enableNativeCSSParsing(): Boolean
112112

113+
@DoNotStrip @JvmStatic public external fun enablePerSurfaceTextScaleAndroid(): Boolean
114+
113115
@DoNotStrip @JvmStatic public external fun enablePreparedTextLayout(): Boolean
114116

115117
@DoNotStrip @JvmStatic public external fun enablePropsUpdateReconciliationAndroid(): 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<<eb9958ddc04dd1cd8d1add366f5f7741>>
7+
* @generated SignedSource<<53359f6086cfbdae9c9cfec04548b6ff>>
88
*/
99

1010
/**
@@ -105,6 +105,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
105105

106106
override fun enableNativeCSSParsing(): Boolean = false
107107

108+
override fun enablePerSurfaceTextScaleAndroid(): Boolean = false
109+
108110
override fun enablePreparedTextLayout(): Boolean = false
109111

110112
override fun enablePropsUpdateReconciliationAndroid(): 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<<9ae32c46a5a6310ef96eb91c9ea5b12e>>
7+
* @generated SignedSource<<911cf02f383e704acc90b8302041d5a1>>
88
*/
99

1010
/**
@@ -65,6 +65,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
6565
private var enableMountingCoordinatorPullModelAndroidCache: Boolean? = null
6666
private var enableMutationObserverByDefaultCache: Boolean? = null
6767
private var enableNativeCSSParsingCache: Boolean? = null
68+
private var enablePerSurfaceTextScaleAndroidCache: Boolean? = null
6869
private var enablePreparedTextLayoutCache: Boolean? = null
6970
private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null
7071
private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null
@@ -522,6 +523,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
522523
return cached
523524
}
524525

526+
override fun enablePerSurfaceTextScaleAndroid(): Boolean {
527+
var cached = enablePerSurfaceTextScaleAndroidCache
528+
if (cached == null) {
529+
cached = currentProvider.enablePerSurfaceTextScaleAndroid()
530+
accessedFeatureFlags.add("enablePerSurfaceTextScaleAndroid")
531+
enablePerSurfaceTextScaleAndroidCache = cached
532+
}
533+
return cached
534+
}
535+
525536
override fun enablePreparedTextLayout(): Boolean {
526537
var cached = enablePreparedTextLayoutCache
527538
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<<1a1d47f2d85404c776e55db40f7dbc6e>>
7+
* @generated SignedSource<<0bffb447de4a420f3df5d2b43d62be21>>
88
*/
99

1010
/**
@@ -105,6 +105,8 @@ public interface ReactNativeFeatureFlagsProvider {
105105

106106
@DoNotStrip public fun enableNativeCSSParsing(): Boolean
107107

108+
@DoNotStrip public fun enablePerSurfaceTextScaleAndroid(): Boolean
109+
108110
@DoNotStrip public fun enablePreparedTextLayout(): Boolean
109111

110112
@DoNotStrip public fun enablePropsUpdateReconciliationAndroid(): Boolean

0 commit comments

Comments
 (0)