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 + } +}