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 d5c154f1e6f1..8fbebe71c932 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 @@ -300,6 +300,13 @@ jint FabricUIManagerBinding::findNextFocusableElement( return -1; } + if (focusDirection == FocusDirection::FocusForward || + focusDirection == FocusDirection::FocusBackward) { + nextNode = FocusOrderingHelper::findNextInTabOrder( + *parentShadowNode, focusedTag, focusDirection.value()); + return nextNode == nullptr ? -1 : nextNode->getTag(); + } + LayoutMetrics childLayoutMetrics = uimanager->getRelativeLayoutMetrics( *focusedShadowNode, parentShadowNode.get(), {.includeTransform = true}); 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 index 1e4e04c49d9c..44e1da77d830 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -8,6 +8,9 @@ #include "FocusOrderingHelper.h" #include +#include +#include + namespace facebook::react { int majorAxisDistanceRaw( @@ -84,24 +87,11 @@ bool isCandidate(Rect source, Rect dest, FocusDirection focusDirection) { (source.origin.y + source.size.height) <= dest.origin.y) && ((source.origin.y + source.size.height) < (dest.origin.y + dest.size.height)); + // Forward and backward follow the tab order instead, see + // FocusOrderingHelper::findNextInTabOrder case FocusDirection::FocusForward: - 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))) || - ((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::FocusBackward: - 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) || - (((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); + return false; } } @@ -131,65 +121,148 @@ bool isBetterCandidate( minorAxisDistance(focusDirection, source, current)); return candidateWeightedDistance < currentWeightedDistance; - /* - * For forward and backward, Android uses a different algorithm to tell the - * order in which to focus. It sorts the children of the root by top to - * bottom and then groups the views into "rows". Then, it looks for the - * index of the current focused, and focuses the next or previous element. - * - * We are not implementing this algorithm, but instead, we are using the - * same comparison logic android uses to sort the children to find the next - * focusable. - * - * See: - * https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/FocusFinder.java;l=833;drc=61197364367c9e404c7da6900658f1b16c42d0da - */ - case FocusDirection::FocusForward: { - Float currentRowBottom = source.origin.y + source.size.height; - Float candidateRowBottom = candidate.origin.y + candidate.size.height; - Float result = NAN; - - if (candidate.origin.y < currentRowBottom && - candidateRowBottom > source.origin.y) { - result = candidate.origin.x - current.origin.x; - if (result == 0.0) { - result = (candidate.origin.x + candidate.size.width) - - (current.origin.x + current.size.width); - } - return result < 0.0; - } else { - result = candidate.origin.y - current.origin.y; - if (result == 0.0) { - result = (candidate.origin.y + candidate.size.height) - - (current.origin.y + current.size.height); - } - return result < 0.0; - } + case FocusDirection::FocusForward: + case FocusDirection::FocusBackward: + return false; + } + return false; +} + +bool isKeyboardFocusable(const ShadowNode& shadowNode) { + const auto* props = + dynamic_cast(shadowNode.getProps().get()); + + return shadowNode.getTraits().check( + ShadowNodeTraits::Trait::KeyboardFocusable) || + (props != nullptr && (props->focusable || props->accessible)); +} + +struct NativeChild { + std::shared_ptr shadowNode; + // In the coordinate space of the native parent + Rect frame; + bool hostsItsChildren; +}; + +// The views a native parent hosts, as the mounting layer flattens them: a +// child that does not form a view is skipped, and the children of one that +// does not form a stacking context are hoisted into the parent. +void appendNativeChildren( + const ShadowNode& parentShadowNode, + Point parentOffset, + std::vector& nativeChildren) { + bool childrenFormStackingContexts = parentShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ChildrenFormStackingContext); + + for (const auto& childShadowNode : parentShadowNode.getChildren()) { + const auto& traits = childShadowNode->getTraits(); + const auto* layoutableShadowNode = + dynamic_cast(childShadowNode.get()); + + if (traits.check(ShadowNodeTraits::Trait::RootNodeKind) || + traits.check(ShadowNodeTraits::Trait::Hidden) || + layoutableShadowNode == nullptr) { + continue; } - case FocusDirection::FocusBackward: { - Float currentRowBottom = source.origin.y + source.size.height; - Float candidateRowBottom = candidate.origin.y + candidate.size.height; - Float result = NAN; - - if (candidate.origin.y < currentRowBottom && - candidateRowBottom > source.origin.y) { - result = current.origin.x - candidate.origin.x; - if (result == 0.0) { - result = (current.origin.x + current.size.width) - - (candidate.origin.x + candidate.size.width); - } - return result < 0.0; - } else { - result = current.origin.y - candidate.origin.y; - if (result == 0.0) { - result = (current.origin.y + current.size.height) - - (candidate.origin.y + candidate.size.height); + + Rect frame = layoutableShadowNode->getLayoutMetrics().frame; + frame.origin += parentOffset; + + bool forceFlattenView = + traits.check(ShadowNodeTraits::Trait::ForceFlattenView); + bool isConcreteView = (traits.check(ShadowNodeTraits::Trait::FormsView) || + childrenFormStackingContexts) && + !forceFlattenView; + bool areChildrenFlattened = + (!traits.check(ShadowNodeTraits::Trait::FormsStackingContext) && + !childrenFormStackingContexts) || + forceFlattenView; + + if (isConcreteView) { + nativeChildren.push_back( + {.shadowNode = childShadowNode, + .frame = frame, + .hostsItsChildren = !areChildrenFlattened}); + } + + if (areChildrenFlattened) { + appendNativeChildren(*childShadowNode, frame.origin, nativeChildren); + } + } +} + +/* + * Sorts siblings the way Android's FocusFinder.FocusSorter does: top to bottom, + * then each run of vertically overlapping views forms a row, sorted by its + * start edge. + * + * See: + * https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/FocusFinder.java;l=833;drc=61197364367c9e404c7da6900658f1b16c42d0da + */ +void sortInTabOrder(std::vector& nativeChildren, bool isRTL) { + if (nativeChildren.size() < 2) { + return; + } + + auto sortRow = [isRTL](auto rowBegin, auto rowEnd) { + std::stable_sort( + rowBegin, rowEnd, [isRTL](const auto& first, const auto& second) { + if (first.frame.getMinX() != second.frame.getMinX()) { + return isRTL ? first.frame.getMinX() > second.frame.getMinX() + : first.frame.getMinX() < second.frame.getMinX(); + } + return first.frame.getMaxX() < second.frame.getMaxX(); + }); + }; + + std::stable_sort( + nativeChildren.begin(), + nativeChildren.end(), + [](const auto& first, const auto& second) { + if (first.frame.getMinY() != second.frame.getMinY()) { + return first.frame.getMinY() < second.frame.getMinY(); } - return result < 0.0; - } + return first.frame.getMaxY() < second.frame.getMaxY(); + }); + + auto rowBegin = nativeChildren.begin(); + Float sweepBottom = rowBegin->frame.getMaxY(); + for (auto it = std::next(nativeChildren.begin()); it != nativeChildren.end(); + ++it) { + if (it->frame.getMinY() >= sweepBottom) { + sortRow(rowBegin, it); + rowBegin = it; + sweepBottom = it->frame.getMaxY(); + } else { + sweepBottom = std::max(sweepBottom, it->frame.getMaxY()); + } + } + sortRow(rowBegin, nativeChildren.end()); +} + +bool isLayoutRTL(const ShadowNode& shadowNode) { + const auto* layoutableShadowNode = + dynamic_cast(&shadowNode); + return layoutableShadowNode != nullptr && + layoutableShadowNode->getLayoutMetrics().layoutDirection == + LayoutDirection::RightToLeft; +} + +void appendFocusablesInTabOrder( + const ShadowNode& parentShadowNode, + std::vector>& focusables) { + std::vector nativeChildren; + appendNativeChildren(parentShadowNode, Point{}, nativeChildren); + sortInTabOrder(nativeChildren, isLayoutRTL(parentShadowNode)); + + for (const auto& nativeChild : nativeChildren) { + if (isKeyboardFocusable(*nativeChild.shadowNode)) { + focusables.push_back(nativeChild.shadowNode); + } + if (nativeChild.hostsItsChildren) { + appendFocusablesInTabOrder(*nativeChild.shadowNode, focusables); } } - return false; } void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( @@ -201,13 +274,9 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( Rect sourceRect, std::optional& nextRect, std::shared_ptr& 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))) { + if (isKeyboardFocusable(*currNode)) { LayoutMetrics nodeLayoutMetrics = uimanager.getRelativeLayoutMetrics( *currNode, parentShadowNode.get(), {.includeTransform = true}); @@ -244,6 +313,44 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( }; }; +std::vector> +FocusOrderingHelper::collectFocusablesInTabOrder( + const ShadowNode& rootShadowNode) { + std::vector> focusables; + appendFocusablesInTabOrder(rootShadowNode, focusables); + return focusables; +} + +std::shared_ptr FocusOrderingHelper::findNextInTabOrder( + const ShadowNode& rootShadowNode, + Tag focusedTag, + FocusDirection focusDirection) { + auto focusables = collectFocusablesInTabOrder(rootShadowNode); + auto focused = std::find_if( + focusables.begin(), focusables.end(), [focusedTag](const auto& node) { + return node->getTag() == focusedTag; + }); + + if (focused == focusables.end()) { + return nullptr; + } + + switch (focusDirection) { + case FocusDirection::FocusForward: + return std::next(focused) == focusables.end() ? nullptr + : *std::next(focused); + case FocusDirection::FocusBackward: + return focused == focusables.begin() ? nullptr : *std::prev(focused); + case FocusDirection::FocusDown: + case FocusDirection::FocusUp: + case FocusDirection::FocusRight: + case FocusDirection::FocusLeft: + return nullptr; + } + + return nullptr; +} + std::shared_ptr FocusOrderingHelper::findShadowNodeByTagRecursively( const std::shared_ptr& parentShadowNode, 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 index 802ba1699785..2c857faf2bc6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.h @@ -31,6 +31,21 @@ class FocusOrderingHelper { std::optional &nextRect, std::shared_ptr &nextNode); + /* + * The focusable descendants of `rootShadowNode` in the order Android's + * FocusFinder gives FOCUS_FORWARD: each native parent sorts the views it + * hosts into rows by top edge, then by start edge, and lists itself before + * them. + */ + static std::vector> collectFocusablesInTabOrder(const ShadowNode &rootShadowNode); + + /* + * The focusable after (FocusForward) or before (FocusBackward) the one + * tagged `focusedTag` in that order, or nullptr past either end. + */ + static std::shared_ptr + findNextInTabOrder(const ShadowNode &rootShadowNode, Tag focusedTag, FocusDirection focusDirection); + static std::shared_ptr findShadowNodeByTagRecursively( const std::shared_ptr &parentShadowNode, Tag tag); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp new file mode 100644 index 000000000000..88a7abf1e370 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/FocusOrderingHelperTabOrderTest.cpp @@ -0,0 +1,234 @@ +/* + * 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 +#include +#include +#include +#include + +#include +#include +#include + +namespace facebook::react { + +namespace { + +enum class ViewKind { + // Forms no view, so the mounting layer hoists its children into its parent + Flattened, + // Hosts its children, as `collapsable={false}` does + Native, + // Takes keyboard focus and hosts its children, as a Pressable does + Focusable, +}; + +Element view( + Tag tag, + ViewKind kind, + Rect frame, + LayoutDirection layoutDirection = LayoutDirection::LeftToRight) { + return Element() + .tag(tag) + .props([kind] { + auto props = std::make_shared(); + props->collapsable = kind == ViewKind::Flattened; + props->accessible = kind == ViewKind::Focusable; + return props; + }) + .finalize([frame, layoutDirection](ViewShadowNode& shadowNode) { + auto layoutMetrics = EmptyLayoutMetrics; + layoutMetrics.frame = frame; + layoutMetrics.layoutDirection = layoutDirection; + shadowNode.setLayoutMetrics(layoutMetrics); + }); +} + +Rect frame(Float x, Float y, Float width, Float height) { + return Rect{ + .origin = {.x = x, .y = y}, .size = {.width = width, .height = height}}; +} + +std::vector tabOrderTags(const ShadowNode& rootShadowNode) { + std::vector tags; + for (const auto& focusable : + FocusOrderingHelper::collectFocusablesInTabOrder(rootShadowNode)) { + tags.push_back(focusable->getTag()); + } + return tags; +} + +std::optional nextTagInTabOrder( + const ShadowNode& rootShadowNode, + Tag focusedTag, + FocusDirection focusDirection) { + auto next = FocusOrderingHelper::findNextInTabOrder( + rootShadowNode, focusedTag, focusDirection); + return next == nullptr ? std::nullopt : std::optional(next->getTag()); +} + +} // namespace + +TEST(FocusOrderingHelperTabOrderTest, testRowsGoTopToBottomThenByLeftEdge) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(200, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + view(4, ViewKind::Focusable, frame(0, 0, 80, 40)), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 2, 3}), tabOrderTags(*root)); +} + +TEST(FocusOrderingHelperTabOrderTest, testRightToLeftRowStartsAtTheRightEdge) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300), LayoutDirection::RightToLeft).children({ + view(2, ViewKind::Focusable, frame(200, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + view(4, ViewKind::Focusable, frame(0, 0, 80, 40)), + })); + // clang-format on + + EXPECT_EQ(std::vector({2, 4, 3}), tabOrderTags(*root)); +} + +TEST(FocusOrderingHelperTabOrderTest, testRowGrowsWhileViewsOverlapIt) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 0, 50, 100)), + view(3, ViewKind::Focusable, frame(100, 60, 50, 20)), + view(4, ViewKind::Focusable, frame(60, 90, 30, 30)), + })); + // clang-format on + + EXPECT_EQ(std::vector({2, 4, 3}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testViewThatHostsItsChildrenIsOrderedAsOneBlock) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Native, frame(0, 0, 100, 100)).children({ + view(3, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(4, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + view(5, ViewKind::Native, frame(120, 0, 100, 100)).children({ + view(6, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(7, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({3, 4, 6, 7}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testFlattenedViewLeavesItsChildrenToItsParentsRows) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Flattened, frame(0, 0, 100, 100)).children({ + view(3, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(4, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + view(5, ViewKind::Flattened, frame(120, 0, 100, 100)).children({ + view(6, ViewKind::Focusable, frame(0, 0, 100, 40)), + view(7, ViewKind::Focusable, frame(0, 50, 100, 40)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({3, 6, 4, 7}), tabOrderTags(*root)); +} + +TEST( + FocusOrderingHelperTabOrderTest, + testFocusableViewComesBeforeTheViewsItHosts) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 100, 300, 100)).children({ + view(3, ViewKind::Focusable, frame(10, 10, 50, 30)), + }), + view(4, ViewKind::Focusable, frame(0, 0, 300, 50)), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 2, 3}), tabOrderTags(*root)); +} + +/* + * A card whose content column holds an action below its title, with a dismiss + * button at the top end: Android puts the action first, so Tab from the + * dismiss button leaves the card instead of returning to the action below it. + */ +TEST( + FocusOrderingHelperTabOrderTest, + testTabFromTheLastFocusableLeavesInsteadOfReturningToOneBelowIt) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Native, frame(0, 0, 300, 100)).children({ + view(3, ViewKind::Native, frame(0, 0, 240, 100)).children({ + view(4, ViewKind::Focusable, frame(10, 60, 100, 30)), + }), + view(5, ViewKind::Focusable, frame(260, 10, 30, 30)), + }), + })); + // clang-format on + + EXPECT_EQ(std::vector({4, 5}), tabOrderTags(*root)); + EXPECT_EQ( + std::optional(5), + nextTagInTabOrder(*root, 4, FocusDirection::FocusForward)); + EXPECT_EQ( + std::optional(4), + nextTagInTabOrder(*root, 5, FocusDirection::FocusBackward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 5, FocusDirection::FocusForward)); +} + +TEST(FocusOrderingHelperTabOrderTest, testNextInTabOrderStopsAtEitherEnd) { + auto builder = simpleComponentBuilder(); + // clang-format off + auto root = builder.build( + view(1, ViewKind::Native, frame(0, 0, 300, 300)).children({ + view(2, ViewKind::Focusable, frame(0, 0, 80, 40)), + view(3, ViewKind::Focusable, frame(0, 100, 80, 40)), + })); + // clang-format on + + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 2, FocusDirection::FocusBackward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 3, FocusDirection::FocusForward)); + EXPECT_EQ( + std::optional(), + nextTagInTabOrder(*root, 9, FocusDirection::FocusForward)); +} + +} // namespace facebook::react diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index ef937f99089c..5317ba2c88f5 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -554,6 +554,15 @@ if (Platform.OS === 'ios') { return ; }, }); + examples.push({ + name: 'keyboardFocusOrder', + title: ' Keyboard focus order\n', + description: + 'Tab through the card with a hardware keyboard: after its last control, focus should leave the scroll view for the button below it.', + render(): React.Node { + return ; + }, + }); } exports.examples = examples; @@ -611,6 +620,25 @@ const ScrollsChildToFocusExample = () => { ); }; +const KeyboardFocusOrderExample = () => { + return ( + + + + + A card with an action below its title +