From c8a9b9d342bb72f8ad41fd6b16b1c375dd5cf7df Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Mon, 14 Apr 2025 18:08:36 -0700 Subject: [PATCH] Add fabric implementation to find next focusable view (#50196) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/50196 Currently when `removeClippedSubviews` is enabled on Android keyboard navigation breaks and we can never focus the elements that are clipped. iOS has a similar issue but not as drastic, it only happens when elements on the FlatList have a lot of margin between them. This algorithm aims to find the next focusable view and return it to native so that we can prevent the clipping of the view on the view clipping algorithm and hence fix keyboard navigation. For more information see D71324219 Fabric algorithm to find the next focusable view given: `parentTag`: Top most relevant parent of the focused view `focusedTag`: Tag of the currently focused view `direction`: Direction in which focus is moving Changelog: [Internal] Reviewed By: joevilches Differential Revision: D71558965 fbshipit-source-id: 1a13c82d067834337e7f7936860780f467c9a15d --- .../react/fabric/FabricUIManagerBinding.kt | 2 + .../react/fabric/FabricUIManagerBinding.cpp | 52 +++++ .../jni/react/fabric/FabricUIManagerBinding.h | 3 + .../jni/react/fabric/FocusOrderingHelper.cpp | 186 ++++++++++++++++++ .../jni/react/fabric/FocusOrderingHelper.h | 38 ++++ 5 files changed, 281 insertions(+) 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/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt index 7b9f9ee4500..06ee1e4f503 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,8 @@ internal class FabricUIManagerBinding : HybridClassBase() { isMountable: Boolean ) + external fun findNextFocusableElement(parentTag: Int, focusedTag: Int, direction: Int): Int + external fun stopSurface(surfaceId: Int) external fun stopSurfaceWithSurfaceHandler(surfaceHandler: SurfaceHandlerBinding) 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 e3224ffac39..5473ceb47d5 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 @@ -11,6 +11,7 @@ #include "ComponentFactory.h" #include "EventBeatManager.h" #include "FabricMountingManager.h" +#include "FocusOrderingHelper.h" #include #include @@ -195,6 +196,54 @@ void FabricUIManagerBinding::startSurface( } } +jint FabricUIManagerBinding::findNextFocusableElement( + jint parentTag, + jint focusedTag, + jint direction) { + ShadowNode::Shared nextNode; + + std::optional focusDirection = + FocusOrderingHelper::resolveFocusDirection(direction); + + if (!focusDirection.has_value()) { + return -1; + } + + std::shared_ptr uimanager = getScheduler()->getUIManager(); + + ShadowNode::Shared parentShadowNode = + uimanager->findShadowNodeByTag_DEPRECATED(parentTag); + ShadowNode::Shared focusedShadowNode = + FocusOrderingHelper::findShadowNodeByTagRecursively( + parentShadowNode, focusedTag); + + LayoutMetrics childLayoutMetrics = uimanager->getRelativeLayoutMetrics( + *focusedShadowNode, parentShadowNode.get(), {.includeTransform = true}); + + Rect sourceRect = childLayoutMetrics.frame; + + /* + * Traverse the tree recursively to find the next focusable element in the + * given direction + */ + std::optional nextRect = std::nullopt; + FocusOrderingHelper::traverseAndUpdateNextFocusableElement( + parentShadowNode, + focusedShadowNode, + parentShadowNode, + focusDirection.value(), + *uimanager, + sourceRect, + nextRect, + nextNode); + + if (nextNode == nullptr) { + return -1; + } + + return nextNode->getTag(); +} + // Used by non-bridgeless+Fabric void FabricUIManagerBinding::startSurfaceWithConstraints( jint surfaceId, @@ -667,6 +716,9 @@ void FabricUIManagerBinding::registerNatives() { makeNativeMethod( "stopSurfaceWithSurfaceHandler", FabricUIManagerBinding::stopSurfaceWithSurfaceHandler), + makeNativeMethod( + "findNextFocusableElement", + FabricUIManagerBinding::findNextFocusableElement), }); } 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 c3c404c9990..fd298701562 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 @@ -132,6 +132,9 @@ class FabricUIManagerBinding : public jni::HybridClass, void reportMount(SurfaceId surfaceId); + jint + findNextFocusableElement(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..e30b1e2c89a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -0,0 +1,186 @@ +/* + * 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 +#include + +namespace facebook::react { + +int majorAxisDistanceRaw( + FocusDirection focusDirection, + Rect source, + Rect dest) { + switch (focusDirection) { + case FocusDirection::FocusLeft: + return static_cast( + source.origin.x - (dest.origin.x + dest.size.width)); + case FocusDirection::FocusRight: + return static_cast( + dest.origin.x - (source.origin.x + source.size.width)); + case FocusDirection::FocusUp: + return static_cast( + source.origin.y - (dest.origin.y + dest.size.height)); + case FocusDirection::FocusDown: + return static_cast( + dest.origin.y - (source.origin.y + source.size.height)); + } +} + +int majorAxisDistance(FocusDirection focusDirection, Rect source, Rect dest) { + return std::max(0, majorAxisDistanceRaw(focusDirection, source, dest)); +} + +int minorAxisDistance(FocusDirection direction, Rect source, Rect dest) { + switch (direction) { + case FocusDirection::FocusLeft: + case FocusDirection::FocusRight: + // the distance between the center verticals + return static_cast(abs((source.getMidY() - dest.getMidY()))); + case FocusDirection::FocusUp: + case FocusDirection::FocusDown: + // the distance between the center horizontals + return static_cast(abs((source.getMidX() - dest.getMidX()))); + } +} + +// 13 is a magic number that comes from Android's implementation. We opt to use +// this to get the same focus ordering as Android. See: +// https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/FocusFinder.java;l=547 +int getWeightedDistanceFor(int majorAxisDistance, int minorAxisDistance) { + return 13 * majorAxisDistance * majorAxisDistance + + minorAxisDistance * minorAxisDistance; +} + +// Make sure dest rect is actually on the direction of focus +bool isCandidate(Rect source, Rect dest, FocusDirection focusDirection) { + switch (focusDirection) { + case FocusDirection::FocusLeft: + return ((source.origin.x + source.size.width) > + (dest.origin.x + dest.size.width) || + source.origin.x >= (dest.origin.x + dest.size.width)) && + source.origin.x > dest.origin.x; + case FocusDirection::FocusRight: + return (source.origin.x < dest.origin.x || + (source.origin.x + source.size.width) <= dest.origin.x) && + (source.origin.x + source.size.width) < + (dest.origin.x + dest.size.width); + case FocusDirection::FocusUp: + return ((source.origin.y + source.size.height) > + (dest.origin.y + dest.size.height) || + source.origin.y >= (dest.origin.y + dest.size.height)) && + source.origin.y > dest.origin.y; + case FocusDirection::FocusDown: + return (source.origin.y < dest.origin.y || + (source.origin.y + source.size.height) <= dest.origin.y) && + ((source.origin.y + source.size.height) < + (dest.origin.y + dest.size.height)); + } +} + +bool isBetterCandidate( + FocusDirection focusDirection, + Rect source, + Rect current, + Rect candidate) { + if (!isCandidate(source, candidate, focusDirection)) { + return false; + } + + int candidateWeightedDistance = getWeightedDistanceFor( + majorAxisDistance(focusDirection, source, candidate), + minorAxisDistance(focusDirection, source, candidate)); + + int currentWeightedDistance = getWeightedDistanceFor( + majorAxisDistance(focusDirection, source, current), + minorAxisDistance(focusDirection, source, current)); + + return candidateWeightedDistance < currentWeightedDistance; +} + +void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( + const ShadowNode::Shared& parentShadowNode, + const ShadowNode::Shared& focusedShadowNode, + const ShadowNode::Shared& currNode, + FocusDirection focusDirection, + const UIManager& uimanager, + Rect sourceRect, + std::optional& nextRect, + 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 (currNode->getTraits().check(ShadowNodeTraits::Trait::KeyboardFocusable) || + (props != nullptr && + (props->focusable || props->accessible || props->hasTVPreferredFocus))) { + LayoutMetrics nodeLayoutMetrics = uimanager.getRelativeLayoutMetrics( + *currNode, parentShadowNode.get(), {.includeTransform = true}); + + if (nextRect == std::nullopt && + isCandidate(sourceRect, nodeLayoutMetrics.frame, focusDirection)) { + nextNode = currNode; + nextRect = nodeLayoutMetrics.frame; + } else if ( + nextRect != std::nullopt && + isBetterCandidate( + focusDirection, + sourceRect, + nextRect.value(), + nodeLayoutMetrics.frame)) { + nextNode = currNode; + nextRect = nodeLayoutMetrics.frame; + } + } + + for (auto& child : currNode->getChildren()) { + if (child->getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) { + continue; + } + + traverseAndUpdateNextFocusableElement( + parentShadowNode, + focusedShadowNode, + child, + focusDirection, + uimanager, + sourceRect, + nextRect, + 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; +} +} // 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..97eeba7d1ea --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h @@ -0,0 +1,38 @@ +/* + * 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 traverseAndUpdateNextFocusableElement( + const ShadowNode::Shared& parentShadowNode, + const ShadowNode::Shared& focusedShadowNode, + const ShadowNode::Shared& currNode, + FocusDirection focusDirection, + const UIManager& uimanager, + Rect sourceRect, + std::optional& nextRect, + ShadowNode::Shared& nextNode); + + static ShadowNode::Shared findShadowNodeByTagRecursively( + const ShadowNode::Shared& parentShadowNode, + Tag tag); + + static std::optional resolveFocusDirection(int direction); +}; +} // namespace facebook::react