From da6a2b25912fdfe10d1fefc2692dbef36944d062 Mon Sep 17 00:00:00 2001 From: yao Date: Fri, 21 Aug 2026 05:33:48 +0800 Subject: [PATCH] Fix Android text measurement to use the rendering TextView default typeface Auto-width can clip its last glyph(s) when the system default font is replaced/bolded at the theme level (e.g. a bold system font or a font replacement app). Measurement resets the TextPaint typeface to Typeface.DEFAULT, while ReactTextView renders with the TextView default typeface that inherits the system font configuration. When those diverge, the measured width is narrower than what is drawn, so trailing glyphs get pushed out of the view. Update updateTextPaint so that, when no explicit font family/weight/style is set and there is no font weight adjustment, the measurement paint uses the same default typeface the rendering TextView uses. The typeface is resolved from a single cached TextView (application context, no per-measure allocation) and re-resolved when the Configuration changes. --- .../react/fabric/FabricUIManager.java | 3 + .../react/views/text/TextLayoutManager.kt | 55 +++++++++++++++++-- ...xtLayoutManagerFontWeightAdjustmentTest.kt | 13 ++++- 3 files changed, 65 insertions(+), 6 deletions(-) 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 3fe95a4595db..51a486adc1a5 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 @@ -559,6 +559,7 @@ private NativeArray measureLines( TextLayoutManager.measureLines( mReactApplicationContext.getAssets(), ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), + mReactApplicationContext, attributedString, paragraphAttributes, PixelUtil.toPixelFromDIP(width), @@ -648,6 +649,7 @@ public long measureText( return TextLayoutManager.measureText( mReactApplicationContext.getAssets(), ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), + mReactApplicationContext, attributedString, paragraphAttributes, getYogaSize(minWidth, maxWidth), @@ -676,6 +678,7 @@ public PreparedLayout prepareTextLayout( return TextLayoutManager.createPreparedLayout( mReactApplicationContext.getAssets(), ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), + mReactApplicationContext, attributedString, paragraphAttributes, getYogaSize(minWidth, maxWidth), 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 53a67377158f..ddfd1a97fb9c 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 @@ -7,7 +7,9 @@ package com.facebook.react.views.text +import android.content.Context import android.content.res.AssetManager +import android.content.res.Configuration import android.graphics.Color import android.graphics.RectF import android.graphics.Typeface @@ -25,6 +27,7 @@ import android.text.TextUtils import android.util.LayoutDirection import android.view.Gravity import android.view.View +import android.widget.TextView import androidx.annotation.VisibleForTesting import com.facebook.common.logging.FLog import com.facebook.infer.annotation.Assertions @@ -1008,6 +1011,7 @@ internal object TextLayoutManager { baseTextAttributes: TextAttributeProps, assets: AssetManager, fontWeightAdjustment: Int, + context: Context, ) { if (baseTextAttributes.fontSize != ReactConstants.UNSET) { paint.textSize = baseTextAttributes.fontSize.toFloat() @@ -1044,12 +1048,40 @@ internal object TextLayoutManager { val typeface = ReactTypefaceUtils.applyFontWeightAdjustment(null, fontWeightAdjustment) if (typeface != null) { paint.setTypeface(typeface) + } else { + // ReactTextView renders text with a plain TextView, whose default typeface + // inherits the system/theme font configuration (e.g. a bold system font or a + // font-replacement app). Measurement uses a bare TextPaint whose default + // typeface is Typeface.DEFAULT; when these diverge, auto-width is + // measured narrower than it renders, clipping the last glyphs. Use the same + // default typeface the rendering TextView uses. + paint.setTypeface(getSystemDefaultTypeface(context)) } } ReactTypefaceUtils.applyFontVariationSettings(paint, baseTextAttributes.fontVariationSettings) } + private var sSystemDefaultTypeface: Typeface? = null + private var sSystemDefaultTypefaceConfiguration: Configuration? = null + + @Synchronized + private fun getSystemDefaultTypeface(context: Context): Typeface { + val config = context.resources.configuration + if ( + sSystemDefaultTypeface == null || + sSystemDefaultTypefaceConfiguration?.equals(config) != true + ) { + // Create a single TextView (no per-measure allocation) from the application + // context so we read the same default typeface the rendering TextView uses, + // including system/theme-level font replacements. Re-resolve whenever the + // Configuration (fontScale, fontWeightAdjustment, locale, ...) changes. + sSystemDefaultTypeface = TextView(context.applicationContext).typeface + sSystemDefaultTypefaceConfiguration = Configuration(config) + } + return checkNotNull(sSystemDefaultTypeface) + } + /** * WARNING: This paint should not be used for any layouts which may escape TextLayoutManager, as * they may need to be drawn later, and may not safely be reused @@ -1058,13 +1090,14 @@ internal object TextLayoutManager { baseTextAttributes: TextAttributeProps, assets: AssetManager, fontWeightAdjustment: Int, + context: Context, ): TextPaint { val paint = checkNotNull(textPaintInstance.get()) paint.setTypeface(null) paint.textSize = 12f paint.isFakeBoldText = false paint.textSkewX = 0f - updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment) + updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment, context) return paint } @@ -1072,9 +1105,10 @@ internal object TextLayoutManager { baseTextAttributes: TextAttributeProps, assets: AssetManager, fontWeightAdjustment: Int, + context: Context, ): TextPaint { val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) - updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment) + updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment, context) return paint } @@ -1082,6 +1116,7 @@ internal object TextLayoutManager { private fun createLayoutForMeasurement( assets: AssetManager, fontWeightAdjustment: Int, + context: Context, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -1106,7 +1141,8 @@ internal object TextLayoutManager { } else { val baseTextAttributes = TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) - paint = scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment) + paint = + scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment, context) } return createLayout( @@ -1245,6 +1281,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) fun createPreparedLayout( assets: AssetManager, + context: Context, attributedString: ReadableMapBuffer, paragraphAttributes: ReadableMapBuffer, width: Float, @@ -1257,6 +1294,7 @@ internal object TextLayoutManager { createPreparedLayout( assets, 0, + context, attributedString, paragraphAttributes, width, @@ -1272,6 +1310,7 @@ internal object TextLayoutManager { fun createPreparedLayout( assets: AssetManager, fontWeightAdjustment: Int, + context: Context, attributedString: ReadableMapBuffer, paragraphAttributes: ReadableMapBuffer, width: Float, @@ -1297,7 +1336,7 @@ internal object TextLayoutManager { val result = createLayout( text, - newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment), + newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment, context), attributedString, paragraphAttributes, width, @@ -1439,6 +1478,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) fun measureText( assets: AssetManager, + context: Context, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -1452,6 +1492,7 @@ internal object TextLayoutManager { measureText( assets, 0, + context, attributedString, paragraphAttributes, width, @@ -1468,6 +1509,7 @@ internal object TextLayoutManager { fun measureText( assets: AssetManager, fontWeightAdjustment: Int, + context: Context, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -1483,6 +1525,7 @@ internal object TextLayoutManager { createLayoutForMeasurement( assets, fontWeightAdjustment, + context, attributedString, paragraphAttributes, width, @@ -1754,6 +1797,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) fun measureLines( assetManager: AssetManager, + context: Context, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -1764,6 +1808,7 @@ internal object TextLayoutManager { measureLines( assetManager, 0, + context, attributedString, paragraphAttributes, width, @@ -1777,6 +1822,7 @@ internal object TextLayoutManager { fun measureLines( assetManager: AssetManager, fontWeightAdjustment: Int, + context: Context, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -1788,6 +1834,7 @@ internal object TextLayoutManager { createLayoutForMeasurement( assetManager, fontWeightAdjustment, + context, attributedString, paragraphAttributes, width, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt index 7fadd9b556da..751d7d69f573 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt @@ -11,6 +11,7 @@ import android.graphics.Paint import android.graphics.Typeface import android.os.Build import android.text.TextPaint +import android.widget.TextView import androidx.annotation.RequiresApi import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.uimanager.DisplayMetricsHolder @@ -56,13 +57,14 @@ class TextLayoutManagerFontWeightAdjustmentTest { textAttributes, RuntimeEnvironment.getApplication().assets, FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT, + RuntimeEnvironment.getApplication(), ) assertThat(paint.typeface).isNotNull } @Test - fun `plain text paint keeps default typeface unset without font weight adjustment`() { + fun `plain text paint uses rendering TextView default typeface without font weight adjustment`() { val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) val textAttributes = TextAttributeProps.fromReadableMap(ReactStylesDiffMap(JavaOnlyMap())) @@ -71,9 +73,15 @@ class TextLayoutManagerFontWeightAdjustmentTest { textAttributes, RuntimeEnvironment.getApplication().assets, 0, + RuntimeEnvironment.getApplication(), ) - assertThat(paint.typeface).isNull() + // Matches the typeface the rendering TextView (ReactTextView) would use, so + // measurement and rendering stay in sync even when the system default font is + // replaced (e.g. a bold system font). + assertThat(paint.typeface) + .isNotNull + .isEqualTo(TextView(RuntimeEnvironment.getApplication()).typeface) } @Test @@ -89,6 +97,7 @@ class TextLayoutManagerFontWeightAdjustmentTest { textAttributes, RuntimeEnvironment.getApplication().assets, 0, + RuntimeEnvironment.getApplication(), ) assertThat(paint.fontVariationSettings).isNull()