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
9 changes: 9 additions & 0 deletions packages/react-native/Libraries/Text/TextProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ export type TextPropsAndroid = {
*/
disabled?: ?boolean,

/**
* Whether this `Text` should be focusable with a non-touch input device, eg.
* receive focus with a hardware keyboard. An accessible `Text` is focusable
* unless this is `false`, and accessibility services reach it either way.
*
* @platform android
*/
focusable?: ?boolean,

/**
* Highlight color of the text when selected.
*
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -6018,6 +6018,7 @@ public final class com/facebook/react/views/text/ReactFontManager$Companion {

public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/widget/AppCompatTextView, com/facebook/react/uimanager/ReactCompoundView {
public fun <init> (Landroid/content/Context;)V
public fun addFocusables (Ljava/util/ArrayList;II)V
protected fun dispatchHoverEvent (Landroid/view/MotionEvent;)Z
public fun dispatchKeyEvent (Landroid/view/KeyEvent;)Z
public fun getSpanned ()Landroid/text/Spannable;
Expand All @@ -6029,6 +6030,7 @@ public class com/facebook/react/views/text/ReactTextView : androidx/appcompat/wi
protected fun onMeasure (II)V
protected fun onSizeChanged (IIII)V
public fun reactTagForTouch (FF)I
public fun requestFocus (ILandroid/graphics/Rect;)Z
public fun setAdjustFontSizeToFit (Z)V
public fun setBackgroundColor (I)V
public fun setBorderColor (ILjava/lang/Integer;)V
Expand Down Expand Up @@ -6085,6 +6087,7 @@ public class com/facebook/react/views/text/ReactTextViewManager : com/facebook/r
public final fun setDataDetectorType (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V
public final fun setDisabled (Lcom/facebook/react/views/text/ReactTextView;Z)V
public final fun setEllipsizeMode (Lcom/facebook/react/views/text/ReactTextView;Ljava/lang/String;)V
public final fun setFocusable (Lcom/facebook/react/views/text/ReactTextView;Z)V
public final fun setFontSize (Lcom/facebook/react/views/text/ReactTextView;F)V
public final fun setIncludeFontPadding (Lcom/facebook/react/views/text/ReactTextView;Z)V
public final fun setLetterSpacing (Lcom/facebook/react/views/text/ReactTextView;F)V
Expand Down Expand Up @@ -6439,6 +6442,7 @@ public final class com/facebook/react/views/view/ReactDrawableHelper {
public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGroup, com/facebook/react/touch/ReactHitSlopView, com/facebook/react/touch/ReactInterceptingViewGroup, com/facebook/react/uimanager/HasElevatedDescendantCache, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/uimanager/ReactPointerEventsView {
public fun <init> (Landroid/content/Context;)V
public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V
public fun addFocusables (Ljava/util/ArrayList;II)V
public final fun cleanUpAxOrderListener ()V
protected fun dispatchDraw (Landroid/graphics/Canvas;)V
public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z
Expand Down Expand Up @@ -6468,6 +6472,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
public fun onViewAdded (Landroid/view/View;)V
public fun onViewRemoved (Landroid/view/View;)V
public fun requestFocus (ILandroid/graphics/Rect;)Z
public fun requestLayout ()V
public final fun setAxOrderList (Ljava/util/List;)V
public final fun setBackfaceVisibility (Ljava/lang/String;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -581,20 +581,41 @@ public object ReactScrollViewHelper {
UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC)
?: return null

val nextFocusableViewId =
(uimanager as FabricUIManager).findNextFocusableElement(host.id, focused.id, direction)
?: return null
// The shadow tree offers every focusable or accessible node, while a view can still keep
// itself out of keyboard navigation (focusable={false} on an accessible view), so the search
// moves on from a candidate that offers no keyboard focus. Forward and backward candidates can
// lead back to each other, which the visited ids end.
val visitedIds = mutableSetOf(focused.id)
var sourceId = focused.id
while (true) {
val nextFocusableViewId =
(uimanager as FabricUIManager).findNextFocusableElement(host.id, sourceId, direction)
?: return null
if (!visitedIds.add(nextFocusableViewId)) {
return null
}

val ancestorIdList =
uimanager
.getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId)
?.toMutableSet() ?: return null

val ancestorIdList =
uimanager
.getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId)
?.toMutableSet() ?: return null
ancestorIdList.add(nextFocusableViewId)

ancestorIdList.add(nextFocusableViewId)
host.updateClippingRect(ancestorIdList)

host.updateClippingRect(ancestorIdList)
val nextFocusableView = host.findViewById<View>(nextFocusableViewId) ?: return null
if (offersKeyboardFocus(nextFocusableView, direction)) {
return nextFocusableView
}
sourceId = nextFocusableViewId
}
}

return host.findViewById(nextFocusableViewId)
private fun offersKeyboardFocus(view: View, @FocusDirection direction: Int): Boolean {
val focusables = ArrayList<View>()
view.addFocusables(focusables, direction)
return focusables.isNotEmpty()
}

@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re

@ColorInt var selectionColor: Int? = null

/**
* Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a text excluded here
* stays focusable for accessibility services while keyboard navigation passes over it.
*/
var excludedFromKeyboardFocus: Boolean = false

val text: CharSequence?
// Avoid mangling the getter name, to allow black box E2E tests to read text content via
// reflection
Expand All @@ -106,6 +112,7 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
clickableSpans = emptyList()
selection = null
selectionColor = null
excludedFromKeyboardFocus = false
lastFrameTimeNanos = 0L
preparedLayout = null
}
Expand Down Expand Up @@ -424,6 +431,23 @@ internal class PreparedLayoutTextView(context: Context) : ViewGroup(context), Re
}
}

