Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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?) {
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,27 @@ 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
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
Expand Down Expand Up @@ -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
Expand Down
Loading