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