override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
val firstAddedIndex = views.size
super.addFocusables(views, direction, focusableMode)
// A focused view stays a candidate so that focus search can move on from it.
if (excludedFromKeyboardFocus && !isFocused) {
views.subList(firstAddedIndex, views.size).remove(this)
}
}

override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean {
if (!excludedFromKeyboardFocus) {
return super.requestFocus(direction, previouslyFocusedRect)
}
return descendantFocusability != FOCUS_BLOCK_DESCENDANTS &&
onRequestFocusInDescendants(direction, previouslyFocusedRect)
}

override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val accessibilityDelegateCompat = ViewCompat.getAccessibilityDelegate(this)
val delegateHandled =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ internal class PreparedLayoutTextViewManager :
view.isFocusable = accessible
}

@ReactProp(name = "focusable", defaultBoolean = true)
fun setFocusable(view: PreparedLayoutTextView, focusable: Boolean) {
view.excludedFromKeyboardFocus = !focusable
}

@ReactProp(name = "selectable", defaultBoolean = false)
fun setSelectable(view: PreparedLayoutTextView, isSelectable: Boolean) {
check(!isSelectable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import com.facebook.react.views.text.internal.span.ReactTagSpan;
import com.facebook.yoga.YogaMeasureMode;
import java.lang.reflect.Method;
import java.util.ArrayList;

@Nullsafe(Nullsafe.Mode.LOCAL)
public class ReactTextView extends AppCompatTextView implements ReactCompoundView {
Expand Down Expand Up @@ -80,6 +81,13 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie
private boolean mShouldAdjustSpannableFontSize;
private Overflow mOverflow = Overflow.VISIBLE;

/**
* Set from the `focusable` prop. {@link #isFocusable()} also carries `accessible`, so a text
* excluded here stays focusable for accessibility services while keyboard navigation passes over
* it.
*/
private boolean mExcludedFromKeyboardFocus;

private @Nullable Spannable mSpanned;
private @Nullable PreparedLayout mPreparedLayout;

Expand Down Expand Up @@ -132,6 +140,7 @@ private void initView() {
mAdjustsFontSizeToFit = false;
mLinkifyMaskType = 0;
mTextIsSelectable = false;
mExcludedFromKeyboardFocus = false;
mShouldAdjustSpannableFontSize = false;
mEllipsizeLocation = TextUtils.TruncateAt.END;
mFontSize = Float.NaN;
Expand Down Expand Up @@ -687,6 +696,28 @@ public void setLinkifyMask(int mask) {
mLinkifyMaskType = mask;
}

/* package */ void setExcludedFromKeyboardFocus(boolean excludedFromKeyboardFocus) {
mExcludedFromKeyboardFocus = excludedFromKeyboardFocus;
}

@Override
public void addFocusables(ArrayList<View> views, int direction, int focusableMode) {
// A focused view stays a candidate so that focus search can move on from it.
if (mExcludedFromKeyboardFocus && !isFocused()) {
return;
}
super.addFocusables(views, direction, focusableMode);
}

@Override
public boolean requestFocus(int direction, @Nullable Rect previouslyFocusedRect) {
// Selecting text takes focus from a touch, which `focusable` leaves alone.
if (mExcludedFromKeyboardFocus && !isInTouchMode()) {
return false;
}
return super.requestFocus(direction, previouslyFocusedRect);
}

@Override
protected boolean dispatchHoverEvent(MotionEvent event) {
// if this view has an accessibility delegate set, and that delegate supports virtual view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ public constructor(
view.isFocusable = accessible
}

@ReactProp(name = "focusable", defaultBoolean = true)
public fun setFocusable(view: ReactTextView, focusable: Boolean) {
view.setExcludedFromKeyboardFocus(!focusable)
}

// maxLines can only be set in master view (block), doesn't really make sense to set in a span
@ReactProp(name = ViewProps.NUMBER_OF_LINES, defaultInt = ViewDefaults.NUMBER_OF_LINES)
public fun setNumberOfLines(view: ReactTextView, numberOfLines: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ public open class ReactViewGroup public constructor(context: Context?) :
null
private var focusOnAttach = false

/**
* Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a view excluded here
* stays focusable for accessibility services while keyboard navigation passes over it.
*/
internal var excludedFromKeyboardFocus = false

internal var nativeBackgroundMap: ReadableMap? = null
internal var nativeForegroundMap: ReadableMap? = null

Expand Down Expand Up @@ -201,6 +207,7 @@ public open class ReactViewGroup public constructor(context: Context?) :
childrenRemovedWhileTransitioning = null
nativeBackgroundMap = null
nativeForegroundMap = null
excludedFromKeyboardFocus = false
}

internal open fun recycleView() {
Expand Down Expand Up @@ -508,6 +515,23 @@ public open class ReactViewGroup public constructor(context: Context?) :
updateClippingToRect(clippingRect, excludedViews)
}

override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
val firstAddedIndex = views.size
super.addFocusables(views, direction, focusableMode)
// A focused view stays a candidate so that focus search can move on from it.
if (excludedFromKeyboardFocus && !isFocused) {
views.subList(firstAddedIndex, views.size).remove(this)
}
}

override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean {
if (!excludedFromKeyboardFocus) {
return super.requestFocus(direction, previouslyFocusedRect)
}
return descendantFocusability != FOCUS_BLOCK_DESCENDANTS &&
onRequestFocusInDescendants(direction, previouslyFocusedRect)
}

internal fun requestFocusFromJS() {
if (isAttachedToWindow) {
super.requestFocus(FOCUS_DOWN, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()

@ReactProp(name = "focusable")
public open fun setFocusable(view: ReactViewGroup, focusable: Boolean) {
view.excludedFromKeyboardFocus = !focusable
if (focusable) {
view.setOnClickListener {
val eventDispatcher = UIManagerHelper.getEventDispatcher(view.context as ReactContext)
Expand All @@ -436,7 +437,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
view.setOnClickListener(null)
view.isClickable = false
// Don't set view.setFocusable(false) because we might still want it to be focusable for
// accessibility reasons
// accessibility reasons. excludedFromKeyboardFocus keeps it out of keyboard navigation.
}
}

Expand Down
Loading
Loading