From c068c599c641491e735caf79e4e60a0fb3c04f83 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Tue, 11 Mar 2025 17:08:51 -0700 Subject: [PATCH] Fix keyboard navigation for FlatList with `removeClippedSubviews` enabled (#49543) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/49543 When using `ReactScrollView` or `ReactHorizontalScrollView` Views with `removeClippedSubviews` keyboard navigation didn't work. This is because keyboard navigation relies on Android's View hierarchy to find the next focusable element. With `removeClippedSubviews` the next View might've been removed from the hierarchy. With this change we delegate the job of figuring out the next focusable element to the Shadow Tree, which will always contain layout information of the next element of the ScrollView. Changelog: [Android][Fixed] - Fix keyboard navigation on lists with `removeClippedSubviews` enabled Reviewed By: joevilches Differential Revision: D69618406 fbshipit-source-id: 1df7f90066dfc685e74a89b29222937777714b87 --- .../ReactAndroid/api/ReactAndroid.api | 12 ++ .../react/fabric/FabricUIManager.java | 46 ++++++ .../react/fabric/FabricUIManagerBinding.kt | 6 + .../react/fabric/NextFocusableNode.kt | 10 ++ .../scroll/ReactHorizontalScrollView.java | 13 ++ .../react/views/scroll/ReactScrollView.java | 14 ++ .../views/scroll/ReactScrollViewHelper.kt | 87 +++++++++++ .../react/fabric/FabricUIManagerBinding.cpp | 104 +++++++++++++ .../jni/react/fabric/FabricUIManagerBinding.h | 5 + .../jni/react/fabric/FocusOrderingHelper.cpp | 139 ++++++++++++++++++ .../jni/react/fabric/FocusOrderingHelper.h | 42 ++++++ 11 files changed, 478 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/NextFocusableNode.kt create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 2f64b4636ca..f348840baaf 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -2396,6 +2396,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid public fun dispatchCommand (IILcom/facebook/react/bridge/ReadableArray;)V public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V + public fun findNextFocusableElementMetrics (III)Lcom/facebook/react/fabric/NextFocusableNode; public fun getColor (I[Ljava/lang/String;)I public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher; public fun getPerformanceCounters ()Ljava/util/Map; @@ -2435,6 +2436,12 @@ public final class com/facebook/react/fabric/FabricUIManagerProviderImpl : com/f public fun createUIManager (Lcom/facebook/react/bridge/ReactApplicationContext;)Lcom/facebook/react/bridge/UIManager; } +public final class com/facebook/react/fabric/NextFocusableNode { + public fun (IF)V + public final fun getDeltaScroll ()F + public final fun getId ()I +} + public class com/facebook/react/fabric/StateWrapperImpl : com/facebook/jni/HybridClassBase, com/facebook/react/uimanager/StateWrapper { public fun destroyState ()V public fun getStateData ()Lcom/facebook/react/bridge/ReadableNativeMap; @@ -6198,6 +6205,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android public fun executeKeyEvent (Landroid/view/KeyEvent;)Z public fun flashScrollIndicators ()V public fun fling (I)V + public fun focusSearch (Landroid/view/View;I)Landroid/view/View; public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z public fun getClippingRect (Landroid/graphics/Rect;)V public fun getFlingAnimator ()Landroid/animation/ValueAnimator; @@ -6320,6 +6328,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun executeKeyEvent (Landroid/view/KeyEvent;)Z public fun flashScrollIndicators ()V public fun fling (I)V + public fun focusSearch (Landroid/view/View;I)Landroid/view/View; public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z public fun getClippingRect (Landroid/graphics/Rect;)V public fun getFlingAnimator ()Landroid/animation/ValueAnimator; @@ -6440,6 +6449,8 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper { public static final fun emitScrollEvent (Landroid/view/ViewGroup;FF)V public static final fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V public static final fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V + public static final fun findNextClippedElement (Landroid/view/ViewGroup;Landroid/view/View;ILcom/facebook/react/bridge/ReactContext;)Landroid/view/View; + public static final fun findNextFocusableView (Landroid/view/ViewGroup;Landroid/view/View;IZ)Landroid/view/View; public static final fun forceUpdateState (Landroid/view/ViewGroup;)V public static final fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I public static final fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I @@ -6449,6 +6460,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper { public final fun registerFlingAnimator (Landroid/view/ViewGroup;)V public static final fun removeLayoutChangeListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$LayoutChangeListener;)V public static final fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V + public static final fun resolveAbsoluteDirection (IZI)I public static final fun smoothScrollTo (Landroid/view/ViewGroup;II)V public static final fun updateFabricScrollState (Landroid/view/ViewGroup;)V public final fun updateFabricScrollState (Landroid/view/ViewGroup;II)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 0ecb1ace9f2..0fcdb771091 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -29,6 +29,7 @@ import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; +import androidx.core.view.ViewCompat.FocusRealDirection; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.ThreadConfined; import com.facebook.proguard.annotations.DoNotStripAny; @@ -260,6 +261,51 @@ public class FabricUIManager return rootTag; } + /** + * Find the next focusable element's id and position relative to the parent from the shadow tree + * based on the current focusable element and the direction. + * + * @return A NextFocusableNode object where the 'id' is the reactId/Tag of the next focusable + * view, and 'deltaScroll' is the scroll delta needed to make the view visible on the screen. + * Returns null if no valid node is found. + */ + public @Nullable NextFocusableNode findNextFocusableElementMetrics( + int parentTag, int focusedTag, @FocusRealDirection int direction) { + if (mBinding == null) { + return null; + } + + int generalizedDirection; + + switch (direction) { + case View.FOCUS_DOWN: + generalizedDirection = 0; + break; + case View.FOCUS_UP: + generalizedDirection = 1; + break; + case View.FOCUS_RIGHT: + generalizedDirection = 2; + break; + case View.FOCUS_LEFT: + generalizedDirection = 3; + break; + default: + return null; + } + + @Nullable + float[] serializedNextFocusableNodeMetrics = + mBinding.findNextFocusableElementMetrics(parentTag, focusedTag, generalizedDirection); + + if (serializedNextFocusableNodeMetrics == null) { + return null; + } + + return new NextFocusableNode( + (int) serializedNextFocusableNodeMetrics[0], serializedNextFocusableNodeMetrics[1]); + } + @Override @AnyThread @ThreadConfined(ANY) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt index 13c448525a2..6c8b64c2b84 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt @@ -55,6 +55,12 @@ internal class FabricUIManagerBinding : HybridClassBase() { isMountable: Boolean ) + external fun findNextFocusableElementMetrics( + parentTag: Int, + focusedTag: Int, + direction: Int + ): FloatArray + external fun stopSurface(surfaceId: Int) external fun stopSurfaceWithSurfaceHandler(surfaceHandler: SurfaceHandlerBinding) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/NextFocusableNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/NextFocusableNode.kt new file mode 100644 index 00000000000..1b2e88e813a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/NextFocusableNode.kt @@ -0,0 +1,10 @@ +/* + * 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.fabric + +public class NextFocusableNode(public val id: Int, public val deltaScroll: Float) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index e037f05ad19..e326fcfed81 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -11,6 +11,7 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; +import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; @@ -31,6 +32,7 @@ import android.widget.HorizontalScrollView; import android.widget.OverScroller; import androidx.annotation.Nullable; import androidx.core.view.ViewCompat; +import androidx.core.view.ViewCompat.FocusRealDirection; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.Nullsafe; @@ -772,6 +774,17 @@ public class ReactHorizontalScrollView extends HorizontalScrollView } } + @Override + public @Nullable View focusSearch(View focused, @FocusRealDirection int direction) { + @Nullable View nextfocusableView = findNextFocusableView(this, focused, direction, true); + + if (nextfocusableView != null) { + return nextfocusableView; + } + + return super.focusSearch(focused, direction); + } + @Override public void updateClippingRect() { if (!mRemoveClippedSubviews) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 71acfa6288b..0b321c07dd1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -11,6 +11,7 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; +import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; @@ -31,6 +32,7 @@ import android.widget.ScrollView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.view.ViewCompat; +import androidx.core.view.ViewCompat.FocusRealDirection; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.Nullsafe; @@ -359,6 +361,18 @@ public class ReactScrollView extends ScrollView } } + @Override + public @Nullable View focusSearch(View focused, @FocusRealDirection int direction) { + + @Nullable View nextfocusableView = findNextFocusableView(this, focused, direction, false); + + if (nextfocusableView != null) { + return nextfocusableView; + } + + return super.focusSearch(focused, direction); + } + /** * Since ReactScrollView handles layout changes on JS side, it does not call super.onlayout due to * which mIsLayoutDirty flag in ScrollView remains true and prevents scrolling to child when diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index 5b3f661c634..517dddb4905 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -11,15 +11,19 @@ import android.animation.Animator import android.animation.ValueAnimator import android.content.Context import android.graphics.Point +import android.view.FocusFinder import android.view.View import android.view.ViewGroup import android.widget.OverScroller +import androidx.core.view.ViewCompat.FocusRealDirection import com.facebook.common.logging.FLog import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.common.ReactConstants +import com.facebook.react.fabric.FabricUIManager import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel +import com.facebook.react.uimanager.PixelUtil.toPixelFromDIP import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.common.UIManagerType @@ -462,6 +466,89 @@ public object ReactScrollViewHelper { return Point(scroller.finalX, scroller.finalY) } + @JvmStatic + public fun findNextFocusableView( + host: ViewGroup, + focused: View, + @FocusRealDirection direction: Int, + horizontal: Boolean + ): View? { + val absDir = resolveAbsoluteDirection(direction, horizontal, host.getLayoutDirection()) + + /* + * Check if we can focus the next element in the absolute direction within the ScrollView, + * if we can't, look into the shadow tree to find the next focusable element + */ + val ff = FocusFinder.getInstance() + val result = ff.findNextFocus(host, focused, absDir) + + if (result != null) { + return result + } + + /* + * Attempt to focus the next focusable but clipped element on the list if there is one, since + * the view is clipped it is not currently in the hierarchy so we scroll it into view and then + * focus it. + */ + return findNextClippedElement(host, focused, absDir, host.context as ReactContext) + } + + /** + * Attempts to focus the next element in the specified direction within the scrollView. + * + * @return true if a new element was successfully focused, otherwise false. + */ + @JvmStatic + public fun findNextClippedElement( + scrollView: ViewGroup, + focused: View, + @FocusRealDirection direction: Int, + context: ReactContext, + ): View? { + val uimanager = UIManagerHelper.getUIManager(context, UIManagerType.FABRIC) ?: return null + + val nextFocusableViewMetrics = + (uimanager as FabricUIManager).findNextFocusableElementMetrics( + scrollView.id, focused.id, direction) + + if (nextFocusableViewMetrics != null) { + + when (direction) { + View.FOCUS_UP, + View.FOCUS_DOWN -> { + scrollView.scrollBy(0, toPixelFromDIP(nextFocusableViewMetrics.deltaScroll).toInt()) + } + View.FOCUS_RIGHT, + View.FOCUS_LEFT -> { + scrollView.scrollBy(toPixelFromDIP(nextFocusableViewMetrics.deltaScroll).toInt(), 0) + } + else -> return null + } + return scrollView.findViewById(nextFocusableViewMetrics.id) + } + return null + } + + @JvmStatic + public fun resolveAbsoluteDirection( + @FocusRealDirection direction: Int, + horizontal: Boolean, + layoutDirection: Int + ): Int { + val rtl: Boolean = layoutDirection == View.LAYOUT_DIRECTION_RTL + + return if (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD) { + if (horizontal) { + if ((direction == View.FOCUS_FORWARD) != rtl) View.FOCUS_RIGHT else View.FOCUS_LEFT + } else { + if (direction == View.FOCUS_FORWARD) View.FOCUS_DOWN else View.FOCUS_UP + } + } else { + direction + } + } + public interface ScrollListener { public fun onScroll( scrollView: ViewGroup?, diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp index 4b6418c5500..67d4e124ef1 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp @@ -12,6 +12,7 @@ #include "EventBeatManager.h" #include "EventEmitterWrapper.h" #include "FabricMountingManager.h" +#include "FocusOrderingHelper.h" #include #include @@ -195,6 +196,106 @@ void FabricUIManagerBinding::startSurface( } } +jfloatArray FabricUIManagerBinding::findNextFocusableElementMetrics( + jint parentTag, + jint focusedTag, + jint direction) { + /* + * Holds the values of the next focusable element + * [0] - tag + * [1] - delta + */ + std::array nodeValues{-1, -1}; + + JNIEnv* env = jni::Environment::current(); + + ShadowNode::Shared nextNode; + + auto nodeValuesSize = static_cast(nodeValues.size()); + jfloatArray result = env->NewFloatArray(nodeValuesSize); + + std::optional focusDirection = + FocusOrderingHelper::resolveFocusDirection(direction); + + if (!focusDirection.has_value()) { + return nullptr; + } + + ShadowNode::Shared parentShadowNode; + ShadowNode::Shared focusedShadowNode; + std::shared_ptr uimanager = getScheduler()->getUIManager(); + + parentShadowNode = uimanager->findShadowNodeByTag_DEPRECATED(parentTag); + focusedShadowNode = FocusOrderingHelper::findShadowNodeByTagRecursively( + parentShadowNode, focusedTag); + + LayoutMetrics childLayoutMetrics = uimanager->getRelativeLayoutMetrics( + *focusedShadowNode, parentShadowNode.get(), {.includeTransform = true}); + + /* + * Set currentDelta to the origin of the focused node + * Set nextDelta to the max int value in the direction we want to go (down = + * INT_MIN, up = INT_MAX) + */ + auto [currentDelta, nextDelta] = FocusOrderingHelper::initScrollDeltas( + focusDirection.value(), childLayoutMetrics.frame.origin); + + /* + * Traverse the tree recursively to find the next focusable element in the + * given direction + */ + FocusOrderingHelper::traverseAndUpdateNextFocusableElementMetrics( + parentShadowNode, + focusedShadowNode, + parentShadowNode, + focusDirection.value(), + *uimanager, + currentDelta, + nextDelta, + nextNode); + + if (nextNode == nullptr) { + return nullptr; + } + + LayoutMetrics nextNodeLayoutMetrics = uimanager->getRelativeLayoutMetrics( + *nextNode, parentShadowNode.get(), {.includeTransform = true}); + + LayoutMetrics parentNodeLayoutMetrics = uimanager->getRelativeLayoutMetrics( + *parentShadowNode, nullptr, {.includeTransform = true}); + + nodeValues[0] = static_cast(nextNode->getTag()); + + /* + * In the end we want to scroll enough to make the next node visible + * When going up or right we want delta to be the origin of the next node + * When going down or left we want delta to be the end of the next node + */ + switch (focusDirection.value()) { + case FocusDirection::FocusDown: + nextDelta = (nextNodeLayoutMetrics.frame.origin.y - + parentNodeLayoutMetrics.frame.size.height) + + nextNodeLayoutMetrics.frame.size.height; + nodeValues[1] = nextDelta; + break; + case FocusDirection::FocusRight: + nextDelta = (nextNodeLayoutMetrics.frame.origin.x - + parentNodeLayoutMetrics.frame.size.width) + + nextNodeLayoutMetrics.frame.size.width; + nodeValues[1] = nextDelta; + break; + case FocusDirection::FocusLeft: + case FocusDirection::FocusUp: + nodeValues[1] = nextDelta; + break; + } + + // Build the jfloatArray with the updates nodeValues + env->SetFloatArrayRegion(result, 0, nodeValuesSize, nodeValues.data()); + + return result; +} + // Used by non-bridgeless+Fabric void FabricUIManagerBinding::startSurfaceWithConstraints( jint surfaceId, @@ -656,6 +757,9 @@ void FabricUIManagerBinding::registerNatives() { makeNativeMethod( "stopSurfaceWithSurfaceHandler", FabricUIManagerBinding::stopSurfaceWithSurfaceHandler), + makeNativeMethod( + "findNextFocusableElementMetrics", + FabricUIManagerBinding::findNextFocusableElementMetrics), }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h index 50c234eca5f..7d3b2303837 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.h @@ -128,6 +128,11 @@ class FabricUIManagerBinding : public jni::HybridClass, void reportMount(SurfaceId surfaceId); + jfloatArray findNextFocusableElementMetrics( + jint parentTag, + jint focusedTag, + jint direction); + void uninstallFabricUIManager(); // Private member variables diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp new file mode 100644 index 00000000000..51abc61c8f8 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -0,0 +1,139 @@ +/* + * 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. + */ + +#include "FocusOrderingHelper.h" +#include + +namespace facebook::react { + +void FocusOrderingHelper::traverseAndUpdateNextFocusableElementMetrics( + const ShadowNode::Shared& parentShadowNode, + const ShadowNode::Shared& focusedShadowNode, + const ShadowNode::Shared& currNode, + FocusDirection focusDirection, + const UIManager& uimanager, + float& currentDelta, + float& nextDelta, + ShadowNode::Shared& nextNode) { + const auto* props = + dynamic_cast(currNode->getProps().get()); + + // We only care about focusable elements since only they can be both + // focused and present in the hierarchy + if (props != nullptr && props->focusable) { + LayoutMetrics nodeLayoutMetrics = uimanager.getRelativeLayoutMetrics( + *currNode, parentShadowNode.get(), {.includeTransform = true}); + + switch (focusDirection) { + case FocusDirection::FocusDown: + if (nodeLayoutMetrics.frame.origin.y > currentDelta && + nodeLayoutMetrics.frame.origin.y < nextDelta && + currNode != focusedShadowNode) { + nextNode = currNode; + nextDelta = nodeLayoutMetrics.frame.origin.y; + } + break; + case FocusDirection::FocusUp: + if (nodeLayoutMetrics.frame.origin.y < currentDelta && + nodeLayoutMetrics.frame.origin.y > nextDelta && + currNode != focusedShadowNode) { + nextNode = currNode; + nextDelta = nodeLayoutMetrics.frame.origin.y; + } + break; + case FocusDirection::FocusRight: + if (nodeLayoutMetrics.frame.origin.x > currentDelta && + nodeLayoutMetrics.frame.origin.x < nextDelta && + currNode != focusedShadowNode) { + nextNode = currNode; + nextDelta = nodeLayoutMetrics.frame.origin.x; + } + break; + case FocusDirection::FocusLeft: + if (nodeLayoutMetrics.frame.origin.x < currentDelta && + nodeLayoutMetrics.frame.origin.x > nextDelta && + currNode != focusedShadowNode) { + nextNode = currNode; + nextDelta = nodeLayoutMetrics.frame.origin.x; + } + break; + } + } + + for (auto& child : currNode->getChildren()) { + if (child->getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) { + continue; + } + + traverseAndUpdateNextFocusableElementMetrics( + parentShadowNode, + focusedShadowNode, + child, + focusDirection, + uimanager, + currentDelta, + nextDelta, + nextNode); + }; +}; + +ShadowNode::Shared FocusOrderingHelper::findShadowNodeByTagRecursively( + const ShadowNode::Shared& parentShadowNode, + Tag tag) { + if (parentShadowNode->getTag() == tag) { + return parentShadowNode; + } + + for (auto& shadowNode : parentShadowNode->getChildren()) { + if (auto result = findShadowNodeByTagRecursively(shadowNode, tag)) { + return result; + } + } + + return nullptr; +} + +std::optional FocusOrderingHelper::resolveFocusDirection( + int direction) { + switch (static_cast(direction)) { + case FocusDirection::FocusDown: + case FocusDirection::FocusUp: + case FocusDirection::FocusRight: + case FocusDirection::FocusLeft: + return static_cast(direction); + } + + return std::nullopt; +} + +std::tuple FocusOrderingHelper::initScrollDeltas( + FocusDirection focusDirection, + Point refPoint) { + float currentDelta = 0; + float nextDelta = 0; + + switch (focusDirection) { + case FocusDirection::FocusDown: + currentDelta = refPoint.y; + nextDelta = std::numeric_limits::max(); + break; + case FocusDirection::FocusUp: + currentDelta = refPoint.y; + nextDelta = -std::numeric_limits::max(); + break; + case FocusDirection::FocusRight: + currentDelta = refPoint.x; + nextDelta = std::numeric_limits::max(); + break; + case FocusDirection::FocusLeft: + currentDelta = refPoint.x; + nextDelta = -std::numeric_limits::max(); + break; + } + return std::make_tuple(currentDelta, nextDelta); +} +} // namespace facebook::react diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h new file mode 100644 index 00000000000..65167d54ce8 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h @@ -0,0 +1,42 @@ +/* + * 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. + */ + +#include +#include "FabricUIManagerBinding.h" + +namespace facebook::react { + +enum class FocusDirection { + FocusDown = 0, + FocusUp = 1, + FocusRight = 2, + FocusLeft = 3, +}; + +class FocusOrderingHelper { + public: + static void traverseAndUpdateNextFocusableElementMetrics( + const ShadowNode::Shared& parentShadowNode, + const ShadowNode::Shared& focusedShadowNode, + const ShadowNode::Shared& currNode, + FocusDirection focusDirection, + const UIManager& uimanager, + float& currentDelta, + float& nextDelta, + ShadowNode::Shared& nextNode); + + static ShadowNode::Shared findShadowNodeByTagRecursively( + const ShadowNode::Shared& parentShadowNode, + Tag tag); + + static std::optional resolveFocusDirection(int direction); + + static std::tuple initScrollDeltas( + FocusDirection focusDirection, + Point refPoint); +}; +} // namespace facebook::react