From eb11ef92c2069f4cfa3603cabedcddc1ea4069e6 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 19 Oct 2026 18:17:17 +0200 Subject: [PATCH] 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. --- .../react/views/textinput/ReactEditText.kt | 40 +++++--- .../views/textinput/ReactEditTextTest.kt | 97 +++++++++++++++++++ 2 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextTest.kt 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 754f10026624..2e5df884d39c 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 @@ -19,6 +19,7 @@ import android.os.Build import android.os.Bundle import android.text.Editable import android.text.InputType +import android.text.Layout import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -1239,31 +1240,44 @@ public open class ReactEditText public constructor(context: Context) : AppCompat val spanned = text as? Spanned if (spanned != null) { - val layout = layout + val drawSpans = spanned.getSpans(0, spanned.length, CanvasEffectSpan::class.java) + val layout = if (drawSpans.isNotEmpty()) layoutForDraw() else null if (layout != null) { - val drawSpans = spanned.getSpans(0, spanned.length, CanvasEffectSpan::class.java) - if (drawSpans.isNotEmpty()) { - canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) { - for (span in drawSpans) { - span.onPreDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout) - } + canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) { + for (span in drawSpans) { + span.onPreDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout) } + } - super.onDraw(canvas) + super.onDraw(canvas) - canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) { - for (span in drawSpans) { - span.onDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout) - } + canvas.withTranslation(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat()) { + for (span in drawSpans) { + span.onDraw(spanned.getSpanStart(span), spanned.getSpanEnd(span), this, layout) } - return } + return } } super.onDraw(canvas) } + /** + * TextView drops its layout when an attribute like the placeholder changes, and Fabric only + * measures this view again when its frame changes. Rebuild the layout for the current frame so + * the CanvasEffectSpans drawn around super.onDraw are not skipped. + */ + private fun layoutForDraw(): Layout? { + if (layout == null && width > 0) { + measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + } + return layout + } + public override fun onDragEvent(event: DragEvent): Boolean { val dragFilter = dragAndDropFilter if (dragFilter != null && event.action == DragEvent.ACTION_DRAG_STARTED) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextTest.kt new file mode 100644 index 000000000000..1c908075d611 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextTest.kt @@ -0,0 +1,97 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.textinput + +import android.graphics.Canvas +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat.ID_NULL +import androidx.core.graphics.createBitmap +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.text.internal.span.CanvasEffectSpan +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 + +@RunWith(RobolectricTestRunner::class) +class ReactEditTextTest { + + private lateinit var view: ReactEditText + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + val themedContext = ThemedReactContext(context, context.baseContext, null, ID_NULL) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + view = ReactTextInputManager().createViewInstance(themedContext) + } + + @Test + fun drawsCanvasEffectSpansAfterPlaceholderChangeThatKeepsTheFrame() { + val width = 200 + val height = 40 + val text = SpannableStringBuilder("Underlined") + val span = RecordingCanvasEffectSpan() + text.setSpan(span, 0, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + view.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) + view.setText(text) + view.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + view.layout(0, 0, width, height) + view.draw(Canvas(createBitmap(width, height))) + span.reset() + + // Changing the placeholder drops TextView's layout, but Fabric does not measure the view again + // because its frame did not change. + view.setPlaceholder("Placeholder") + view.draw(Canvas(createBitmap(width, height))) + + assertThat(span.preDrawCount).isEqualTo(1) + assertThat(span.drawCount).isEqualTo(1) + } + + private class RecordingCanvasEffectSpan : CanvasEffectSpan() { + var preDrawCount = 0 + var drawCount = 0 + + fun reset() { + preDrawCount = 0 + drawCount = 0 + } + + override fun onPreDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) { + preDrawCount++ + } + + override fun onDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) { + drawCount++ + } + } +}