From 12c621ee14034d45e2b6a701ff8c84cf56d3e244 Mon Sep 17 00:00:00 2001 From: Mehul Gore Date: Thu, 22 Oct 2026 10:58:02 -0700 Subject: [PATCH] Fix TextInput outline collapsing below theme default padding Summary: TextInput keeps the theme's default EditText background but applies the style's padding. That background is an inset around underline nine-patches, so its outline is the view bounds minus the insets, which only the theme padding covers. With less padding, such as `padding: 0`, the outline collapses to a strip through the text, which breaks elevation shadows and anything else that reads the view outline. `ReactEditText` now uses its bounds as the outline in that case, and refreshes the outline when its padding changes, since Android only does so on size and background changes. Any other background, including the ones React Native creates for border and background styles, keeps its normal outline. Changelog: [Android][Fixed] - Fix `TextInput` outline collapsing when its padding is below the theme default Differential Revision: D126540563 --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/views/textinput/ReactEditText.kt | 46 +++++++ .../textinput/ReactEditTextOutlineTest.kt | 126 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextOutlineTest.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index ba540f18c54d..5498a7a36809 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6292,6 +6292,7 @@ public class com/facebook/react/views/textinput/ReactEditText : androidx/appcomp protected final fun setNativeEventCount (I)V public final fun setOnKeyPress (Z)V public final fun setOverflow (Ljava/lang/String;)V + public fun setPadding (IIII)V public final fun setPlaceholder (Ljava/lang/String;)V public final fun setReturnKeyType (Ljava/lang/String;)V public final fun setScrollWatcher (Lcom/facebook/react/views/textinput/ScrollWatcher;)V 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..30252413d79b 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 @@ -12,9 +12,14 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Canvas import android.graphics.Color +import android.graphics.Outline import android.graphics.Paint import android.graphics.Rect import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.NinePatchDrawable +import android.graphics.drawable.StateListDrawable import android.os.Build import android.os.Bundle import android.text.Editable @@ -37,6 +42,7 @@ import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection @@ -223,6 +229,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat textAttributes = TextAttributes() applyTextAttributes() + outlineProvider = TextFieldOutlineProvider() // Turn off hardware acceleration for Oreo (T40484798) // see https://issuetracker.google.com/issues/67102093 @@ -1058,6 +1065,16 @@ public open class ReactEditText public constructor(context: Context) : AppCompat didAttachToWindow = true } + override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) { + val isSamePadding = + left == paddingLeft && top == paddingTop && right == paddingRight && bottom == paddingBottom + super.setPadding(left, top, right, bottom) + // Android doesn't rebuild the outline on padding changes. + if (!isSamePadding) { + invalidateOutline() + } + } + override fun setBackgroundColor(color: Int) { setBackgroundColor(this, color) } @@ -1346,6 +1363,35 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } } + /** + * Uses the view bounds as the outline when the padding is below the theme EditText background's + * own padding, which would otherwise collapse the outline to a strip through the text. + */ + private class TextFieldOutlineProvider : ViewOutlineProvider() { + private val backgroundPadding = Rect() + + override fun getOutline(view: View, outline: Outline) { + ViewOutlineProvider.BACKGROUND.getOutline(view, outline) + val background = view.background ?: return + if (isThemeTextFieldBackground(background) && hasLessPaddingThan(background, view)) { + outline.setRect(0, 0, view.width, view.height) + } + } + + private fun isThemeTextFieldBackground(background: Drawable): Boolean { + val selector = (background as? InsetDrawable)?.drawable as? StateListDrawable + return selector?.current is NinePatchDrawable + } + + private fun hasLessPaddingThan(background: Drawable, view: View): Boolean { + background.getPadding(backgroundPadding) + return view.paddingLeft < backgroundPadding.left || + view.paddingTop < backgroundPadding.top || + view.paddingRight < backgroundPadding.right || + view.paddingBottom < backgroundPadding.bottom + } + } + public companion object { public val DEBUG_MODE: Boolean = ReactBuildConfig.DEBUG && false diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextOutlineTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextOutlineTest.kt new file mode 100644 index 000000000000..e9612a46764f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactEditTextOutlineTest.kt @@ -0,0 +1,126 @@ +/* + * 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.textinput + +import android.graphics.Color +import android.graphics.Outline +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.NinePatchDrawable +import android.graphics.drawable.StateListDrawable +import android.os.Build +import android.util.DisplayMetrics +import android.util.StateSet +import android.view.ContextThemeWrapper +import androidx.annotation.RequiresApi +import androidx.core.graphics.drawable.toDrawable +import com.facebook.react.bridge.ReactTestHelper.createCatalystContextForTest +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +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) +@RequiresApi(Build.VERSION_CODES.N) +class ReactEditTextOutlineTest { + + private lateinit var view: ReactEditText + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + val context = createCatalystContextForTest() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + view = + ReactTextInputManager().createViewInstance(ThemedReactContext(context, context, null, -1)) + view.layout(0, 0, WIDTH, HEIGHT) + } + + @Test + fun themeBackground_isInsetSelectorOfNinePatches() { + val background = themeTextFieldBackground() + + assertThat(background).isInstanceOf(InsetDrawable::class.java) + val selector = (background as InsetDrawable).drawable + assertThat(selector).isInstanceOf(StateListDrawable::class.java) + assertThat(selector?.current).isInstanceOf(NinePatchDrawable::class.java) + } + + @Test + fun themeBackgroundWithoutPadding_usesViewBounds() { + view.background = themeTextFieldBackground() + view.setPadding(0, 0, 0, 0) + + assertThat(outlineBounds()).isEqualTo(Rect(0, 0, WIDTH, HEIGHT)) + } + + @Test + fun themeBackgroundWithOneSideBelowThemePadding_usesViewBounds() { + val background = themeTextFieldBackground() + view.background = background + val padding = Rect().also { background.getPadding(it) } + view.setPadding(padding.left, 0, padding.right, padding.bottom) + + assertThat(outlineBounds()).isEqualTo(Rect(0, 0, WIDTH, HEIGHT)) + } + + @Test + fun themeBackgroundWithThemePadding_usesBackgroundOutline() { + val background = themeTextFieldBackground() + view.background = background + val padding = Rect().also { background.getPadding(it) } + view.setPadding(padding.left, padding.top, padding.right, padding.bottom) + + assertThat(outlineBounds()).isEqualTo(backgroundOutlineBounds(background)) + } + + @Test + fun customInsetBackgroundWithoutPadding_usesBackgroundOutline() { + val selector = StateListDrawable() + selector.addState(StateSet.WILD_CARD, Color.BLACK.toDrawable()) + val background = InsetDrawable(selector, 4, 10, 4, 7) + background.setBounds(0, 0, WIDTH, HEIGHT) + view.background = background + view.setPadding(0, 0, 0, 0) + + assertThat(outlineBounds()).isEqualTo(Rect(4, 10, WIDTH - 4, HEIGHT - 7)) + } + + private fun themeTextFieldBackground(): Drawable { + val context = + ContextThemeWrapper(RuntimeEnvironment.getApplication(), android.R.style.Theme_Material) + val attrs = context.obtainStyledAttributes(intArrayOf(android.R.attr.editTextBackground)) + val background = checkNotNull(attrs.getDrawable(0)) + attrs.recycle() + background.setBounds(0, 0, WIDTH, HEIGHT) + return background + } + + private fun outlineBounds(): Rect { + val outline = Outline() + view.outlineProvider.getOutline(view, outline) + return Rect().also { outline.getRect(it) } + } + + private fun backgroundOutlineBounds(background: Drawable): Rect { + val outline = Outline() + background.getOutline(outline) + return Rect().also { outline.getRect(it) } + } + + private companion object { + const val WIDTH = 200 + const val HEIGHT = 100 + } +}