Skip to content

Commit 6c2acc3

Browse files
jkadamczykclaude
andcommitted
Fix Android text mis-measurement when fontVariationSettings are re-applied to a reused Paint
Summary: `fontVariationSettings` (#57804, #57805) is applied with `Paint.setFontVariationSettings` after `Paint.setTypeface`. Android's `Paint.setFontVariationSettings` returns early when the requested settings string equals the value the paint already holds, and `Paint.setTypeface` does not clear that stored string. On the legacy variation path (every shipped Android release), the variation instance lives on the typeface, so once `setTypeface` replaces the typeface the axes are gone, but the paint still reports the old settings and the second `setFontVariationSettings` call is a no-op. React Native hits this sequence on every measurement after the first. `TextLayoutManager.scratchPaintWithAttributes` reuses a thread-local `TextPaint` and resets it with `setTypeface(null)`, which leaves the stale settings string in place. Any subsequent `<Text>` with the same `fontVariationSettings` string is then measured with the un-varied typeface, while the `TextView` draws it through its own paint where the axes apply. The same happens inside `StaticLayout`, whose pooled `MeasuredParagraph`/`TextLine` paints copy the settings string via `Paint.set`. `ReactEditText` has the equivalent problem through `TextView.setFontVariationSettings`, which also skips unchanged values. With a variable font whose default instance is lighter than the requested `wght`, Yoga receives regular-width metrics while semibold glyphs are drawn, so `numberOfLines={1}` text is ellipsized ("Get Start...") and wrapped text breaks in different places than it renders. This change clears the paint's settings before re-applying them in `ReactTypefaceUtils.applyFontVariationSettings` and in `ReactEditText.maybeUpdateTypeface`, so the axes are always rebuilt on the current typeface. Paints without previous settings are unaffected. Changelog: [ANDROID] [FIXED] - Fix text measured with the wrong width when `fontVariationSettings` are re-applied to a reused `Paint` Test Plan: - `./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "*TextLayoutManagerFontWeightAdjustmentTest*" --tests "*ReactTextInputPropertyTest*"`: 8/8 and 27/27 pass. The three new tests cover the reused-paint reset, the fresh-paint path (no spurious clear), and the measurement scratch paint through `TextLayoutManager.updateTextPaint`. Without the production change, `reused paint re-applies unchanged font variation settings to a new typeface` and `scratch text paint re-applies font variation settings on repeated measurement` fail. - `yarn lint-kotlin-check` (`./gradlew ktfmtCheck`): passes. - Manual, Android 17 emulator (API 37), variable Inter font registered via `ReactFontManager.addCustomFont` with entries for weights 100–900, `<Text numberOfLines={1} style={{ fontFamily: "InterVariable", fontWeight: "600", fontVariationSettings: "'wght' 600" }}>`: - Before: the second and later semibold texts are measured as regular and ellipsized; button label rendered as "Get Start...". - After: labels render in full and the measured bounds match the drawn text for `'wght' 600`, `650` and `700`, including repeated identical styles and `TextInput`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f970054 commit 6c2acc3

3 files changed

Lines changed: 58 additions & 0 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ public object ReactTypefaceUtils {
179179
}
180180

181181
try {
182+
// Paint skips unchanged settings even after setTypeface, so clear them first.
183+
if (paint.fontVariationSettings != null) {
184+
paint.setFontVariationSettings(null)
185+
}
182186
paint.setFontVariationSettings(fontVariationSettings)
183187
} catch (exception: IllegalArgumentException) {
184188
// Paint instances are reused, so explicitly clear axes from a previous layout.

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,10 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
689689
val newTypeface = applyStyles(typeface, fontStyle, fontWeight, fontFamily, context.assets)
690690
typeface = newTypeface
691691
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
692+
// TextView skips unchanged settings even after a typeface change, so clear them first.
693+
if (fontVariationSettings != null) {
694+
super.setFontVariationSettings(null)
695+
}
692696
super.setFontVariationSettings(parsedFontVariationSettings)
693697
}
694698

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import org.junit.runner.RunWith
2424
import org.mockito.kotlin.any
2525
import org.mockito.kotlin.inOrder
2626
import org.mockito.kotlin.mock
27+
import org.mockito.kotlin.never
2728
import org.mockito.kotlin.verify
2829
import org.mockito.kotlin.whenever
2930
import org.robolectric.RobolectricTestRunner
@@ -127,6 +128,55 @@ class TextLayoutManagerFontWeightAdjustmentTest {
127128
verify(paint).setFontVariationSettings(null)
128129
}
129130

131+
@Test
132+
fun `reused paint re-applies unchanged font variation settings to a new typeface`() {
133+
val paint = mock<Paint>()
134+
whenever(paint.fontVariationSettings).thenReturn("'wght' 600")
135+
136+
ReactTypefaceUtils.applyFontVariationSettings(paint, "'wght' 600")
137+
138+
inOrder(paint) {
139+
verify(paint).setFontVariationSettings(null)
140+
verify(paint).setFontVariationSettings("'wght' 600")
141+
}
142+
}
143+
144+
@Test
145+
fun `fresh paint applies font variation settings without clearing first`() {
146+
val paint = mock<Paint>()
147+
whenever(paint.fontVariationSettings).thenReturn(null)
148+
149+
ReactTypefaceUtils.applyFontVariationSettings(paint, "'wght' 600")
150+
151+
verify(paint).setFontVariationSettings("'wght' 600")
152+
verify(paint, never()).setFontVariationSettings(null)
153+
}
154+
155+
@Test
156+
fun `scratch text paint re-applies font variation settings on repeated measurement`() {
157+
val paint = mock<TextPaint>()
158+
whenever(paint.fontVariationSettings).thenReturn("'wght' 600")
159+
val textAttributes =
160+
TextAttributeProps.fromReadableMap(
161+
ReactStylesDiffMap(
162+
JavaOnlyMap.of("fontWeight", "600", "fontVariationSettings", "'wght' 600"),
163+
),
164+
)
165+
166+
TextLayoutManager.updateTextPaint(
167+
paint,
168+
textAttributes,
169+
RuntimeEnvironment.getApplication().assets,
170+
0,
171+
)
172+
173+
inOrder(paint) {
174+
verify(paint).setTypeface(any())
175+
verify(paint).setFontVariationSettings(null)
176+
verify(paint).setFontVariationSettings("'wght' 600")
177+
}
178+
}
179+
130180
private companion object {
131181
const val FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT = 300
132182
}

0 commit comments

Comments
 (0)