From fa06c7bf19c18d445969430b4929e41d559e7e67 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 23:32:37 +0300 Subject: [PATCH] Keep a read-only Android TextInput focusable editable={false} disabled the EditText, so a hardware keyboard's Tab skipped it, TalkBack announced it as disabled, and its text could not be selected. Refuse input the way TextView does instead: the input drops its key listener while it is read-only, which leaves no input method, hardware key or cut and paste able to edit it and keeps it focusable. TextView reports TYPE_NULL for such a view and marks every EditText editable for accessibility, so the multiline, secure-text and input-type reads use the configured type, and the accessibility delegate drops the editable flag and ACTION_SET_TEXT while the input is read-only. --- .../react/views/textinput/ReactEditText.kt | 58 ++++++++-- .../views/textinput/ReactTextInputManager.kt | 2 +- .../textinput/ReactTextInputPropertyTest.kt | 101 +++++++++++++++++- 3 files changed, 146 insertions(+), 15 deletions(-) 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..f5e9c85b37f2 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 @@ -45,6 +45,8 @@ import androidx.appcompat.widget.AppCompatEditText import androidx.core.graphics.withTranslation import androidx.core.util.Predicate import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat import com.facebook.common.logging.FLog import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException import com.facebook.react.common.ReactConstants @@ -165,12 +167,29 @@ public open class ReactEditText public constructor(context: Context) : AppCompat return field } + /** + * Whether the text takes edits. A field that does not keeps its focus and its selection: it + * carries no key listener, which is how [android.widget.TextView] refuses typing, an input method + * and cut and paste while staying enabled. + */ + internal var isEditable: Boolean = true + set(value) { + if (field != value) { + field = value + applyKeyListener() + } + } + + // The input type the props configure. A field with no key listener reports TYPE_NULL from + // getInputType(), so the configuration is read from here. + private var configuredInputType: Int + internal val isMultiline: Boolean - get() = (inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0 + get() = (configuredInputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0 private val isSecureText: Boolean get() = - ((inputType and + ((configuredInputType and (InputType.TYPE_NUMBER_VARIATION_PASSWORD or InputType.TYPE_TEXT_VARIATION_PASSWORD)) != 0) @@ -216,6 +235,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat disableFullscreen = false listeners = null stagedInputType = inputType + configuredInputType = inputType if (keyListener == null) { keyListener = InternalKeyListener() } @@ -240,7 +260,23 @@ public open class ReactEditText public constructor(context: Context) : AppCompat this@ReactEditText.isFocusable, this@ReactEditText.importantForAccessibility, ) { + // TextView reports every EditText as editable and offers to replace its text, whatever + // its key listener says. + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat, + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + if (!this@ReactEditText.isEditable) { + info.isEditable = false + info.removeAction(AccessibilityActionCompat.ACTION_SET_TEXT) + } + } + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + if (action == AccessibilityNodeInfo.ACTION_SET_TEXT && !this@ReactEditText.isEditable) { + return false + } if (action == AccessibilityNodeInfo.ACTION_CLICK) { val length = checkNotNull(text).length if (length > 0) { @@ -424,9 +460,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat // that. This method will eventually replace requestFocusInternal() private fun requestFocusProgrammatically(): Boolean { val focused = super.requestFocus(FOCUS_DOWN, null) - if (isInTouchMode && showSoftInputOnFocus) { - showSoftKeyboard() - } + showSoftKeyboardIfEditable() return focused } @@ -561,7 +595,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat } internal fun commitStagedInputType() { - if (inputType != stagedInputType) { + if (configuredInputType != stagedInputType) { val selectionStart = selectionStart val selectionEnd = selectionEnd inputType = stagedInputType @@ -574,6 +608,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat val tf = super.getTypeface() super.setInputType(type) stagedInputType = type + configuredInputType = type // Input type password defaults to monospace font, so we need to re-apply the font super.setTypeface(tf) @@ -623,7 +658,11 @@ public open class ReactEditText public constructor(context: Context) : AppCompat keyListener = InternalKeyListener() } keyListener?.inputType = type - super.setKeyListener(keyListener) + applyKeyListener() + } + + private fun applyKeyListener() { + super.setKeyListener(if (isEditable) keyListener else null) } public fun setPlaceholder(placeholder: String?) { @@ -967,10 +1006,9 @@ public open class ReactEditText public constructor(context: Context) : AppCompat protected fun showSoftKeyboard(): Boolean = inputMethodManager.showSoftInput(this, 0) - // Mirrors the guard on requestFocusProgrammatically(), plus editability ("editable" - // prop maps to isEnabled): never summon the keyboard for a read-only input. + // Never summon the keyboard for an input that cannot be edited. private fun showSoftKeyboardIfEditable() { - if (isEnabled && isInTouchMode && showSoftInputOnFocus) { + if (isEditable && isEnabled && isInTouchMode && showSoftInputOnFocus) { showSoftKeyboard() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 82e5abd361bd..ed8638f75a3c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -606,7 +606,7 @@ public open class ReactTextInputManager public constructor() : @ReactProp(name = "editable", defaultBoolean = true) public fun setEditable(view: ReactEditText, editable: Boolean) { - view.isEnabled = editable + view.isEditable = editable } @ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = 1) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt index 82ec014eb89e..6179498e77c0 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/textinput/ReactTextInputPropertyTest.kt @@ -13,6 +13,7 @@ package com.facebook.react.views.textinput import android.graphics.Color import android.os.Build +import android.os.Bundle import android.text.InputFilter import android.text.InputFilter.AllCaps import android.text.InputType @@ -20,13 +21,19 @@ import android.text.Layout import android.text.SpannableString import android.text.Spanned import android.text.TextUtils +import android.text.method.PasswordTransformationMethod import android.util.DisplayMetrics import android.view.Gravity +import android.view.KeyEvent import android.view.View +import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.EditorInfo import androidx.annotation.RequiresApi import androidx.autofill.HintConstants import androidx.core.content.res.ResourcesCompat.ID_NULL +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.JavaOnlyMap @@ -227,19 +234,105 @@ class ReactTextInputPropertyTest { @Test fun testEditable() { manager.updateProperties(view, buildStyles()) - assertThat(view.isEnabled).isTrue + assertThat(view.onCheckIsTextEditor()).isTrue manager.updateProperties(view, buildStyles("editable", false)) - assertThat(view.isEnabled).isFalse + assertThat(view.onCheckIsTextEditor()).isFalse + assertThat(view.isEnabled).isTrue manager.updateProperties(view, buildStyles("editable", null)) - assertThat(view.isEnabled).isTrue + assertThat(view.onCheckIsTextEditor()).isTrue manager.updateProperties(view, buildStyles("editable", false)) - assertThat(view.isEnabled).isFalse + assertThat(view.onCheckIsTextEditor()).isFalse + assertThat(view.isEnabled).isTrue manager.updateProperties(view, buildStyles("editable", true)) + assertThat(view.onCheckIsTextEditor()).isTrue + } + + @Test + fun testNonEditableTakesFocus() { + manager.updateProperties(view, buildStyles("editable", false)) assertThat(view.isEnabled).isTrue + assertThat(view.requestFocus()).isTrue + } + + @Test + fun testNonEditableRefusesAccessibilitySetText() { + val setText = Bundle() + setText.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, "a") + + manager.updateProperties(view, buildStyles("editable", false)) + val info = AccessibilityNodeInfoCompat.obtain() + ViewCompat.onInitializeAccessibilityNodeInfo(view, info) + assertThat(info.isEditable).isFalse + assertThat(info.actionList).doesNotContain(AccessibilityActionCompat.ACTION_SET_TEXT) + assertThat( + ViewCompat.performAccessibilityAction( + view, + AccessibilityNodeInfo.ACTION_SET_TEXT, + setText, + ) + ) + .isFalse + assertThat(view.text.toString()).isEmpty() + + manager.updateProperties(view, buildStyles("editable", true)) + assertThat( + ViewCompat.performAccessibilityAction( + view, + AccessibilityNodeInfo.ACTION_SET_TEXT, + setText, + ) + ) + .isTrue + assertThat(view.text.toString()).isEqualTo("a") + } + + @Test + fun testNonEditableRefusesKeys() { + manager.updateProperties(view, buildStyles("editable", false)) + view.onKeyDown(KeyEvent.KEYCODE_A, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A)) + assertThat(view.text.toString()).isEmpty() + + manager.updateProperties(view, buildStyles("editable", true)) + view.onKeyDown(KeyEvent.KEYCODE_A, KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_A)) + assertThat(view.text.toString()).isEqualTo("a") + } + + @Test + fun testNonEditableKeepsInputType() { + val emailTypeFlags = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS or InputType.TYPE_CLASS_TEXT + + manager.updateProperties(view, buildStyles("keyboardType", "email-address", "editable", false)) + manager.updateProperties(view, buildStyles("editable", true)) + assertThat(view.inputType and generalKeyboardTypeFlags).isEqualTo(emailTypeFlags) + } + + @Test + fun testNonEditableTakesKeyboardTypeChanges() { + val numericTypeFlags = + (InputType.TYPE_CLASS_NUMBER or + InputType.TYPE_NUMBER_FLAG_DECIMAL or + InputType.TYPE_NUMBER_FLAG_SIGNED) + + manager.updateProperties(view, buildStyles("editable", false)) + manager.updateProperties(view, buildStyles("keyboardType", "numeric")) + manager.updateProperties(view, buildStyles("editable", true)) + assertThat(view.inputType and generalKeyboardTypeFlags).isEqualTo(numericTypeFlags) + } + + @Test + fun testNonEditableKeepsMultilineAndSecureText() { + manager.updateProperties(view, buildStyles("multiline", true, "editable", false)) + assertThat(view.isMultiline).isTrue + + manager.updateProperties(view, buildStyles("multiline", false, "secureTextEntry", true)) + assertThat(view.transformationMethod).isInstanceOf(PasswordTransformationMethod::class.java) + + manager.updateProperties(view, buildStyles("secureTextEntry", false)) + assertThat(view.transformationMethod).isNotInstanceOf(PasswordTransformationMethod::class.java) } @Test