Skip to content

Commit eb11ef9

Browse files
committed
Fix nested Text decorations disappearing from TextInput on Android
ReactEditText draws the underline or line-through of a nested Text as CanvasEffectSpans around super.onDraw, using getLayout(). Changing the placeholder makes TextView drop its layout and wait for the next measure. Fabric measures a TextInput only when its frame changes, so the layout stays null. onDraw then fell back to super.onDraw, which rebuilds the layout and draws the text, but skipped the decorations. When the input has CanvasEffectSpans and its layout is missing at draw time, measure the view again with its current frame before drawing. TextView#onDraw would build the same layout anyway.
1 parent 3f5c2f3 commit eb11ef9

2 files changed

Lines changed: 124 additions & 13 deletions

File tree

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

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.os.Build
1919
import android.os.Bundle
2020
import android.text.Editable
2121
import android.text.InputType
22+
import android.text.Layout
2223
import android.text.Spannable
2324
import android.text.SpannableStringBuilder
2425
import android.text.Spanned
@@ -1239,31 +1240,44 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
12391240

12401241
val spanned = text as? Spanned
12411242
if (spanned != null) {
1242-
val layout = layout
1243+
val drawSpans = spanned.getSpans(0, spanned.length, CanvasEffectSpan::class.java)
1244+
val layout = if (drawSpans.isNotEmpty()) layoutForDraw() else null
12431245
if (layout != null) {
1244-
val drawSpans = spanned.getSpans(0, spanned.length, CanvasEffectSpan::class.java)
1245-
if (drawSpans.isNotEmpty()) {
1246-
canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) {
1247-
for (span in drawSpans) {
1248-
span.onPreDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout)
1249-
}
1246+
canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) {
1247+
for (span in drawSpans) {
1248+
span.onPreDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout)
12501249
}
1250+
}
12511251

1252-
super.onDraw(canvas)
1252+
super.onDraw(canvas)
12531253

1254-
canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) {
1255-
for (span in drawSpans) {
1256-
span.onDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout)
1257-
}
1254+
canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) {
1255+
for (span in drawSpans) {
1256+
span.onDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout)
12581257
}
1259-
return
12601258
}
1259+
return
12611260
}
12621261
}
12631262

12641263
super.onDraw(canvas)
12651264
}
12661265

1266+
/**
1267+
* TextView drops its layout when an attribute like the placeholder changes, and Fabric only
1268+
* measures this view again when its frame changes. Rebuild the layout for the current frame so
1269+
* the CanvasEffectSpans drawn around super.onDraw are not skipped.
1270+
*/
1271+
private fun layoutForDraw(): Layout? {
1272+
if (layout == null && width > 0) {
1273+
measure(
1274+
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
1275+
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
1276+
)
1277+
}
1278+
return layout
1279+
}
1280+
12671281
public override fun onDragEvent(event: DragEvent): Boolean {
12681282
val dragFilter = dragAndDropFilter
12691283
if (dragFilter != null && event.action == DragEvent.ACTION_DRAG_STARTED) {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
@file:Suppress("DEPRECATION")
9+
10+
package com.facebook.react.views.textinput
11+
12+
import android.graphics.Canvas
13+
import android.text.Layout
14+
import android.text.SpannableStringBuilder
15+
import android.text.Spanned
16+
import android.util.DisplayMetrics
17+
import android.view.View
18+
import android.view.ViewGroup
19+
import androidx.core.content.res.ResourcesCompat.ID_NULL
20+
import androidx.core.graphics.createBitmap
21+
import com.facebook.react.bridge.BridgeReactContext
22+
import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance
23+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
24+
import com.facebook.react.uimanager.DisplayMetricsHolder
25+
import com.facebook.react.uimanager.ThemedReactContext
26+
import com.facebook.react.views.text.internal.span.CanvasEffectSpan
27+
import org.assertj.core.api.Assertions.assertThat
28+
import org.junit.Before
29+
import org.junit.Test
30+
import org.junit.runner.RunWith
31+
import org.robolectric.RobolectricTestRunner
32+
import org.robolectric.RuntimeEnvironment
33+
34+
@RunWith(RobolectricTestRunner::class)
35+
class ReactEditTextTest {
36+
37+
private lateinit var view: ReactEditText
38+
39+
@Before
40+
fun setUp() {
41+
ReactNativeFeatureFlagsForTests.setUp()
42+
val context = BridgeReactContext(RuntimeEnvironment.getApplication())
43+
context.initializeWithInstance(createMockCatalystInstance())
44+
val themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL)
45+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics())
46+
view = ReactTextInputManager().createViewInstance(themedContext)
47+
}
48+
49+
@Test
50+
fun drawsCanvasEffectSpansAfterPlaceholderChangeThatKeepsTheFrame() {
51+
val width = 200
52+
val height = 40
53+
val text = SpannableStringBuilder("Underlined")
54+
val span = RecordingCanvasEffectSpan()
55+
text.setSpan(span, 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
56+
57+
view.layoutParams =
58+
ViewGroup.LayoutParams(
59+
ViewGroup.LayoutParams.WRAP_CONTENT,
60+
ViewGroup.LayoutParams.WRAP_CONTENT,
61+
)
62+
view.setText(text)
63+
view.measure(
64+
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
65+
View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
66+
)
67+
view.layout(0, 0, width, height)
68+
view.draw(Canvas(createBitmap(width, height)))
69+
span.reset()
70+
71+
// Changing the placeholder drops TextView's layout, but Fabric does not measure the view again
72+
// because its frame did not change.
73+
view.setPlaceholder("Placeholder")
74+
view.draw(Canvas(createBitmap(width, height)))
75+
76+
assertThat(span.preDrawCount).isEqualTo(1)
77+
assertThat(span.drawCount).isEqualTo(1)
78+
}
79+
80+
private class RecordingCanvasEffectSpan : CanvasEffectSpan() {
81+
var preDrawCount = 0
82+
var drawCount = 0
83+
84+
fun reset() {
85+
preDrawCount = 0
86+
drawCount = 0
87+
}
88+
89+
override fun onPreDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
90+
preDrawCount++
91+
}
92+
93+
override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) {
94+
drawCount++
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)