From eec59e8c2f6a4326959282db719203efed6d56fc Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:45:57 +0200 Subject: [PATCH 1/7] 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 --- gradle.properties | 3 + .../react/fabric/FabricUIManager.java | 83 +++++++-- .../mounting/LayoutMetricsConversions.kt | 15 ++ .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../com/facebook/react/uimanager/PixelUtil.kt | 119 +++++++++++-- .../react/views/text/PreparedLayout.kt | 6 + .../views/text/PreparedLayoutTextView.kt | 1 + .../react/views/text/ReactTextView.java | 28 ++- .../react/views/text/ReactTextViewManager.kt | 17 ++ .../react/views/text/TextAttributeProps.kt | 52 +++++- .../react/views/text/TextAttributes.kt | 33 +++- .../react/views/text/TextLayoutManager.kt | 142 +++++++++++++--- .../react/views/textinput/ReactEditText.kt | 3 +- .../views/textinput/ReactTextInputManager.kt | 2 + .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../facebook/react/uimanager/PixelUtilTest.kt | 38 +++++ .../react/views/text/ReactTextViewTest.kt | 13 ++ .../views/text/TextAttributePropsTest.kt | 50 ++++++ .../text/TextLayoutManagerDensityTest.kt | 160 ++++++++++++++++++ .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 112 +++++++----- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../components/text/ParagraphShadowNode.cpp | 22 ++- .../textinput/BaseTextInputShadowNode.h | 9 +- .../AndroidTextInputShadowNode.cpp | 7 + .../textlayoutmanager/TextLayoutContext.h | 7 + .../TextLayoutManagerExtended.h | 5 +- .../textlayoutmanager/TextMeasureCache.h | 28 ++- .../textlayoutmanager/TextLayoutManager.cpp | 43 +++-- .../textlayoutmanager/TextLayoutManager.h | 1 + .../textlayoutmanager/TextLayoutManager.h | 1 + .../textlayoutmanager/TextLayoutManager.mm | 8 +- .../tests/TextLayoutManagerTest.cpp | 54 ++++++ .../ReactNativeFeatureFlags.config.js | 11 ++ .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- yarn.lock | 8 +- 49 files changed, 1029 insertions(+), 178 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt diff --git a/gradle.properties b/gradle.properties index 1028b5c5238e..17b8449c9049 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,6 @@ react.internal.useHermesStable=false # Controls whether to use Hermes from nightly builds. This will speed up builds # but should NOT be turned on for CI or release builds. react.internal.useHermesNightly=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 7479f9cb53e8..0c7a2368b2b3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -21,6 +21,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Point; +import android.util.DisplayMetrics; import android.os.SystemClock; import android.view.View; import android.view.accessibility.AccessibilityEvent; @@ -105,6 +106,7 @@ import java.util.Map; import java.util.Queue; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CopyOnWriteArrayList; @@ -185,6 +187,13 @@ public class FabricUIManager private final TextEffectRegistry mTextEffectRegistry = new TextEffectRegistry(); + /** + * Memoizes the {@link DisplayMetrics} synthesized for each distinct (pointScaleFactor, fontScale) + * pair seen during measurement. In practice this holds one entry per display the app has surfaces + * on, but text measurement is hot enough that allocating per call is worth avoiding. + */ + private final Map mSurfaceDisplayMetricsCache = new ConcurrentHashMap<>(); + private final BatchEventDispatchedListener mBatchEventDispatchedListener; private final List mListeners = new CopyOnWriteArrayList<>(); @@ -550,8 +559,11 @@ private NativeArray measureLines( ReadableMapBuffer attributedString, ReadableMapBuffer paragraphAttributes, float width, - float height) { + float height, + float pointScaleFactor, + float fontScale) { ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS); + DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale); return (NativeArray) TextLayoutManager.measureLines( @@ -559,12 +571,40 @@ private NativeArray measureLines( ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, - PixelUtil.toPixelFromDIP(width), - PixelUtil.toPixelFromDIP(height), + PixelUtil.toPixelFromDIP(width, metrics), + PixelUtil.toPixelFromDIP(height, metrics), textViewManager instanceof ReactTextViewManagerCallback ? (ReactTextViewManagerCallback) textViewManager : null, - mTextEffectRegistry); + mTextEffectRegistry, + metrics); + } + + /** + * The {@link DisplayMetrics} text should be measured against for the surface currently being laid + * out. + * + *

{@code pointScaleFactor} and {@code fontScale} originate from the surface's {@code + * LayoutContext}, which {@link com.facebook.react.runtime.ReactSurfaceImpl} derives from the + * Activity's resources — i.e. from the display the surface is actually on. {@link + * DisplayMetricsHolder} in contrast always describes the device's primary display, so on a + * secondary display (Samsung DeX, desktop mode, an external monitor, a freeform window) the two + * disagree and text is measured at one scale but mounted at another. + */ + private DisplayMetrics surfaceDisplayMetrics(float pointScaleFactor, float fontScale) { + if (!ReactNativeFeatureFlags.enablePerSurfaceTextScaleAndroid()) { + return DisplayMetricsHolder.getScreenDisplayMetrics(); + } + + long key = (((long) Float.floatToRawIntBits(pointScaleFactor)) << 32) | (Float.floatToRawIntBits(fontScale) & 0xFFFFFFFFL); + DisplayMetrics cached = mSurfaceDisplayMetricsCache.get(key); + if (cached != null) { + return cached; + } + + DisplayMetrics metrics = PixelUtil.displayMetricsFor(pointScaleFactor, fontScale); + DisplayMetrics existing = mSurfaceDisplayMetricsCache.putIfAbsent(key, metrics); + return existing != null ? existing : metrics; } public @Nullable Integer getColor(int surfaceId, String[] resourcePaths) { @@ -639,24 +679,28 @@ public long measureText( float maxWidth, float minHeight, float maxHeight, - @Nullable float[] attachmentsPositions) { + @Nullable float[] attachmentsPositions, + float pointScaleFactor, + float fontScale) { ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS); + DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale); return TextLayoutManager.measureText( mReactApplicationContext.getAssets(), ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, - getYogaSize(minWidth, maxWidth), + getYogaSize(minWidth, maxWidth, metrics), getYogaMeasureMode(minWidth, maxWidth), - getYogaSize(minHeight, maxHeight), + getYogaSize(minHeight, maxHeight, metrics), getYogaMeasureMode(minHeight, maxHeight), textViewManager instanceof ReactTextViewManagerCallback ? (ReactTextViewManagerCallback) textViewManager : null, attachmentsPositions, - mTextEffectRegistry); + mTextEffectRegistry, + metrics); } @AnyThread @@ -668,22 +712,26 @@ public PreparedLayout prepareTextLayout( float minWidth, float maxWidth, float minHeight, - float maxHeight) { + float maxHeight, + float pointScaleFactor, + float fontScale) { ViewManager textViewManager = mViewManagerRegistry.get(ReactTextViewManager.REACT_CLASS); + DisplayMetrics metrics = surfaceDisplayMetrics(pointScaleFactor, fontScale); return TextLayoutManager.createPreparedLayout( mReactApplicationContext.getAssets(), ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, - getYogaSize(minWidth, maxWidth), + getYogaSize(minWidth, maxWidth, metrics), getYogaMeasureMode(minWidth, maxWidth), - getYogaSize(minHeight, maxHeight), + getYogaSize(minHeight, maxHeight, metrics), getYogaMeasureMode(minHeight, maxHeight), textViewManager instanceof ReactTextViewManagerCallback ? (ReactTextViewManagerCallback) textViewManager : null, - mTextEffectRegistry); + mTextEffectRegistry, + metrics); } @AnyThread @@ -697,7 +745,8 @@ public PreparedLayout reusePreparedLayoutWithNewReactTags( preparedLayout.getVerticalOffset(), reactTags, preparedLayout.getTextBreakStrategy(), - preparedLayout.getJustificationMode()); + preparedLayout.getJustificationMode(), + preparedLayout.getDisplayMetrics()); } @AnyThread @@ -709,11 +758,15 @@ public float[] measurePreparedLayout( float maxWidth, float minHeight, float maxHeight) { + // A prepared layout is in physical pixels of the display it was laid out on, so its constraints + // have to be converted with the metrics it was prepared with rather than the primary display's. + DisplayMetrics metrics = preparedLayout.getDisplayMetrics(); + return TextLayoutManager.measurePreparedLayout( preparedLayout, - getYogaSize(minWidth, maxWidth), + getYogaSize(minWidth, maxWidth, metrics), getYogaMeasureMode(minWidth, maxWidth), - getYogaSize(minHeight, maxHeight), + getYogaSize(minHeight, maxHeight, metrics), getYogaMeasureMode(minHeight, maxHeight)); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/LayoutMetricsConversions.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/LayoutMetricsConversions.kt index e99eed1c439c..0b70aa9ee9c0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/LayoutMetricsConversions.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/LayoutMetricsConversions.kt @@ -7,6 +7,7 @@ package com.facebook.react.fabric.mounting +import android.util.DisplayMetrics import android.view.View.MeasureSpec import com.facebook.react.uimanager.PixelUtil.dpToPx import com.facebook.yoga.YogaMeasureMode @@ -40,6 +41,20 @@ internal interface LayoutMetricsConversions { maxSize.dpToPx() } + /** + * Same as [getYogaSize], but converts using [metrics] rather than the process-wide + * [com.facebook.react.uimanager.DisplayMetricsHolder], which always tracks the primary display. + */ + @JvmStatic + fun getYogaSize(minSize: Float, maxSize: Float, metrics: DisplayMetrics): Float = + if (minSize == maxSize) { + maxSize.dpToPx(metrics) + } else if (maxSize.isInfinite()) { + Float.POSITIVE_INFINITY + } else { + maxSize.dpToPx(metrics) + } + @JvmStatic fun getYogaMeasureMode(minSize: Float, maxSize: Float): YogaMeasureMode = if (minSize == maxSize) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 1596d8eb2a46..adab414d4ece 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2fc347cdb33327437d29e5fd91e24011>> + * @generated SignedSource<<9d2dd4be9427f8e3bc4ffdcb61f23c76>> */ /** @@ -276,6 +276,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enableNativeCSSParsing(): Boolean = accessor.enableNativeCSSParsing() + /** + * 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). + */ + @JvmStatic + public fun enablePerSurfaceTextScaleAndroid(): Boolean = accessor.enablePerSurfaceTextScaleAndroid() + /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 59d53089af06..207b524c952d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -61,6 +61,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableMountingCoordinatorPullModelAndroidCache: Boolean? = null private var enableMutationObserverByDefaultCache: Boolean? = null private var enableNativeCSSParsingCache: Boolean? = null + private var enablePerSurfaceTextScaleAndroidCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null @@ -477,6 +478,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enablePerSurfaceTextScaleAndroid(): Boolean { + var cached = enablePerSurfaceTextScaleAndroidCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enablePerSurfaceTextScaleAndroid() + enablePerSurfaceTextScaleAndroidCache = cached + } + return cached + } + override fun enablePreparedTextLayout(): Boolean { var cached = enablePreparedTextLayoutCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index fbbef2ca6587..5aca10f7badc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<1ef72233f02973021b83bd2e2aa1f69b>> + * @generated SignedSource<<6f38ac16b33db913b5ffbde151c2fc0c>> */ /** @@ -110,6 +110,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enableNativeCSSParsing(): Boolean + @DoNotStrip @JvmStatic public external fun enablePerSurfaceTextScaleAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun enablePreparedTextLayout(): Boolean @DoNotStrip @JvmStatic public external fun enablePropsUpdateReconciliationAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index b9cd0522e87f..2fac69470fc9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<53359f6086cfbdae9c9cfec04548b6ff>> */ /** @@ -105,6 +105,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enableNativeCSSParsing(): Boolean = false + override fun enablePerSurfaceTextScaleAndroid(): Boolean = false + override fun enablePreparedTextLayout(): Boolean = false override fun enablePropsUpdateReconciliationAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 4ed61aa19114..100eb216b505 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9ae32c46a5a6310ef96eb91c9ea5b12e>> + * @generated SignedSource<<911cf02f383e704acc90b8302041d5a1>> */ /** @@ -65,6 +65,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableMountingCoordinatorPullModelAndroidCache: Boolean? = null private var enableMutationObserverByDefaultCache: Boolean? = null private var enableNativeCSSParsingCache: Boolean? = null + private var enablePerSurfaceTextScaleAndroidCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null private var enableRuntimeSchedulerQueueClearingOnErrorCache: Boolean? = null @@ -522,6 +523,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enablePerSurfaceTextScaleAndroid(): Boolean { + var cached = enablePerSurfaceTextScaleAndroidCache + if (cached == null) { + cached = currentProvider.enablePerSurfaceTextScaleAndroid() + accessedFeatureFlags.add("enablePerSurfaceTextScaleAndroid") + enablePerSurfaceTextScaleAndroidCache = cached + } + return cached + } + override fun enablePreparedTextLayout(): Boolean { var cached = enablePreparedTextLayoutCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 1b52bd34d580..a75263bef4c2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<1a1d47f2d85404c776e55db40f7dbc6e>> + * @generated SignedSource<<0bffb447de4a420f3df5d2b43d62be21>> */ /** @@ -105,6 +105,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enableNativeCSSParsing(): Boolean + @DoNotStrip public fun enablePerSurfaceTextScaleAndroid(): Boolean + @DoNotStrip public fun enablePreparedTextLayout(): Boolean @DoNotStrip public fun enablePropsUpdateReconciliationAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt index 5682b1643fd1..a66a31b82066 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt @@ -7,70 +7,145 @@ package com.facebook.react.uimanager +import android.content.Context +import android.util.DisplayMetrics import android.util.TypedValue +import androidx.annotation.VisibleForTesting +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import kotlin.math.min /** Android dp to pixel manipulation */ public object PixelUtil { - /** Convert from DIP to PX */ + /** + * Convert from DIP to PX, using [metrics] instead of the process-wide + * [DisplayMetricsHolder]. + * + * The holder always tracks the device's primary display, so on a surface attached to a display + * with a different density (Samsung DeX, desktop mode, an external monitor, a freeform window) + * only metrics derived from that display produce conversions that agree with the surface's + * `pointScaleFactor`. + */ @JvmStatic - public fun toPixelFromDIP(value: Float): Float { + public fun toPixelFromDIP(value: Float, metrics: DisplayMetrics): Float { if (value.isNaN()) { return Float.NaN } - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - value, - DisplayMetricsHolder.getScreenDisplayMetrics(), - ) + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, metrics) } + /** Convert from DIP to PX */ + @JvmStatic + public fun toPixelFromDIP(value: Float): Float = + toPixelFromDIP(value, DisplayMetricsHolder.getScreenDisplayMetrics()) + /** Convert from DIP to PX */ @JvmStatic public fun toPixelFromDIP(value: Double): Float { return toPixelFromDIP(value.toFloat()) } - /** Convert from SP to PX */ - @JvmOverloads + /** Convert from SP to PX, using [metrics] instead of the process-wide [DisplayMetricsHolder]. */ @JvmStatic - public fun toPixelFromSP(value: Float, maxFontScale: Float = Float.NaN): Float { + public fun toPixelFromSP(value: Float, maxFontScale: Float, metrics: DisplayMetrics): Float { if (value.isNaN()) { return Float.NaN } - val displayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics() - val scaledValue = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, displayMetrics) + val scaledValue = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, metrics) if (maxFontScale >= 1) { - return min(scaledValue, value * displayMetrics.density * maxFontScale) + return min(scaledValue, value * metrics.density * maxFontScale) } return scaledValue } + /** Convert from SP to PX */ + @JvmOverloads + @JvmStatic + public fun toPixelFromSP(value: Float, maxFontScale: Float = Float.NaN): Float = + toPixelFromSP(value, maxFontScale, DisplayMetricsHolder.getScreenDisplayMetrics()) + /** Convert from SP to PX */ @JvmStatic public fun toPixelFromSP(value: Double): Float { return toPixelFromSP(value.toFloat()) } - /** Convert from PX to DP */ + /** Convert from PX to DP, using [metrics] instead of the process-wide [DisplayMetricsHolder]. */ @JvmStatic - public fun toDIPFromPixel(value: Float): Float { + public fun toDIPFromPixel(value: Float, metrics: DisplayMetrics): Float { if (value.isNaN()) { return Float.NaN } - return value / DisplayMetricsHolder.getScreenDisplayMetrics().density + return value / metrics.density } + /** Convert from PX to DP */ + @JvmStatic + public fun toDIPFromPixel(value: Float): Float = + toDIPFromPixel(value, DisplayMetricsHolder.getScreenDisplayMetrics()) + /** @return [Float] that represents the density of the display metrics for device screen. */ @JvmStatic public fun getDisplayMetricDensity(): Float = DisplayMetricsHolder.getScreenDisplayMetrics().density + /** + * Builds a [DisplayMetrics] describing a display with the given [density] and system font scale. + * + * Only the fields consumed by [TypedValue.applyDimension] and by the conversions above are + * meaningful; this is a scale descriptor, not a description of a physical display. + */ + @JvmStatic + @Suppress("DEPRECATION") // DisplayMetrics.scaledDensity + public fun displayMetricsFor(density: Float, fontScale: Float): DisplayMetrics { + return DisplayMetrics().apply { + this.density = density + this.scaledDensity = density * fontScale + this.densityDpi = (density * DisplayMetrics.DENSITY_DEFAULT).toInt() + this.xdpi = density * DisplayMetrics.DENSITY_DEFAULT + this.ydpi = density * DisplayMetrics.DENSITY_DEFAULT + } + } + + /** + * The [DisplayMetrics] conversions on the mounting side should use for a view living in + * [context]. + * + * A themed React context wraps the Activity, so its resources describe the display the surface is + * actually on — the same display [com.facebook.react.runtime.ReactSurfaceImpl] took the surface's + * `pointScaleFactor` from. [DisplayMetricsHolder] instead always describes the primary display, + * so the two disagree whenever the surface is on a secondary one. + * + * Returns the holder's metrics while `enablePerSurfaceTextScaleAndroid` is off, preserving the + * previous behaviour exactly. + */ + @JvmStatic + public fun displayMetricsOf(context: Context): DisplayMetrics = + if (isPerSurfaceTextScaleEnabled()) { + context.resources.displayMetrics + } else { + DisplayMetricsHolder.getScreenDisplayMetrics() + } + + // Resolved once: this sits on the text draw path, and reaching into the C++-backed feature flags + // there would mean a JNI hop per conversion. + @Volatile private var perSurfaceTextScaleEnabled: Boolean? = null + + private fun isPerSurfaceTextScaleEnabled(): Boolean = + perSurfaceTextScaleEnabled + ?: ReactNativeFeatureFlags.enablePerSurfaceTextScaleAndroid().also { + perSurfaceTextScaleEnabled = it + } + + @VisibleForTesting + internal fun resetPerSurfaceTextScaleCache() { + perSurfaceTextScaleEnabled = null + } + /* Kotlin extensions */ public fun Int.dpToPx(): Float = toPixelFromDIP(this.toFloat()) @@ -87,4 +162,16 @@ public object PixelUtil { public fun Float.pxToDp(): Float = toDIPFromPixel(this) public fun Double.pxToDp(): Float = toDIPFromPixel(this.toFloat()) + + public fun Int.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this.toFloat(), metrics) + + public fun Float.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this, metrics) + + public fun Double.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this.toFloat(), metrics) + + public fun Int.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this.toFloat(), metrics) + + public fun Float.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this, metrics) + + public fun Double.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this.toFloat(), metrics) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt index 51836c884989..a5d4cceaaa5c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayout.kt @@ -8,6 +8,7 @@ package com.facebook.react.views.text import android.text.Layout +import android.util.DisplayMetrics import com.facebook.proguard.annotations.DoNotStrip /** @@ -22,4 +23,9 @@ internal class PreparedLayout( val reactTags: IntArray, val textBreakStrategy: Int, val justificationMode: Int, + /** + * The metrics the layout was laid out against. A prepared layout is in physical pixels of a + * specific display, so measuring it must convert back with the same metrics. + */ + val displayMetrics: DisplayMetrics, ) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt index cf91b5b8ccee..ae03a0cbf7b2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/PreparedLayoutTextView.kt @@ -503,6 +503,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re reactTags, textBreakStrategy, justificationMode, + displayMetrics, ) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index c20f271bb512..ee4a10e262e2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -18,6 +18,7 @@ import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.util.Linkify; +import android.util.DisplayMetrics; import android.util.TypedValue; import android.view.Gravity; import android.view.KeyEvent; @@ -155,7 +156,13 @@ private void initView() { } private static WritableMap inlineViewJson( - int visibility, int index, int left, int top, int right, int bottom) { + int visibility, + int index, + int left, + int top, + int right, + int bottom, + DisplayMetrics metrics) { WritableMap json = Arguments.createMap(); if (visibility == View.GONE) { json.putString("visibility", "gone"); @@ -163,10 +170,10 @@ private static WritableMap inlineViewJson( } else if (visibility == View.VISIBLE) { json.putString("visibility", "visible"); json.putInt("index", index); - json.putDouble("left", PixelUtil.toDIPFromPixel(left)); - json.putDouble("top", PixelUtil.toDIPFromPixel(top)); - json.putDouble("right", PixelUtil.toDIPFromPixel(right)); - json.putDouble("bottom", PixelUtil.toDIPFromPixel(bottom)); + json.putDouble("left", PixelUtil.toDIPFromPixel(left, metrics)); + json.putDouble("top", PixelUtil.toDIPFromPixel(top, metrics)); + json.putDouble("right", PixelUtil.toDIPFromPixel(right, metrics)); + json.putDouble("bottom", PixelUtil.toDIPFromPixel(bottom, metrics)); } else { json.putString("visibility", "unknown"); json.putInt("index", index); @@ -205,7 +212,8 @@ protected void onDraw(Canvas canvas) { // how exactly lines are aligned, just their width Layout.Alignment.ALIGN_NORMAL, (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) ? -1 : getJustificationMode(), - getPaint()); + getPaint(), + PixelUtil.displayMetricsOf(getContext())); setText(spanned); } @@ -488,10 +496,11 @@ public void setAdjustFontSizeToFit(boolean adjustsFontSizeToFit) { } public void setFontSize(float fontSize) { + DisplayMetrics metrics = PixelUtil.displayMetricsOf(getContext()); mFontSize = mAdjustsFontSizeToFit - ? (float) Math.ceil(PixelUtil.toPixelFromSP(fontSize)) - : (float) Math.ceil(PixelUtil.toPixelFromDIP(fontSize)); + ? (float) Math.ceil(PixelUtil.toPixelFromSP(fontSize, Float.NaN, metrics)) + : (float) Math.ceil(PixelUtil.toPixelFromDIP(fontSize, metrics)); applyTextAttributes(); } @@ -525,7 +534,8 @@ public void setLetterSpacing(float letterSpacing) { return; } - float letterSpacingPixels = PixelUtil.toPixelFromDIP(letterSpacing); + float letterSpacingPixels = + PixelUtil.toPixelFromDIP(letterSpacing, PixelUtil.displayMetricsOf(getContext())); // `letterSpacingPixels` and `getEffectiveFontSize` are both in pixels, // yielding an accurate em value. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index edcd85130f34..88be33850341 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -21,6 +21,7 @@ import com.facebook.common.logging.FLog import com.facebook.react.R import com.facebook.react.common.ReactConstants import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.common.mapbuffer.MapBuffer import com.facebook.react.internal.SystraceSection import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags @@ -31,6 +32,7 @@ import com.facebook.react.uimanager.IViewManagerWithChildren import com.facebook.react.uimanager.LayoutShadowNode import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.ReferenceStateWrapper @@ -165,6 +167,7 @@ public constructor( attributedString, reactTextViewManagerCallback, TextEffectRegistry.current, + PixelUtil.displayMetricsOf(view.context), ) view.setSpanned(spanned) @@ -201,6 +204,20 @@ public constructor( val layout = preparedLayout.layout val text = layout.text val spanned = if (text is Spannable) text else SpannableString(text) + + if (ReactBuildConfig.DEBUG) { + val mountDensity = PixelUtil.displayMetricsOf(view.context).density + if (preparedLayout.displayMetrics.density != mountDensity) { + FLog.w( + ReactConstants.TAG, + "Text was laid out at density %f but is being mounted on a display of density %f. " + + "It will render at the wrong size until the surface is laid out again.", + preparedLayout.displayMetrics.density, + mountDensity, + ) + } + } + view.setSpanned(spanned) view.setPreparedLayout(preparedLayout) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt index 4ebaa5630c09..215cba5b653a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt @@ -10,6 +10,7 @@ package com.facebook.react.views.text import android.os.Build import android.text.Layout import android.text.TextUtils.TruncateAt +import android.util.DisplayMetrics import android.util.LayoutDirection import android.view.Gravity import com.facebook.common.logging.FLog @@ -17,8 +18,8 @@ import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants import com.facebook.react.common.mapbuffer.MapBuffer -import com.facebook.react.uimanager.PixelUtil.toPixelFromDIP -import com.facebook.react.uimanager.PixelUtil.toPixelFromSP +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ReactAccessibilityDelegate import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole import com.facebook.react.uimanager.ReactStylesDiffMap @@ -32,7 +33,23 @@ import kotlin.math.ceil // TODO: T63643819 refactor naming of TextAttributeProps to make explicit that this represents // TextAttributes and not TextProps. As part of this refactor extract methods that don't belong to // TextAttributeProps (e.g. TextAlign) -public class TextAttributeProps private constructor() { +public class TextAttributeProps +private constructor( + /** + * The metrics `sp` and `dp` attributes are resolved against. + * + * This is the density of the display the surface is on, which is not necessarily the density of + * the device's primary display that [DisplayMetricsHolder] tracks. + */ + private val displayMetrics: DisplayMetrics, +) { + private fun toPixelFromSP(value: Float, maxFontScale: Float = Float.NaN): Float = + PixelUtil.toPixelFromSP(value, maxFontScale, displayMetrics) + + private fun toPixelFromDIP(value: Float): Float = PixelUtil.toPixelFromDIP(value, displayMetrics) + + private fun toPixelFromDIP(value: Double): Float = toPixelFromDIP(value.toFloat()) + public var lineHeight: Float = Float.NaN private set(value) { lineHeightInput = value @@ -422,8 +439,17 @@ public class TextAttributeProps private constructor() { private const val DEFAULT_HYPHENATION_FREQUENCY = Layout.HYPHENATION_FREQUENCY_NONE /** Build a TextAttributeProps using data from the [MapBuffer] received as a parameter. */ - public fun fromMapBuffer(props: MapBuffer): TextAttributeProps { - val result = TextAttributeProps() + @JvmStatic + public fun fromMapBuffer(props: MapBuffer): TextAttributeProps = + fromMapBuffer(props, DisplayMetricsHolder.getScreenDisplayMetrics()) + + /** + * Build a TextAttributeProps whose `sp`/`dp` attributes are resolved against [displayMetrics] + * rather than the process-wide [DisplayMetricsHolder]. + */ + @JvmStatic + public fun fromMapBuffer(props: MapBuffer, displayMetrics: DisplayMetrics): TextAttributeProps { + val result = TextAttributeProps(displayMetrics) // TODO T83483191: Review constants that are not being set! val iterator = props.iterator() @@ -487,8 +513,20 @@ public class TextAttributeProps private constructor() { return result } - public fun fromReadableMap(props: ReactStylesDiffMap): TextAttributeProps { - val result = TextAttributeProps() + @JvmStatic + public fun fromReadableMap(props: ReactStylesDiffMap): TextAttributeProps = + fromReadableMap(props, DisplayMetricsHolder.getScreenDisplayMetrics()) + + /** + * Build a TextAttributeProps whose `sp`/`dp` attributes are resolved against [displayMetrics] + * rather than the process-wide [DisplayMetricsHolder]. + */ + @JvmStatic + public fun fromReadableMap( + props: ReactStylesDiffMap, + displayMetrics: DisplayMetrics, + ): TextAttributeProps { + val result = TextAttributeProps(displayMetrics) result.setNumberOfLines(getIntProp(props, ViewProps.NUMBER_OF_LINES, ReactConstants.UNSET)) result.lineHeight = getFloatProp(props, ViewProps.LINE_HEIGHT, ReactConstants.UNSET.toFloat()) result.letterSpacing = getFloatProp(props, ViewProps.LETTER_SPACING, Float.NaN) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.kt index bdbbbfbfcd35..72e4de4507af 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.kt @@ -7,8 +7,10 @@ package com.facebook.react.views.text +import android.util.DisplayMetrics import com.facebook.common.logging.FLog import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ViewDefaults @@ -19,7 +21,19 @@ import com.facebook.react.uimanager.ViewDefaults * the rendered aka effective value. For example, to figure out the rendered/effective font size, * you need to take into account the fontSize, maxFontSizeMultiplier, and allowFontScaling props. */ -public class TextAttributes { +public class TextAttributes +@JvmOverloads +constructor( + /** + * The metrics `sp` and `dp` values are resolved against, or `null` to fall back to the + * process-wide [DisplayMetricsHolder]. Views should pass the metrics of their own context so + * that text on a secondary display is sized for that display; see [PixelUtil.displayMetricsOf]. + */ + private val displayMetrics: DisplayMetrics? = null, +) { + private val metrics: DisplayMetrics + get() = displayMetrics ?: DisplayMetricsHolder.getScreenDisplayMetrics() + public var allowFontScaling: Boolean = true public var fontSize: Float = Float.NaN public var lineHeight: Float = Float.NaN @@ -29,7 +43,7 @@ public class TextAttributes { @JvmField internal var textTransform: TextTransform = TextTransform.UNSET public fun applyChild(child: TextAttributes): TextAttributes { - val result = TextAttributes() + val result = TextAttributes(displayMetrics) // allowFontScaling is always determined by the root Text // component so don't allow the child to overwrite it. @@ -66,10 +80,12 @@ public class TextAttributes { get() { val fontSize = if (!fontSize.isNaN()) fontSize else ViewDefaults.FONT_SIZE_SP return if (allowFontScaling) { - Math.ceil(PixelUtil.toPixelFromSP(fontSize, effectiveMaxFontSizeMultiplier).toDouble()) + Math.ceil( + PixelUtil.toPixelFromSP(fontSize, effectiveMaxFontSizeMultiplier, metrics).toDouble(), + ) .toInt() } else { - Math.ceil(PixelUtil.toPixelFromDIP(fontSize).toDouble()).toInt() + Math.ceil(PixelUtil.toPixelFromDIP(fontSize, metrics).toDouble()).toInt() } } @@ -80,8 +96,9 @@ public class TextAttributes { } val lineHeight: Float = - if (allowFontScaling) PixelUtil.toPixelFromSP(lineHeight, effectiveMaxFontSizeMultiplier) - else PixelUtil.toPixelFromDIP(lineHeight) + if (allowFontScaling) + PixelUtil.toPixelFromSP(lineHeight, effectiveMaxFontSizeMultiplier, metrics) + else PixelUtil.toPixelFromDIP(lineHeight, metrics) // Take into account the requested line height // and the height of the inline images. @@ -98,8 +115,8 @@ public class TextAttributes { val letterSpacingPixels: Float = if (allowFontScaling) - PixelUtil.toPixelFromSP(letterSpacing, effectiveMaxFontSizeMultiplier) - else PixelUtil.toPixelFromDIP(letterSpacing) + PixelUtil.toPixelFromSP(letterSpacing, effectiveMaxFontSizeMultiplier, metrics) + else PixelUtil.toPixelFromDIP(letterSpacing, metrics) // `letterSpacingPixels` and `getEffectiveFontSize` are both in pixels, // yielding an accurate em value. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 66e6f24fbb0a..cbe0d20fd999 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -21,6 +21,7 @@ import android.text.StaticLayout import android.text.TextDirectionHeuristics import android.text.TextPaint import android.text.TextUtils +import android.util.DisplayMetrics import android.util.LayoutDirection import android.view.Gravity import android.view.View @@ -245,6 +246,7 @@ internal object TextLayoutManager { ops: MutableList, outputReactTags: IntArray?, textEffectRegistry: TextEffectRegistry?, + displayMetrics: DisplayMetrics, ) { // Track pending text effects to coalesce consecutive fragments with the same effects into // single spans, avoiding duplicate draws (e.g. multiple accent marks in HighlighterTextSpan). @@ -256,7 +258,10 @@ internal object TextLayoutManager { val start = sb.length val textAttributes = - TextAttributeProps.fromMapBuffer(fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES)) + TextAttributeProps.fromMapBuffer( + fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES), + displayMetrics, + ) sb.append( TextTransform.apply(fragment.getString(FR_KEY_STRING), textAttributes.textTransform), @@ -272,8 +277,8 @@ internal object TextLayoutManager { sb.length, TextInlineViewPlaceholderSpan( reactTag, - inlineViewSizeToPixels(fragment.getDouble(FR_KEY_WIDTH)), - inlineViewSizeToPixels(fragment.getDouble(FR_KEY_HEIGHT)), + inlineViewSizeToPixels(fragment.getDouble(FR_KEY_WIDTH), displayMetrics), + inlineViewSizeToPixels(fragment.getDouble(FR_KEY_HEIGHT), displayMetrics), ), ), ) @@ -437,13 +442,18 @@ internal object TextLayoutManager { fragments: MapBuffer, outputReactTags: IntArray?, textEffectRegistry: TextEffectRegistry?, + displayMetrics: DisplayMetrics, ): Spannable { val text = StringBuilder() val parsedFragments = ArrayList(fragments.count) for (i in 0 until fragments.count) { val fragment = fragments.getMapBuffer(i) - val props = TextAttributeProps.fromMapBuffer(fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES)) + val props = + TextAttributeProps.fromMapBuffer( + fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES), + displayMetrics, + ) val fragmentText = TextTransform.apply(fragment.getString(FR_KEY_STRING), props.textTransform) text.append(fragmentText) parsedFragments.add( @@ -492,8 +502,8 @@ internal object TextLayoutManager { spannable.setSpan( TextInlineViewPlaceholderSpan( fragment.reactTag, - inlineViewSizeToPixels(fragment.width), - inlineViewSizeToPixels(fragment.height), + inlineViewSizeToPixels(fragment.width, displayMetrics), + inlineViewSizeToPixels(fragment.height, displayMetrics), ), start, end, @@ -660,9 +670,13 @@ internal object TextLayoutManager { return spannable } + @VisibleForTesting + internal fun inlineViewSizeToPixels(size: Double, displayMetrics: DisplayMetrics): Int = + ceil(PixelUtil.toPixelFromDIP(size.toFloat(), displayMetrics).toDouble()).toInt() + @VisibleForTesting internal fun inlineViewSizeToPixels(size: Double): Int = - ceil(PixelUtil.toPixelFromDIP(size).toDouble()).toInt() + ceil(PixelUtil.toPixelFromDIP(size).toDouble()).toInt() @OptIn(UnstableReactNativeAPI::class) fun getOrCreateSpannableForText( @@ -686,6 +700,28 @@ internal object TextLayoutManager { null, ) + /** + * Builds the spannable for [attributedString], resolving `sp`/`dp` attributes against + * [displayMetrics] rather than the process-wide + * [com.facebook.react.uimanager.DisplayMetricsHolder]. + */ + @OptIn(UnstableReactNativeAPI::class) + @JvmStatic + fun getOrCreateSpannableForText( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + displayMetrics: DisplayMetrics, + ): Spannable = getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + null, + displayMetrics, + ) + @OptIn(UnstableReactNativeAPI::class) internal fun getOrCreateSpannableForText( assets: AssetManager, @@ -707,6 +743,31 @@ internal object TextLayoutManager { attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry?, + ): Spannable = getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + textEffectRegistry, + DisplayMetricsHolder.getScreenDisplayMetrics(), + ) + + /** + * Builds the spannable for [attributedString], resolving `sp`/`dp` attributes against + * [displayMetrics]. + * + * Callers on the mounting side should pass the metrics of the view's context so that the + * spannable matches the one the shadow tree was measured with; see + * [com.facebook.react.uimanager.PixelUtil.displayMetricsOf]. + */ + @OptIn(UnstableReactNativeAPI::class) + internal fun getOrCreateSpannableForText( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + textEffectRegistry: TextEffectRegistry?, + displayMetrics: DisplayMetrics, ): Spannable { var text: Spannable? if (attributedString.contains(AS_KEY_CACHE_ID)) { @@ -721,6 +782,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback, null, textEffectRegistry, + displayMetrics, ) } @@ -734,7 +796,8 @@ internal object TextLayoutManager { fragments: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, outputReactTags: IntArray?, - textEffectRegistry: TextEffectRegistry? = null, + textEffectRegistry: TextEffectRegistry?, + displayMetrics: DisplayMetrics, ): Spannable { if (ReactNativeFeatureFlags.enableAndroidTextMeasurementOptimizations()) { val spannable = buildSpannableFromFragmentsOptimized( @@ -743,6 +806,7 @@ internal object TextLayoutManager { fragments, outputReactTags, textEffectRegistry, + displayMetrics, ) reactTextViewManagerCallback?.onPostProcessSpannable(spannable) @@ -763,6 +827,7 @@ internal object TextLayoutManager { ops, outputReactTags, textEffectRegistry, + displayMetrics, ) // TODO T31905686: add support for inline Images @@ -966,7 +1031,8 @@ internal object TextLayoutManager { height: Float, heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, - textEffectRegistry: TextEffectRegistry? = null, + textEffectRegistry: TextEffectRegistry?, + displayMetrics: DisplayMetrics, ): Layout { val text = getOrCreateSpannableForText( assets, @@ -974,6 +1040,7 @@ internal object TextLayoutManager { attributedString, reactTextViewManagerCallback, textEffectRegistry, + displayMetrics, ) val paint: TextPaint @@ -981,7 +1048,10 @@ internal object TextLayoutManager { paint = text.getSpans(0, 0, ReactTextPaintHolderSpan::class.java)[0].textPaint } else { val baseTextAttributes = - TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) + TextAttributeProps.fromMapBuffer( + attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES), + displayMetrics, + ) paint = scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment) } @@ -994,6 +1064,7 @@ internal object TextLayoutManager { widthYogaMeasureMode, height, heightYogaMeasureMode, + displayMetrics, ) .layout } @@ -1007,6 +1078,7 @@ internal object TextLayoutManager { widthYogaMeasureMode: YogaMeasureMode, height: Float, heightYogaMeasureMode: YogaMeasureMode, + displayMetrics: DisplayMetrics, ): CreateLayoutResult { val boring = isBoring(text, paint) @@ -1097,6 +1169,7 @@ internal object TextLayoutManager { heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): PreparedLayout = createPreparedLayout( assets, 0, @@ -1108,6 +1181,7 @@ internal object TextLayoutManager { heightYogaMeasureMode, reactTextViewManagerCallback, textEffectRegistry, + displayMetrics, ) @JvmStatic @@ -1123,6 +1197,7 @@ internal object TextLayoutManager { heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): PreparedLayout { val fragments = attributedString.getMapBuffer(AS_KEY_FRAGMENTS) val reactTags = IntArray(fragments.count) @@ -1133,9 +1208,13 @@ internal object TextLayoutManager { reactTextViewManagerCallback, reactTags, textEffectRegistry, + displayMetrics, ) val baseTextAttributes = - TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) + TextAttributeProps.fromMapBuffer( + attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES), + displayMetrics, + ) val result = createLayout( text, newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment), @@ -1145,6 +1224,7 @@ internal object TextLayoutManager { widthYogaMeasureMode, height, heightYogaMeasureMode, + displayMetrics, ) val maximumNumberOfLines = @@ -1167,6 +1247,7 @@ internal object TextLayoutManager { reactTags, result.textBreakStrategy, result.justificationMode, + displayMetrics, ) } @@ -1185,13 +1266,15 @@ internal object TextLayoutManager { alignment: Layout.Alignment, justificationMode: Int, paint: TextPaint, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): Unit { var boring = isBoring(text, paint) var layout: Layout // Minimum font size is 4pts to match the iOS implementation. val minimumFontSize = - (if (minimumFontSizeAttr.isNaN()) 4.dpToPx() else minimumFontSizeAttr).toInt() + (if (minimumFontSizeAttr.isNaN()) 4.dpToPx(displayMetrics) else minimumFontSizeAttr) + .toInt() // Find the largest font size used in the spannable to use as a starting point. var currentFontSize = minimumFontSize @@ -1288,6 +1371,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): Long = measureText( assets, 0, @@ -1300,6 +1384,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback, attachmentsPositions, textEffectRegistry, + displayMetrics, ) @JvmStatic @@ -1316,6 +1401,7 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): Long { // TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic) val layout = createLayoutForMeasurement( @@ -1329,6 +1415,7 @@ internal object TextLayoutManager { heightYogaMeasureMode, reactTextViewManagerCallback, textEffectRegistry, + displayMetrics, ) val maximumNumberOfLines = @@ -1362,16 +1449,16 @@ internal object TextLayoutManager { metrics, ) if (metrics.wasFound) { - attachmentsPositions[attachmentIndex] = metrics.top.pxToDp() - attachmentsPositions[attachmentIndex + 1] = metrics.left.pxToDp() + attachmentsPositions[attachmentIndex] = metrics.top.pxToDp(displayMetrics) + attachmentsPositions[attachmentIndex + 1] = metrics.left.pxToDp(displayMetrics) attachmentIndex += 2 } i = lastAttachmentFoundInSpan } } - val widthInSP = calculatedWidth.pxToDp() - val heightInSP = calculatedHeight.pxToDp() + val widthInSP = calculatedWidth.pxToDp(displayMetrics) + val heightInSP = calculatedHeight.pxToDp(displayMetrics) return YogaMeasureOutput.make(widthInSP, heightInSP) } @@ -1387,6 +1474,7 @@ internal object TextLayoutManager { val layout = preparedLayout.layout val text = layout.text as Spanned val maximumNumberOfLines = preparedLayout.maximumNumberOfLines + val displayMetrics = preparedLayout.displayMetrics val calculatedLineCount = calculateLineCount(layout, maximumNumberOfLines) val calculatedWidth = @@ -1395,8 +1483,8 @@ internal object TextLayoutManager { calculateHeight(layout, height, heightYogaMeasureMode, calculatedLineCount) val retList = ArrayList() - retList.add(calculatedWidth.pxToDp()) - retList.add(calculatedHeight.pxToDp()) + retList.add(calculatedWidth.pxToDp(displayMetrics)) + retList.add(calculatedHeight.pxToDp(displayMetrics)) val metrics = AttachmentMetrics() var lastAttachmentFoundInSpan: Int @@ -1414,10 +1502,10 @@ internal object TextLayoutManager { metrics, ) if (metrics.wasFound) { - retList.add(metrics.top.pxToDp()) - retList.add(metrics.left.pxToDp()) - retList.add(metrics.width.pxToDp()) - retList.add(metrics.height.pxToDp()) + retList.add(metrics.top.pxToDp(displayMetrics)) + retList.add(metrics.left.pxToDp(displayMetrics)) + retList.add(metrics.width.pxToDp(displayMetrics)) + retList.add(metrics.height.pxToDp(displayMetrics)) } i = lastAttachmentFoundInSpan } @@ -1586,6 +1674,7 @@ internal object TextLayoutManager { height: Float, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): WritableArray = measureLines( assetManager, 0, @@ -1595,6 +1684,7 @@ internal object TextLayoutManager { height, reactTextViewManagerCallback, textEffectRegistry, + displayMetrics, ) @JvmStatic @@ -1608,6 +1698,7 @@ internal object TextLayoutManager { height: Float, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + displayMetrics: DisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(), ): WritableArray { val layout = createLayoutForMeasurement( assetManager, @@ -1620,12 +1711,9 @@ internal object TextLayoutManager { YogaMeasureMode.EXACTLY, reactTextViewManagerCallback, textEffectRegistry, + displayMetrics, ) - return FontMetricsUtil.getFontMetrics( - layout.text, - layout, - DisplayMetricsHolder.getScreenDisplayMetrics(), - ) + return FontMetricsUtil.getFontMetrics(layout.text, layout, displayMetrics) } private fun isBoring(text: Spannable, paint: TextPaint): BoringLayout.Metrics? { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index e27330c87149..145fbc08a477 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -62,6 +62,7 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderStyle import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel import com.facebook.react.uimanager.ReactAccessibilityDelegate import com.facebook.react.uimanager.StateWrapper @@ -220,7 +221,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat keyListener = InternalKeyListener() } scrollWatcher = null - textAttributes = TextAttributes() + textAttributes = TextAttributes(PixelUtil.displayMetricsOf(context)) applyTextAttributes() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 49a75f012c62..c7feec83c71a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -46,6 +46,7 @@ import com.facebook.react.uimanager.BaseViewManager import com.facebook.react.uimanager.LayoutShadowNode import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PointerEvents import com.facebook.react.uimanager.ReactStylesDiffMap import com.facebook.react.uimanager.StateWrapper @@ -1036,6 +1037,7 @@ public open class ReactTextInputManager public constructor() : getFontWeightAdjustment(view.context), attributedString, reactTextViewManagerCallback, + PixelUtil.displayMetricsOf(view.context), ) val textBreakStrategy = diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index bd0bdc7ac32b..1e4edd78d157 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<28de1e205f30135e96d5cb94a902faec>> + * @generated SignedSource<> */ /** @@ -285,6 +285,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enablePerSurfaceTextScaleAndroid() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enablePerSurfaceTextScaleAndroid"); + return method(javaProvider_); + } + bool enablePreparedTextLayout() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enablePreparedTextLayout"); @@ -770,6 +776,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enableNativeCSSParsing( return ReactNativeFeatureFlags::enableNativeCSSParsing(); } +bool JReactNativeFeatureFlagsCxxInterop::enablePerSurfaceTextScaleAndroid( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enablePerSurfaceTextScaleAndroid(); +} + bool JReactNativeFeatureFlagsCxxInterop::enablePreparedTextLayout( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enablePreparedTextLayout(); @@ -1154,6 +1165,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enableNativeCSSParsing", JReactNativeFeatureFlagsCxxInterop::enableNativeCSSParsing), + makeNativeMethod( + "enablePerSurfaceTextScaleAndroid", + JReactNativeFeatureFlagsCxxInterop::enablePerSurfaceTextScaleAndroid), makeNativeMethod( "enablePreparedTextLayout", JReactNativeFeatureFlagsCxxInterop::enablePreparedTextLayout), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index a787b50e7c9c..bcd77f03d13d 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<535898dd9498c65f30d56122c06b408b>> + * @generated SignedSource<<7b61a349635adcf72723b9b594d5e680>> */ /** @@ -153,6 +153,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enableNativeCSSParsing( facebook::jni::alias_ref); + static bool enablePerSurfaceTextScaleAndroid( + facebook::jni::alias_ref); + static bool enablePreparedTextLayout( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt index 46ae613601d3..b251427934be 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/PixelUtilTest.kt @@ -157,6 +157,44 @@ class PixelUtilTest { assertThat(abs(result - expected)).isLessThan(0.1f) } + // A surface can live on a display whose density differs from the primary display's, which is + // what DisplayMetricsHolder tracks. The explicit-metrics overloads must ignore the holder + // entirely so that measurement follows the surface. + @Test + fun explicitMetricsOverloads_ignoreTheHolder() { + val holderMetrics = DisplayMetrics() + holderMetrics.density = 3.0f + holderMetrics.scaledDensity = 3.0f + DisplayMetricsHolder.setScreenDisplayMetrics(holderMetrics) + + val surfaceMetrics = PixelUtil.displayMetricsFor(density = 1.5f, fontScale = 1.0f) + + assertThat(PixelUtil.toPixelFromDIP(16f, surfaceMetrics)).isEqualTo(24f) + assertThat(PixelUtil.toPixelFromSP(16f, Float.NaN, surfaceMetrics)).isEqualTo(24f) + assertThat(PixelUtil.toDIPFromPixel(24f, surfaceMetrics)).isEqualTo(16f) + + // The holder-backed overloads are unaffected. + assertThat(PixelUtil.toPixelFromDIP(16f)).isEqualTo(48f) + } + + @Test + fun displayMetricsFor_appliesFontScaleToScaledDensity() { + val metrics = PixelUtil.displayMetricsFor(density = 2.0f, fontScale = 1.3f) + + assertThat(metrics.density).isEqualTo(2.0f) + assertThat(abs(metrics.scaledDensity - 2.6f)).isLessThan(0.001f) + // 16sp at density 2.0 and font scale 1.3 => 41.6px + assertThat(abs(PixelUtil.toPixelFromSP(16f, Float.NaN, metrics) - 41.6f)).isLessThan(0.01f) + } + + @Test + fun toPixelFromSP_withExplicitMetrics_respectsMaxFontScale() { + val metrics = PixelUtil.displayMetricsFor(density = 1.5f, fontScale = 2.0f) + + // Unclamped this would be 16 * 1.5 * 2.0 = 48px; maxFontScale 1.5 caps it at 16 * 1.5 * 1.5. + assertThat(abs(PixelUtil.toPixelFromSP(16f, 1.5f, metrics) - 36f)).isLessThan(0.01f) + } + @Test fun initDisplayMetrics_preservesFontScale() { // Create a context with custom configuration diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt index fe8d5bf6a558..8159fec8d9e9 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewTest.kt @@ -21,16 +21,29 @@ import android.view.View import android.view.ViewGroup import androidx.core.graphics.createBitmap import androidx.core.graphics.get +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader import org.assertj.core.api.Assertions.assertThat +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) class ReactTextViewTest { + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + @Test fun drawsGlyphInkOutsideLineHeightWhenOverflowIsVisible() { val bitmap = drawReactTextViewWithOverflow(null) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt index e656463b0f68..a9c21e17cefe 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextAttributePropsTest.kt @@ -9,7 +9,9 @@ package com.facebook.react.views.text import android.view.Gravity import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.common.mapbuffer.WritableMapBuffer import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ReactStylesDiffMap import org.assertj.core.api.Assertions.assertThat import org.junit.After @@ -84,6 +86,54 @@ class TextAttributePropsTest { assertThat(textAlignment("end", isRTL = true)).isEqualTo(Gravity.LEFT) } + // A surface on a secondary display is laid out at that display's density, while + // DisplayMetricsHolder keeps describing the primary display. Attributes must follow the metrics + // they are given, otherwise text is measured at one scale and drawn at another. + @Test + fun fromMapBuffer_resolvesFontSizeAgainstSuppliedMetrics() { + DisplayMetricsHolder.setScreenDisplayMetrics( + PixelUtil.displayMetricsFor(density = 3.0f, fontScale = 1.0f), + ) + val surfaceMetrics = PixelUtil.displayMetricsFor(density = 1.5f, fontScale = 1.0f) + + val props = + WritableMapBuffer() + .put(TextAttributeProps.TA_KEY_FONT_SIZE, 16.0) + .put(TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING, true) + + assertThat(TextAttributeProps.fromMapBuffer(props, surfaceMetrics).fontSize).isEqualTo(24) + // Unqualified, it still follows the holder. + assertThat(TextAttributeProps.fromMapBuffer(props).fontSize).isEqualTo(48) + } + + // TA_KEY_MAX_FONT_SIZE_MULTIPLIER (29) is parsed after TA_KEY_FONT_SIZE (4) and re-runs + // setFontSize, so the metrics have to be in place before parsing begins, not applied afterwards. + @Test + fun fromMapBuffer_appliesSuppliedMetricsWhenMaxFontSizeMultiplierReTriggersFontSize() { + val surfaceMetrics = PixelUtil.displayMetricsFor(density = 1.5f, fontScale = 3.0f) + + val props = + WritableMapBuffer() + .put(TextAttributeProps.TA_KEY_FONT_SIZE, 16.0) + .put(TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING, true) + .put(TextAttributeProps.TA_KEY_MAX_FONT_SIZE_MULTIPLIER, 2.0) + + // Font scale 3.0 is clamped to the 2.0 multiplier: 16 * 1.5 * 2.0 = 48. + assertThat(TextAttributeProps.fromMapBuffer(props, surfaceMetrics).fontSize).isEqualTo(48) + } + + @Test + fun fromMapBuffer_ignoresFontScaleWhenFontScalingIsDisabled() { + val surfaceMetrics = PixelUtil.displayMetricsFor(density = 1.5f, fontScale = 2.0f) + + val props = + WritableMapBuffer() + .put(TextAttributeProps.TA_KEY_FONT_SIZE, 16.0) + .put(TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING, false) + + assertThat(TextAttributeProps.fromMapBuffer(props, surfaceMetrics).fontSize).isEqualTo(24) + } + private fun textAlignment(textAlign: String, isRTL: Boolean): Int { return TextAttributeProps.getTextAlignment( ReactStylesDiffMap(JavaOnlyMap.of("textAlign", textAlign)), diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt new file mode 100644 index 000000000000..9815c9e9eb7f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.util.DisplayMetrics +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.common.mapbuffer.MapBuffer +import com.facebook.react.common.mapbuffer.WritableMapBuffer +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.views.text.internal.span.ReactAbsoluteSizeSpan +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.yoga.YogaMeasureMode +import com.facebook.yoga.YogaMeasureOutput +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Text is measured in Java in physical pixels and reported back to Yoga in dp, and Fabric then + * mounts the resulting frame using the surface's own `pointScaleFactor`. When a surface is on a + * display whose density differs from the primary display's — Samsung DeX, desktop mode, an external + * monitor, a freeform window — measuring against the process-wide [DisplayMetricsHolder] makes + * those two scales disagree, and text overflows its box by exactly the ratio between them. + * + * These tests pin the measurement to the metrics it is handed rather than to the holder. + */ +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) +@OptIn(UnstableReactNativeAPI::class) +class TextLayoutManagerDensityTest { + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + // Stand in for the device's primary display. + DisplayMetricsHolder.setScreenDisplayMetrics( + PixelUtil.displayMetricsFor(density = PRIMARY_DENSITY, fontScale = 1.0f), + ) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + // The sp -> px half of the pipeline. The spannable produced here is both what gets measured and + // what gets drawn, so its absolute font size has to come from the surface's display. + @Test + fun spannable_sizesGlyphsWithTheSuppliedMetrics() { + assertThat(fontSizePxAt(PixelUtil.displayMetricsFor(SECONDARY_DENSITY, 1.0f))).isEqualTo(24) + assertThat(fontSizePxAt(PixelUtil.displayMetricsFor(PRIMARY_DENSITY, 1.0f))).isEqualTo(48) + // Font scale applies on top of density. + assertThat(fontSizePxAt(PixelUtil.displayMetricsFor(SECONDARY_DENSITY, 2.0f))).isEqualTo(48) + } + + // The px -> dp half. Robolectric's layout width does not depend on text size, so the measured + // pixel width is the same at both densities; that makes the ratio of the reported dp values + // isolate the conversion, which must use the supplied density and not the holder's. + @Test + fun measureText_convertsToDpWithTheSuppliedDensity() { + val atPrimary = measureAt(PixelUtil.displayMetricsFor(PRIMARY_DENSITY, 1.0f)) + val atSecondary = measureAt(PixelUtil.displayMetricsFor(SECONDARY_DENSITY, 1.0f)) + + assertThat(atSecondary.first / atPrimary.first) + .isCloseTo(PRIMARY_DENSITY / SECONDARY_DENSITY, WITHIN) + assertThat(atSecondary.second / atPrimary.second) + .isCloseTo(PRIMARY_DENSITY / SECONDARY_DENSITY, WITHIN) + } + + @Test + fun measureText_ignoresTheHolderWhenMetricsAreSupplied() { + val supplied = PixelUtil.displayMetricsFor(SECONDARY_DENSITY, 1.0f) + val before = measureAt(supplied) + + // Moving the "primary display" must not move a measurement taken against `supplied`. + DisplayMetricsHolder.setScreenDisplayMetrics(PixelUtil.displayMetricsFor(1.0f, 1.0f)) + val after = measureAt(supplied) + + assertThat(after.first).isCloseTo(before.first, WITHIN) + assertThat(after.second).isCloseTo(before.second, WITHIN) + } + + /** Returns the absolute font size, in physical pixels, of the spannable built for [metrics]. */ + private fun fontSizePxAt(metrics: DisplayMetrics): Int { + val spannable = + TextLayoutManager.getOrCreateSpannableForText( + RuntimeEnvironment.getApplication().assets, + 0, + attributedString(), + null, + metrics, + ) + val sizeSpans = + spannable.getSpans(0, spannable.length, ReactAbsoluteSizeSpan::class.java) + assertThat(sizeSpans).hasSize(1) + return sizeSpans[0].size + } + + /** Returns the measured (width, height) in dp. */ + private fun measureAt(metrics: DisplayMetrics): Pair { + val measurement = + TextLayoutManager.measureText( + RuntimeEnvironment.getApplication().assets, + 0, + attributedString(), + paragraphAttributes(), + Float.POSITIVE_INFINITY, + YogaMeasureMode.UNDEFINED, + Float.POSITIVE_INFINITY, + YogaMeasureMode.UNDEFINED, + null, + null, + null, + metrics, + ) + return YogaMeasureOutput.getWidth(measurement) to YogaMeasureOutput.getHeight(measurement) + } + + private fun paragraphAttributes(): MapBuffer = + WritableMapBuffer() + .put(TextLayoutManager.PA_KEY_TEXT_BREAK_STRATEGY, "highQuality") + .put(TextLayoutManager.PA_KEY_HYPHENATION_FREQUENCY, "none") + + private fun attributedString(): MapBuffer { + val textAttributes = + WritableMapBuffer() + .put(TextAttributeProps.TA_KEY_FONT_SIZE, FONT_SIZE_SP) + .put(TextAttributeProps.TA_KEY_ALLOW_FONT_SCALING, true) + + val fragment = + WritableMapBuffer() + .put(TextLayoutManager.FR_KEY_STRING, "Components") + .put(TextLayoutManager.FR_KEY_TEXT_ATTRIBUTES, textAttributes) + + return WritableMapBuffer() + .put(TextLayoutManager.AS_KEY_STRING, "Components") + .put(TextLayoutManager.AS_KEY_BASE_ATTRIBUTES, textAttributes) + .put(TextLayoutManager.AS_KEY_FRAGMENTS, WritableMapBuffer().put(0, fragment)) + } + + private companion object { + const val PRIMARY_DENSITY = 3.0f + const val SECONDARY_DENSITY = 1.5f + const val FONT_SIZE_SP = 16.0 + val WITHIN = org.assertj.core.data.Offset.offset(0.5f) + } +} diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index 2fa66b894d23..38e7b5c5b768 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<8fd664cb106945e5c6e436a0cf36120d>> + * @generated SignedSource<> */ /** @@ -190,6 +190,10 @@ bool ReactNativeFeatureFlags::enableNativeCSSParsing() { return getAccessor().enableNativeCSSParsing(); } +bool ReactNativeFeatureFlags::enablePerSurfaceTextScaleAndroid() { + return getAccessor().enablePerSurfaceTextScaleAndroid(); +} + bool ReactNativeFeatureFlags::enablePreparedTextLayout() { return getAccessor().enablePreparedTextLayout(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 648d7bf4a0c3..3b13f6d8a8d8 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<196ba25b807fd064ca6150c7d2cd741c>> + * @generated SignedSource<<096860d9a3df73b24eedf2b36e62667b>> */ /** @@ -244,6 +244,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enableNativeCSSParsing(); + /** + * 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). + */ + RN_EXPORT static bool enablePerSurfaceTextScaleAndroid(); + /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 213255b954b8..d933ca2c3f84 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<0500b2c3cb7e5f22e7344c7f5a2e6ca4>> + * @generated SignedSource<<191962355cc876b3a3db93b40adf7455>> */ /** @@ -767,6 +767,24 @@ bool ReactNativeFeatureFlagsAccessor::enableNativeCSSParsing() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enablePerSurfaceTextScaleAndroid() { + auto flagValue = enablePerSurfaceTextScaleAndroid_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(41, "enablePerSurfaceTextScaleAndroid"); + + flagValue = currentProvider_->enablePerSurfaceTextScaleAndroid(); + enablePerSurfaceTextScaleAndroid_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { auto flagValue = enablePreparedTextLayout_.load(); @@ -776,7 +794,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(41, "enablePreparedTextLayout"); + markFlagAsAccessed(42, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -794,7 +812,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(42, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(43, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -812,7 +830,7 @@ bool ReactNativeFeatureFlagsAccessor::enableRuntimeSchedulerQueueClearingOnError // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(43, "enableRuntimeSchedulerQueueClearingOnError"); + markFlagAsAccessed(44, "enableRuntimeSchedulerQueueClearingOnError"); flagValue = currentProvider_->enableRuntimeSchedulerQueueClearingOnError(); enableRuntimeSchedulerQueueClearingOnError_ = flagValue; @@ -830,7 +848,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSchedulerDelegateInvalidation() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(44, "enableSchedulerDelegateInvalidation"); + markFlagAsAccessed(45, "enableSchedulerDelegateInvalidation"); flagValue = currentProvider_->enableSchedulerDelegateInvalidation(); enableSchedulerDelegateInvalidation_ = flagValue; @@ -848,7 +866,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(45, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(46, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -866,7 +884,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(46, "enableViewCulling"); + markFlagAsAccessed(47, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -884,7 +902,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(47, "enableViewRecycling"); + markFlagAsAccessed(48, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enableViewRecyclingForImage"); + markFlagAsAccessed(49, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(50, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableViewRecyclingForText"); + markFlagAsAccessed(51, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableViewRecyclingForView"); + markFlagAsAccessed(52, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(53, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::fixDifferentiatorParentTagForUnflattenCase // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "fixDifferentiatorParentTagForUnflattenCase"); + markFlagAsAccessed(54, "fixDifferentiatorParentTagForUnflattenCase"); flagValue = currentProvider_->fixDifferentiatorParentTagForUnflattenCase(); fixDifferentiatorParentTagForUnflattenCase_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(55, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(56, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(57, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "fuseboxEnabledRelease"); + markFlagAsAccessed(58, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(59, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(60, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(61, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(62, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(63, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "perfIssuesEnabled"); + markFlagAsAccessed(64, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "perfMonitorV2Enabled"); + markFlagAsAccessed(65, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1208,7 +1226,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "preparedTextCacheSize"); + markFlagAsAccessed(66, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(67, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "redBoxV2Android"); + markFlagAsAccessed(68, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "redBoxV2IOS"); + markFlagAsAccessed(69, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(70, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(71, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(72, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(73, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(74, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(75, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(76, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(77, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "useFabricInterop"); + markFlagAsAccessed(78, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(79, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "useNestedScrollViewAndroid"); + markFlagAsAccessed(80, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "useSharedAnimatedBackend"); + markFlagAsAccessed(81, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(82, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useTurboModuleInterop"); + markFlagAsAccessed(83, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1532,7 +1550,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "viewCullingOutsetRatio"); + markFlagAsAccessed(84, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "viewTransitionEnabled"); + markFlagAsAccessed(85, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(86, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1586,7 +1604,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "virtualViewPrerenderRatio"); + markFlagAsAccessed(87, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 76686e57e2aa..f563e29ca9ed 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<88dd99a5753390988c878519a0f468c6>> + * @generated SignedSource<<79dac999de8f27b411990f2bc4016d51>> */ /** @@ -73,6 +73,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableMountingCoordinatorPullModelAndroid(); bool enableMutationObserverByDefault(); bool enableNativeCSSParsing(); + bool enablePerSurfaceTextScaleAndroid(); bool enablePreparedTextLayout(); bool enablePropsUpdateReconciliationAndroid(); bool enableRuntimeSchedulerQueueClearingOnError(); @@ -130,7 +131,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 87> accessedFeatureFlags_; + std::array, 88> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -173,6 +174,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableMountingCoordinatorPullModelAndroid_; std::atomic> enableMutationObserverByDefault_; std::atomic> enableNativeCSSParsing_; + std::atomic> enablePerSurfaceTextScaleAndroid_; std::atomic> enablePreparedTextLayout_; std::atomic> enablePropsUpdateReconciliationAndroid_; std::atomic> enableRuntimeSchedulerQueueClearingOnError_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index c3d560395212..f2df41bcf0e4 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9809c179e61abe55f544d6c8227a5c01>> + * @generated SignedSource<<8e449438065a298854526291c33e146a>> */ /** @@ -191,6 +191,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enablePerSurfaceTextScaleAndroid() override { + return false; + } + bool enablePreparedTextLayout() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 4a3915b7b4c6..60ed0eb6bfb4 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -414,6 +414,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enableNativeCSSParsing(); } + bool enablePerSurfaceTextScaleAndroid() override { + auto value = values_["enablePerSurfaceTextScaleAndroid"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enablePerSurfaceTextScaleAndroid(); + } + bool enablePreparedTextLayout() override { auto value = values_["enablePreparedTextLayout"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 88c71ced84fb..ca6d9edaacae 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2c49f7d9235fdb157fa1291cf4ee4c52>> + * @generated SignedSource<<8522e3d4642ad393849475053616bdbe>> */ /** @@ -66,6 +66,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableMountingCoordinatorPullModelAndroid() = 0; virtual bool enableMutationObserverByDefault() = 0; virtual bool enableNativeCSSParsing() = 0; + virtual bool enablePerSurfaceTextScaleAndroid() = 0; virtual bool enablePreparedTextLayout() = 0; virtual bool enablePropsUpdateReconciliationAndroid() = 0; virtual bool enableRuntimeSchedulerQueueClearingOnError() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 1e615d440c6d..8085ed743446 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9c49f2c73941bd43c3a2ab68eb561b6e>> + * @generated SignedSource<<4c702af4bb47bc94fc6a0bd5ed50e76b>> */ /** @@ -249,6 +249,11 @@ bool NativeReactNativeFeatureFlags::enableNativeCSSParsing( return ReactNativeFeatureFlags::enableNativeCSSParsing(); } +bool NativeReactNativeFeatureFlags::enablePerSurfaceTextScaleAndroid( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enablePerSurfaceTextScaleAndroid(); +} + bool NativeReactNativeFeatureFlags::enablePreparedTextLayout( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enablePreparedTextLayout(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index e96aad170678..9effd00bff95 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<741340cf9c128417368324bb80501f18>> + * @generated SignedSource<<3f2423155338a7baf6ee9012e74a48d2>> */ /** @@ -118,6 +118,8 @@ class NativeReactNativeFeatureFlags bool enableNativeCSSParsing(jsi::Runtime& runtime); + bool enablePerSurfaceTextScaleAndroid(jsi::Runtime& runtime); + bool enablePreparedTextLayout(jsi::Runtime& runtime); bool enablePropsUpdateReconciliationAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp index 17b14c439f16..49cdee7d5d4a 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -235,6 +235,7 @@ Size ParagraphShadowNode::measureContent( TextLayoutContext textLayoutContext{ .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, .surfaceId = getSurfaceId(), }; @@ -282,13 +283,20 @@ Float ParagraphShadowNode::baseline( auto content = getContentWithMeasuredAttachments(layoutContext, layoutConstraints); + TextLayoutContext textLayoutContext{ + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, + .surfaceId = getSurfaceId(), + }; AttributedStringBox attributedStringBox{content.attributedString}; if constexpr (TextLayoutManagerExtended::supportsLineMeasurement()) { - auto lines = - TextLayoutManagerExtended(*textLayoutManager_) - .measureLines( - attributedStringBox, content.paragraphAttributes, size); + auto lines = TextLayoutManagerExtended(*textLayoutManager_) + .measureLines( + attributedStringBox, + content.paragraphAttributes, + textLayoutContext, + size); return LineMeasurement::baseline(lines); } else { LOG(WARNING) @@ -344,6 +352,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { TextLayoutContext textLayoutContext{ .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, .surfaceId = getSurfaceId(), }; AttributedStringBox attributedStringBox{content.attributedString}; @@ -353,7 +362,10 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { auto linesMeasurements = TextLayoutManagerExtended(*textLayoutManager_) .measureLines( - attributedStringBox, content.paragraphAttributes, size); + attributedStringBox, + content.paragraphAttributes, + textLayoutContext, + size); getConcreteEventEmitter().onTextLayout(linesMeasurements); } else { LOG(WARNING) << "onTextLayout is not supported by the current platform"; diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputShadowNode.h index 8fef1a5f9571..c01572507763 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputShadowNode.h @@ -75,6 +75,7 @@ class BaseTextInputShadowNode TextLayoutContext textLayoutContext{ .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, .surfaceId = BaseShadowNode::getSurfaceId(), }; auto textSize = textLayoutManager_ @@ -111,9 +112,15 @@ class BaseTextInputShadowNode AttributedStringBox attributedStringBox{attributedString}; + TextLayoutContext textLayoutContext{ + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, + .surfaceId = BaseShadowNode::getSurfaceId(), + }; + if constexpr (TextLayoutManagerExtended::supportsLineMeasurement()) { auto lines = TextLayoutManagerExtended(*textLayoutManager_) - .measureLines(attributedStringBox, props.paragraphAttributes, size); + .measureLines(attributedStringBox, props.paragraphAttributes, textLayoutContext, size); return LineMeasurement::baseline(lines) + top; } else { LOG(WARNING) << "Baseline alignment is not supported by the current platform"; diff --git a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp index 5a09063b9f34..6b648e04ed89 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp @@ -34,6 +34,7 @@ Size AndroidTextInputShadowNode::measureContent( TextLayoutContext textLayoutContext{ .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, .surfaceId = getSurfaceId(), }; @@ -93,9 +94,15 @@ Float AndroidTextInputShadowNode::baseline( YGNodeLayoutGetPadding(&yogaNode_, YGEdgeTop); AttributedStringBox attributedStringBox{attributedString}; + TextLayoutContext textLayoutContext{ + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier, + .surfaceId = getSurfaceId(), + }; return LineMeasurement::baseline(textLayoutManager_->measureLines( attributedStringBox, getConcreteProps().paragraphAttributes, + textLayoutContext, size)) + top; } diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutContext.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutContext.h index dd8a74b79e0b..d86be1d782b0 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutContext.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutContext.h @@ -25,6 +25,13 @@ struct TextLayoutContext { */ Float pointScaleFactor{1.0}; + /* + * The system font scale of the display the surface is attached to. Mirrors + * `LayoutContext::fontSizeMultiplier` and is needed alongside + * `pointScaleFactor` by platforms that resolve `sp` units themselves. + */ + Float fontSizeMultiplier{1.0}; + /** * The ID of the surface being laid out */ diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutManagerExtended.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutManagerExtended.h index 47474ed06248..e66b6250482a 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutManagerExtended.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextLayoutManagerExtended.h @@ -59,7 +59,7 @@ class TextLayoutManagerExtended { { return requires(TextLayoutManagerT textLayoutManager) { { - textLayoutManager.measureLines(AttributedStringBox{}, ParagraphAttributes{}, Size{}) + textLayoutManager.measureLines(AttributedStringBox{}, ParagraphAttributes{}, TextLayoutContext{}, Size{}) } -> std::same_as; }; } @@ -76,10 +76,11 @@ class TextLayoutManagerExtended { LinesMeasurements measureLines( const AttributedStringBox &attributedStringBox, const ParagraphAttributes ¶graphAttributes, + const TextLayoutContext &layoutContext, const Size &size) { if constexpr (supportsLineMeasurement()) { - return textLayoutManager_.measureLines(attributedStringBox, paragraphAttributes, size); + return textLayoutManager_.measureLines(attributedStringBox, paragraphAttributes, layoutContext, size); } LOG(FATAL) << "Platform TextLayoutManager does not support measureLines"; } diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h index e302e5f7dded..721675608dfd 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/TextMeasureCache.h @@ -70,6 +70,9 @@ class TextMeasureCacheKey final { // are rounded to the pixel grid. Two otherwise-identical measures at different // densities are not interchangeable, so the scale factor is part of the key. Float pointScaleFactor{}; + // `sp` units are resolved against the system font scale on some platforms, so + // two measures at the same density but different font scales differ too. + Float fontSizeMultiplier{}; }; // The Key type that is used for Line Measure Cache. @@ -80,6 +83,8 @@ class LineMeasureCacheKey final { AttributedString attributedString{}; ParagraphAttributes paragraphAttributes{}; Size size{}; + Float pointScaleFactor{}; + Float fontSizeMultiplier{}; }; /** @@ -94,6 +99,7 @@ class PreparedTextCacheKey final { // A prepared layout is rounded to the pixel grid, so it is only reusable at // the pixel scale factor it was laid out at. Float pointScaleFactor{}; + Float fontSizeMultiplier{}; }; /* @@ -263,20 +269,24 @@ inline bool operator==(const TextMeasureCacheKey &lhs, const TextMeasureCacheKey { return areAttributedStringsEquivalentLayoutWise(lhs.attributedString, rhs.attributedString) && lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints && - floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor); + floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor) && + floatEquality(lhs.fontSizeMultiplier, rhs.fontSizeMultiplier); } inline bool operator==(const LineMeasureCacheKey &lhs, const LineMeasureCacheKey &rhs) { return areAttributedStringsEquivalentLayoutWise(lhs.attributedString, rhs.attributedString) && - lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.size == rhs.size; + lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.size == rhs.size && + floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor) && + floatEquality(lhs.fontSizeMultiplier, rhs.fontSizeMultiplier); } inline bool operator==(const PreparedTextCacheKey &lhs, const PreparedTextCacheKey &rhs) { return areAttributedStringsEquivalentDisplayWise(lhs.attributedString, rhs.attributedString) && lhs.paragraphAttributes == rhs.paragraphAttributes && lhs.layoutConstraints == rhs.layoutConstraints && - floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor); + floatEquality(lhs.pointScaleFactor, rhs.pointScaleFactor) && + floatEquality(lhs.fontSizeMultiplier, rhs.fontSizeMultiplier); } } // namespace facebook::react @@ -291,7 +301,8 @@ struct hash { attributedStringHashLayoutWise(key.attributedString), key.paragraphAttributes, key.layoutConstraints, - key.pointScaleFactor); + key.pointScaleFactor, + key.fontSizeMultiplier); } }; @@ -300,7 +311,11 @@ struct hash { size_t operator()(const facebook::react::LineMeasureCacheKey &key) const { return facebook::react::hash_combine( - attributedStringHashLayoutWise(key.attributedString), key.paragraphAttributes, key.size); + attributedStringHashLayoutWise(key.attributedString), + key.paragraphAttributes, + key.size, + key.pointScaleFactor, + key.fontSizeMultiplier); } }; @@ -312,7 +327,8 @@ struct hash { attributedStringHashDisplayWise(key.attributedString), key.paragraphAttributes, key.layoutConstraints, - key.pointScaleFactor); + key.pointScaleFactor, + key.fontSizeMultiplier); } }; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp index 99886999293d..8ef408f1f760 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.cpp @@ -47,7 +47,9 @@ Size measureText( float maxWidth, float minHeight, float maxHeight, - jfloatArray attachmentPositions) { + jfloatArray attachmentPositions, + float pointScaleFactor, + float fontSizeMultiplier) { const jni::global_ref& fabricUIManager = contextContainer->at>("FabricUIManager"); @@ -60,7 +62,9 @@ Size measureText( jfloat, jfloat, jfloat, - jfloatArray)>("measureText"); + jfloatArray, + jfloat, + jfloat)>("measureText"); auto attributedStringBuffer = JReadableMapBuffer::createWithContents(std::move(attributedString)); @@ -75,7 +79,9 @@ Size measureText( maxWidth, minHeight, maxHeight, - attachmentPositions)); + attachmentPositions, + pointScaleFactor, + fontSizeMultiplier)); } TextMeasurement doMeasure( @@ -108,7 +114,9 @@ TextMeasurement doMeasure( maximumSize.width, minimumSize.height, maximumSize.height, - attachmentPositions); + attachmentPositions, + layoutContext.pointScaleFactor, + layoutContext.fontSizeMultiplier); jfloat* attachmentDataElements = env->GetFloatArrayElements(attachmentPositions, nullptr /*isCopy*/); @@ -194,7 +202,8 @@ TextMeasurement TextLayoutManager::measure( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, .layoutConstraints = layoutConstraints, - .pointScaleFactor = layoutContext.pointScaleFactor}, + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier}, std::move(measureText)); measurement.size = layoutConstraints.clamp(measurement.size); @@ -224,7 +233,9 @@ TextMeasurement TextLayoutManager::measureCachedSpannableById( maximumSize.width, minimumSize.height, maximumSize.height, - attachmentPositions); + attachmentPositions, + layoutContext.pointScaleFactor, + layoutContext.fontSizeMultiplier); // Clean up allocated ref - it still takes up space in the JNI ref table even // though it's 0 length @@ -239,6 +250,7 @@ TextMeasurement TextLayoutManager::measureCachedSpannableById( LinesMeasurements TextLayoutManager::measureLines( const AttributedStringBox& attributedStringBox, const ParagraphAttributes& paragraphAttributes, + const TextLayoutContext& layoutContext, const Size& size) const { react_native_assert( attributedStringBox.getMode() == AttributedStringBox::Mode::Value); @@ -253,6 +265,8 @@ LinesMeasurements TextLayoutManager::measureLines( JReadableMapBuffer::javaobject, JReadableMapBuffer::javaobject, jfloat, + jfloat, + jfloat, jfloat)>("measureLines"); auto attributedStringMB = @@ -265,7 +279,9 @@ LinesMeasurements TextLayoutManager::measureLines( attributedStringMB.get(), paragraphAttributesMB.get(), size.width, - size.height); + size.height, + layoutContext.pointScaleFactor, + layoutContext.fontSizeMultiplier); auto dynamicArray = cthis(array)->consume(); LinesMeasurements lineMeasurements; @@ -288,7 +304,9 @@ LinesMeasurements TextLayoutManager::measureLines( : lineMeasureCache_.get( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, - .size = size}, + .size = size, + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier}, std::move(doMeasureLines)); } @@ -305,6 +323,8 @@ TextLayoutManager::PreparedTextLayout TextLayoutManager::prepareLayout( jfloat, jfloat, jfloat, + jfloat, + jfloat, jfloat)>("prepareTextLayout"); static auto reusePreparedLayoutWithNewReactTags = @@ -317,7 +337,8 @@ TextLayoutManager::PreparedTextLayout TextLayoutManager::prepareLayout( {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, .layoutConstraints = layoutConstraints, - .pointScaleFactor = layoutContext.pointScaleFactor}, + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier}, [&]() { const auto& fabricUIManager = contextContainer_->at>("FabricUIManager"); @@ -336,7 +357,9 @@ TextLayoutManager::PreparedTextLayout TextLayoutManager::prepareLayout( minimumSize.width, maximumSize.width, minimumSize.height, - maximumSize.height))}; + maximumSize.height, + layoutContext.pointScaleFactor, + layoutContext.fontSizeMultiplier))}; }); // PreparedTextCacheKey allows equality of layouts which are the same diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h index 0b00c4c7f3d9..8d9057c5e30e 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/android/react/renderer/textlayoutmanager/TextLayoutManager.h @@ -71,6 +71,7 @@ class TextLayoutManager { LinesMeasurements measureLines( const AttributedStringBox &attributedStringBox, const ParagraphAttributes ¶graphAttributes, + const TextLayoutContext &layoutContext, const Size &size) const; /** diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.h b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.h index ce564b01254f..c2d12398bee7 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.h +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.h @@ -53,6 +53,7 @@ class TextLayoutManager { LinesMeasurements measureLines( const AttributedStringBox &attributedStringBox, const ParagraphAttributes ¶graphAttributes, + const TextLayoutContext &layoutContext, const Size &size) const; /* diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm index 82cad8fd2eed..f5fd407754d1 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/TextLayoutManager.mm @@ -103,6 +103,7 @@ LinesMeasurements TextLayoutManager::measureLines( const AttributedStringBox &attributedStringBox, const ParagraphAttributes ¶graphAttributes, + const TextLayoutContext &layoutContext, const Size &size) const { react_native_assert(attributedStringBox.getMode() == AttributedStringBox::Mode::Value); @@ -111,7 +112,12 @@ RCTTextLayoutManager *textLayoutManager = (RCTTextLayoutManager *)unwrapManagedObject(nativeTextLayoutManager_); auto measurement = lineMeasureCache_.get( - {.attributedString = attributedString, .paragraphAttributes = paragraphAttributes, .size = size}, [&]() { + {.attributedString = attributedString, + .paragraphAttributes = paragraphAttributes, + .size = size, + .pointScaleFactor = layoutContext.pointScaleFactor, + .fontSizeMultiplier = layoutContext.fontSizeMultiplier}, + [&]() { auto measurement = [textLayoutManager getLinesForAttributedString:attributedString paragraphAttributes:paragraphAttributes size:{size.width, size.height}]; diff --git a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp index 3b687657917b..6a318eaa57e8 100644 --- a/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/textlayoutmanager/tests/TextLayoutManagerTest.cpp @@ -134,6 +134,60 @@ TEST(TextLayoutManagerTest, pointScaleFactorAffectsPreparedTextCacheHash) { std::hash{}(rhs)); } +// `sp` units are resolved against the system font scale on some platforms, so +// the font scale participates in the cache keys the same way the pixel scale +// factor does. +TEST(TextLayoutManagerTest, fontSizeMultiplierAffectsTextMeasureCacheEquality) { + TextMeasureCacheKey lhs; + TextMeasureCacheKey rhs; + + lhs.fontSizeMultiplier = 1.0; + rhs.fontSizeMultiplier = 1.3; + EXPECT_FALSE(lhs == rhs); + + rhs.fontSizeMultiplier = 1.0; + EXPECT_TRUE(lhs == rhs); +} + +TEST(TextLayoutManagerTest, fontSizeMultiplierAffectsTextMeasureCacheHash) { + TextMeasureCacheKey lhs; + TextMeasureCacheKey rhs; + + lhs.fontSizeMultiplier = 1.0; + rhs.fontSizeMultiplier = 1.3; + + EXPECT_NE( + std::hash{}(lhs), + std::hash{}(rhs)); +} + +TEST(TextLayoutManagerTest, scaleAffectsLineMeasureCacheEquality) { + LineMeasureCacheKey lhs; + LineMeasureCacheKey rhs; + + lhs.pointScaleFactor = 3.0; + rhs.pointScaleFactor = 1.5; + EXPECT_FALSE(lhs == rhs); + + rhs.pointScaleFactor = 3.0; + EXPECT_TRUE(lhs == rhs); + + rhs.fontSizeMultiplier = 1.3; + EXPECT_FALSE(lhs == rhs); +} + +TEST(TextLayoutManagerTest, fontSizeMultiplierAffectsPreparedTextCacheEquality) { + PreparedTextCacheKey lhs; + PreparedTextCacheKey rhs; + + lhs.fontSizeMultiplier = 1.0; + rhs.fontSizeMultiplier = 1.3; + EXPECT_FALSE(lhs == rhs); + + rhs.fontSizeMultiplier = 1.0; + EXPECT_TRUE(lhs == rhs); +} + // Tests for internal_roundTextMeasurementToPixelGrid (the pixel-grid rounding // used by text measurement). A small epsilon is added before ceil so a // dimension that lands exactly on a pixel boundary gains one physical pixel of diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 1a0b3f931cbd..01740b046e1a 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -486,6 +486,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enablePerSurfaceTextScaleAndroid: { + defaultValue: false, + metadata: { + dateAdded: '2026-08-12', + description: + '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).', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enablePreparedTextLayout: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 2bd10e7eef76..7a160fb9481e 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<57ec5f47418b3283caa251114ed07b3b>> + * @generated SignedSource<<5abe10c94af2433487fe6e168e306aad>> * @flow strict * @noformat */ @@ -89,6 +89,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableMountingCoordinatorPullModelAndroid: Getter, enableMutationObserverByDefault: Getter, enableNativeCSSParsing: Getter, + enablePerSurfaceTextScaleAndroid: Getter, enablePreparedTextLayout: Getter, enablePropsUpdateReconciliationAndroid: Getter, enableRuntimeSchedulerQueueClearingOnError: Getter, @@ -370,6 +371,10 @@ export const enableMutationObserverByDefault: Getter = createNativeFlag * Parse CSS strings using the Fabric CSS parser instead of ViewConfig processing */ export const enableNativeCSSParsing: Getter = createNativeFlagGetter('enableNativeCSSParsing', false); +/** + * 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). + */ +export const enablePerSurfaceTextScaleAndroid: Getter = createNativeFlagGetter('enablePerSurfaceTextScaleAndroid', false); /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 300b339d27b6..8cb7ce4facc1 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<721fb68d3038841ecd6bbaabafccd5cc>> + * @generated SignedSource<<9e820598396a51c5384a308680776c00>> * @flow strict * @noformat */ @@ -66,6 +66,7 @@ export interface Spec extends TurboModule { readonly enableMountingCoordinatorPullModelAndroid?: () => boolean; readonly enableMutationObserverByDefault?: () => boolean; readonly enableNativeCSSParsing?: () => boolean; + readonly enablePerSurfaceTextScaleAndroid?: () => boolean; readonly enablePreparedTextLayout?: () => boolean; readonly enablePropsUpdateReconciliationAndroid?: () => boolean; readonly enableRuntimeSchedulerQueueClearingOnError?: () => boolean; diff --git a/yarn.lock b/yarn.lock index bb5f5e4f93a2..3c9cb7592085 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5174,10 +5174,10 @@ hasown@^2.0.3: dependencies: function-bind "^1.1.2" -hermes-compiler@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-0.0.0.tgz#8d9f6a0b2740ce34d71258fec684e7b6bfd97efa" - integrity sha512-boVFutx6ME/Km2mB6vvsQcdnazEYYI/jV1pomx1wcFUG/EVqTkr5CU0CW9bKipOA/8Hyu3NYwW3THg2Q1kNCfA== +hermes-compiler@260318099.0.1: + version "260318099.0.1" + resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-260318099.0.1.tgz#bc073e380bfcb9286d1bcbf69e83933a515b7e2d" + integrity sha512-jDXx48/z7ULUr2+sf+A3+3RQRiwcvH4Y+mhlxMdUhNQGOsnQY8eeDgy+8yhKrDHbLvRfW2JwEnDFfmyf96F5SQ== hermes-estree@0.25.1: version "0.25.1" From 9f2e85f476e90f81e527df0e9bd3361dc96805a1 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:56:08 +0200 Subject: [PATCH 2/7] revert lockfile changes --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3c9cb7592085..bb5f5e4f93a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5174,10 +5174,10 @@ hasown@^2.0.3: dependencies: function-bind "^1.1.2" -hermes-compiler@260318099.0.1: - version "260318099.0.1" - resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-260318099.0.1.tgz#bc073e380bfcb9286d1bcbf69e83933a515b7e2d" - integrity sha512-jDXx48/z7ULUr2+sf+A3+3RQRiwcvH4Y+mhlxMdUhNQGOsnQY8eeDgy+8yhKrDHbLvRfW2JwEnDFfmyf96F5SQ== +hermes-compiler@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-0.0.0.tgz#8d9f6a0b2740ce34d71258fec684e7b6bfd97efa" + integrity sha512-boVFutx6ME/Km2mB6vvsQcdnazEYYI/jV1pomx1wcFUG/EVqTkr5CU0CW9bKipOA/8Hyu3NYwW3THg2Q1kNCfA== hermes-estree@0.25.1: version "0.25.1" From 3ec46f1e65726d24284fa500f6526ed938eb57c0 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:25:24 +0200 Subject: [PATCH 3/7] test: update tests --- .../react/views/text/TextLayoutManager.kt | 6 +-- .../text/TextLayoutManagerDensityTest.kt | 21 ++++++++++ .../TextLayoutManagerInlineViewSizeTest.kt | 39 +++++++------------ 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index cbe0d20fd999..b5d69c994a95 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -672,11 +672,7 @@ internal object TextLayoutManager { @VisibleForTesting internal fun inlineViewSizeToPixels(size: Double, displayMetrics: DisplayMetrics): Int = - ceil(PixelUtil.toPixelFromDIP(size.toFloat(), displayMetrics).toDouble()).toInt() - - @VisibleForTesting - internal fun inlineViewSizeToPixels(size: Double): Int = - ceil(PixelUtil.toPixelFromDIP(size).toDouble()).toInt() + ceil(PixelUtil.toPixelFromDIP(size.toFloat(), displayMetrics).toDouble()).toInt() @OptIn(UnstableReactNativeAPI::class) fun getOrCreateSpannableForText( diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt index 9815c9e9eb7f..44506344623c 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerDensityTest.kt @@ -93,6 +93,27 @@ class TextLayoutManagerDensityTest { assertThat(after.second).isCloseTo(before.second, WITHIN) } + // Inline views are laid out against a placeholder span sized in physical pixels, so that size has + // to come from the surface's display too — otherwise the placeholder and the view Fabric mounts + // into it disagree by the ratio between the two densities. + @Test + fun inlineViewSize_scalesWithTheSuppliedDensity() { + val supplied = PixelUtil.displayMetricsFor(SECONDARY_DENSITY, 1.0f) + + assertThat(TextLayoutManager.inlineViewSizeToPixels(100.0, supplied)).isEqualTo(150) + assertThat( + TextLayoutManager.inlineViewSizeToPixels( + 100.0, + PixelUtil.displayMetricsFor(PRIMARY_DENSITY, 1.0f), + ), + ) + .isEqualTo(300) + + // Moving the "primary display" must not move a size taken against `supplied`. + DisplayMetricsHolder.setScreenDisplayMetrics(PixelUtil.displayMetricsFor(1.0f, 1.0f)) + assertThat(TextLayoutManager.inlineViewSizeToPixels(100.0, supplied)).isEqualTo(150) + } + /** Returns the absolute font size, in physical pixels, of the spannable built for [metrics]. */ private fun fontSizePxAt(metrics: DisplayMetrics): Int { val spannable = diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerInlineViewSizeTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerInlineViewSizeTest.kt index d7148d0b2f5e..6ab64a1a7cef 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerInlineViewSizeTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerInlineViewSizeTest.kt @@ -5,47 +5,34 @@ * LICENSE file in the root directory of this source tree. */ -@file:Suppress("DEPRECATION") - package com.facebook.react.views.text -import android.util.DisplayMetrics -import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.PixelUtil +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader import org.assertj.core.api.Assertions.assertThat -import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) class TextLayoutManagerInlineViewSizeTest { - @After - fun tearDown() { - DisplayMetricsHolder.setScreenDisplayMetrics(null) - } - + // The size arrives from the shadow node in dp, so the system font scale must not apply to it. @Test - fun `inline view attachment width does not shrink with small font scale`() { - DisplayMetricsHolder.setScreenDisplayMetrics( - DisplayMetrics().apply { - density = 1f - scaledDensity = 0.85f - }, - ) + fun `inline view attachment size does not shrink with small font scale`() { + val metrics = PixelUtil.displayMetricsFor(density = 1f, fontScale = 0.85f) - assertThat(TextLayoutManager.inlineViewSizeToPixels(155.0)).isEqualTo(155) + assertThat(TextLayoutManager.inlineViewSizeToPixels(155.0, metrics)).isEqualTo(155) } + // A fractional pixel size would leave the inline view a hair short of its box. @Test - fun `inline view attachment width is rounded up to the pixel grid`() { - DisplayMetricsHolder.setScreenDisplayMetrics( - DisplayMetrics().apply { - density = 1f - scaledDensity = 1f - }, - ) + fun `inline view attachment size is rounded up to the pixel grid`() { + val metrics = PixelUtil.displayMetricsFor(density = 1f, fontScale = 1f) - assertThat(TextLayoutManager.inlineViewSizeToPixels(132.1)).isEqualTo(133) + assertThat(TextLayoutManager.inlineViewSizeToPixels(132.1, metrics)).isEqualTo(133) } } From d76fa98efcd389e93aa0168747f4a8813edc24fb Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:28:32 +0200 Subject: [PATCH 4/7] revert: gradle.properties --- gradle.properties | 3 --- 1 file changed, 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index 17b8449c9049..1028b5c5238e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,6 +18,3 @@ react.internal.useHermesStable=false # Controls whether to use Hermes from nightly builds. This will speed up builds # but should NOT be turned on for CI or release builds. react.internal.useHermesNightly=true - -# Enabled parallel sync for Gradle 9.4+ -org.gradle.tooling.parallel=true From 0e1887dd747ded36c77d483ab87c9d10ffd88dc0 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:28:54 +0200 Subject: [PATCH 5/7] fix: remove redundant caching --- .../com/facebook/react/uimanager/PixelUtil.kt | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt index a66a31b82066..39d6afee0da1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt @@ -10,7 +10,6 @@ package com.facebook.react.uimanager import android.content.Context import android.util.DisplayMetrics import android.util.TypedValue -import androidx.annotation.VisibleForTesting import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import kotlin.math.min @@ -125,27 +124,12 @@ public object PixelUtil { */ @JvmStatic public fun displayMetricsOf(context: Context): DisplayMetrics = - if (isPerSurfaceTextScaleEnabled()) { + if (ReactNativeFeatureFlags.enablePerSurfaceTextScaleAndroid()) { context.resources.displayMetrics } else { DisplayMetricsHolder.getScreenDisplayMetrics() } - // Resolved once: this sits on the text draw path, and reaching into the C++-backed feature flags - // there would mean a JNI hop per conversion. - @Volatile private var perSurfaceTextScaleEnabled: Boolean? = null - - private fun isPerSurfaceTextScaleEnabled(): Boolean = - perSurfaceTextScaleEnabled - ?: ReactNativeFeatureFlags.enablePerSurfaceTextScaleAndroid().also { - perSurfaceTextScaleEnabled = it - } - - @VisibleForTesting - internal fun resetPerSurfaceTextScaleCache() { - perSurfaceTextScaleEnabled = null - } - /* Kotlin extensions */ public fun Int.dpToPx(): Float = toPixelFromDIP(this.toFloat()) From b96ecc3eebafd3a947d99c3f73b3ceea36d0a24c Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:54:02 +0200 Subject: [PATCH 6/7] chore: remove dead code --- .../react/views/text/ReactTextView.java | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index ee4a10e262e2..3fe677cb5266 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -155,32 +155,6 @@ private void initView() { updateView(); // call after changing ellipsizeLocation in particular } - private static WritableMap inlineViewJson( - int visibility, - int index, - int left, - int top, - int right, - int bottom, - DisplayMetrics metrics) { - WritableMap json = Arguments.createMap(); - if (visibility == View.GONE) { - json.putString("visibility", "gone"); - json.putInt("index", index); - } else if (visibility == View.VISIBLE) { - json.putString("visibility", "visible"); - json.putInt("index", index); - json.putDouble("left", PixelUtil.toDIPFromPixel(left, metrics)); - json.putDouble("top", PixelUtil.toDIPFromPixel(top, metrics)); - json.putDouble("right", PixelUtil.toDIPFromPixel(right, metrics)); - json.putDouble("bottom", PixelUtil.toDIPFromPixel(bottom, metrics)); - } else { - json.putString("visibility", "unknown"); - json.putInt("index", index); - } - return json; - } - @Override protected void onLayout( boolean changed, int textViewLeft, int textViewTop, int textViewRight, int textViewBottom) { From 301f2f091d7d50d5142f71602b5df1a0392744e4 Mon Sep 17 00:00:00 2001 From: matinzd <24797481+matinzd@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:26:51 +0200 Subject: [PATCH 7/7] chore: mark old methods without displaymetrics deprecated --- .../java/com/facebook/react/uimanager/PixelUtil.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt index 39d6afee0da1..50e305982111 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/PixelUtil.kt @@ -131,30 +131,42 @@ public object PixelUtil { } /* Kotlin extensions */ + @Deprecated("Use the dpToPx(DisplayMetrics) overload, so conversions match the view's display.") public fun Int.dpToPx(): Float = toPixelFromDIP(this.toFloat()) + @Deprecated("Use the dpToPx(DisplayMetrics) overload, so conversions match the view's display.") public fun Long.dpToPx(): Float = toPixelFromDIP(this.toFloat()) + @Deprecated("Use the dpToPx(DisplayMetrics) overload, so conversions match the view's display.") public fun Float.dpToPx(): Float = toPixelFromDIP(this) + @Deprecated("Use the dpToPx(DisplayMetrics) overload, so conversions match the view's display.") public fun Double.dpToPx(): Float = toPixelFromDIP(this.toFloat()) + @Deprecated("Use the pxToDp(DisplayMetrics) overload, so conversions match the view's display.") public fun Int.pxToDp(): Float = toDIPFromPixel(this.toFloat()) + @Deprecated("Use the pxToDp(DisplayMetrics) overload, so conversions match the view's display.") public fun Long.pxToDp(): Float = toDIPFromPixel(this.toFloat()) + @Deprecated("Use the pxToDp(DisplayMetrics) overload, so conversions match the view's display.") public fun Float.pxToDp(): Float = toDIPFromPixel(this) + @Deprecated("Use the pxToDp(DisplayMetrics) overload, so conversions match the view's display.") public fun Double.pxToDp(): Float = toDIPFromPixel(this.toFloat()) public fun Int.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this.toFloat(), metrics) + public fun Long.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this.toFloat(), metrics) + public fun Float.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this, metrics) public fun Double.dpToPx(metrics: DisplayMetrics): Float = toPixelFromDIP(this.toFloat(), metrics) public fun Int.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this.toFloat(), metrics) + public fun Long.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this.toFloat(), metrics) + public fun Float.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this, metrics) public fun Double.pxToDp(metrics: DisplayMetrics): Float = toDIPFromPixel(this.toFloat(), metrics)