Skip to content

Commit c068c59

Browse files
jorge-cabfacebook-github-bot
authored andcommitted
Fix keyboard navigation for FlatList with removeClippedSubviews enabled (facebook#49543)
Summary: Pull Request resolved: facebook#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
1 parent 81405b4 commit c068c59

11 files changed

+478
-0
lines changed

packages/react-native/ReactAndroid/api/ReactAndroid.api

+12
Original file line numberDiff line numberDiff line change
@@ -2396,6 +2396,7 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid
23962396
public fun dispatchCommand (IILcom/facebook/react/bridge/ReadableArray;)V
23972397
public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
23982398
public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
2399+
public fun findNextFocusableElementMetrics (III)Lcom/facebook/react/fabric/NextFocusableNode;
23992400
public fun getColor (I[Ljava/lang/String;)I
24002401
public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher;
24012402
public fun getPerformanceCounters ()Ljava/util/Map;
@@ -2435,6 +2436,12 @@ public final class com/facebook/react/fabric/FabricUIManagerProviderImpl : com/f
24352436
public fun createUIManager (Lcom/facebook/react/bridge/ReactApplicationContext;)Lcom/facebook/react/bridge/UIManager;
24362437
}
24372438

2439+
public final class com/facebook/react/fabric/NextFocusableNode {
2440+
public fun <init> (IF)V
2441+
public final fun getDeltaScroll ()F
2442+
public final fun getId ()I
2443+
}
2444+
24382445
public class com/facebook/react/fabric/StateWrapperImpl : com/facebook/jni/HybridClassBase, com/facebook/react/uimanager/StateWrapper {
24392446
public fun destroyState ()V
24402447
public fun getStateData ()Lcom/facebook/react/bridge/ReadableNativeMap;
@@ -6198,6 +6205,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
61986205
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
61996206
public fun flashScrollIndicators ()V
62006207
public fun fling (I)V
6208+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
62016209
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
62026210
public fun getClippingRect (Landroid/graphics/Rect;)V
62036211
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -6320,6 +6328,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
63206328
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
63216329
public fun flashScrollIndicators ()V
63226330
public fun fling (I)V
6331+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
63236332
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
63246333
public fun getClippingRect (Landroid/graphics/Rect;)V
63256334
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -6440,6 +6449,8 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
64406449
public static final fun emitScrollEvent (Landroid/view/ViewGroup;FF)V
64416450
public static final fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V
64426451
public static final fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V
6452+
public static final fun findNextClippedElement (Landroid/view/ViewGroup;Landroid/view/View;ILcom/facebook/react/bridge/ReactContext;)Landroid/view/View;
6453+
public static final fun findNextFocusableView (Landroid/view/ViewGroup;Landroid/view/View;IZ)Landroid/view/View;
64436454
public static final fun forceUpdateState (Landroid/view/ViewGroup;)V
64446455
public static final fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I
64456456
public static final fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I
@@ -6449,6 +6460,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
64496460
public final fun registerFlingAnimator (Landroid/view/ViewGroup;)V
64506461
public static final fun removeLayoutChangeListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$LayoutChangeListener;)V
64516462
public static final fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V
6463+
public static final fun resolveAbsoluteDirection (IZI)I
64526464
public static final fun smoothScrollTo (Landroid/view/ViewGroup;II)V
64536465
public static final fun updateFabricScrollState (Landroid/view/ViewGroup;)V
64546466
public final fun updateFabricScrollState (Landroid/view/ViewGroup;II)V

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java

+46
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import androidx.annotation.NonNull;
3030
import androidx.annotation.Nullable;
3131
import androidx.annotation.UiThread;
32+
import androidx.core.view.ViewCompat.FocusRealDirection;
3233
import com.facebook.common.logging.FLog;
3334
import com.facebook.infer.annotation.ThreadConfined;
3435
import com.facebook.proguard.annotations.DoNotStripAny;
@@ -260,6 +261,51 @@ public <T extends View> int addRootView(final T rootView, final WritableMap init
260261
return rootTag;
261262
}
262263

264+
/**
265+
* Find the next focusable element's id and position relative to the parent from the shadow tree
266+
* based on the current focusable element and the direction.
267+
*
268+
* @return A NextFocusableNode object where the 'id' is the reactId/Tag of the next focusable
269+
* view, and 'deltaScroll' is the scroll delta needed to make the view visible on the screen.
270+
* Returns null if no valid node is found.
271+
*/
272+
public @Nullable NextFocusableNode findNextFocusableElementMetrics(
273+
int parentTag, int focusedTag, @FocusRealDirection int direction) {
274+
if (mBinding == null) {
275+
return null;
276+
}
277+
278+
int generalizedDirection;
279+
280+
switch (direction) {
281+
case View.FOCUS_DOWN:
282+
generalizedDirection = 0;
283+
break;
284+
case View.FOCUS_UP:
285+
generalizedDirection = 1;
286+
break;
287+
case View.FOCUS_RIGHT:
288+
generalizedDirection = 2;
289+
break;
290+
case View.FOCUS_LEFT:
291+
generalizedDirection = 3;
292+
break;
293+
default:
294+
return null;
295+
}
296+
297+
@Nullable
298+
float[] serializedNextFocusableNodeMetrics =
299+
mBinding.findNextFocusableElementMetrics(parentTag, focusedTag, generalizedDirection);
300+
301+
if (serializedNextFocusableNodeMetrics == null) {
302+
return null;
303+
}
304+
305+
return new NextFocusableNode(
306+
(int) serializedNextFocusableNodeMetrics[0], serializedNextFocusableNodeMetrics[1]);
307+
}
308+
263309
@Override
264310
@AnyThread
265311
@ThreadConfined(ANY)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManagerBinding.kt

+6
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ internal class FabricUIManagerBinding : HybridClassBase() {
5555
isMountable: Boolean
5656
)
5757

58+
external fun findNextFocusableElementMetrics(
59+
parentTag: Int,
60+
focusedTag: Int,
61+
direction: Int
62+
): FloatArray
63+
5864
external fun stopSurface(surfaceId: Int)
5965

6066
external fun stopSurfaceWithSurfaceHandler(surfaceHandler: SurfaceHandlerBinding)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.fabric
9+
10+
public class NextFocusableNode(public val id: Int, public val deltaScroll: Float)

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java

+13
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED;
1212
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END;
1313
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START;
14+
import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView;
1415

1516
import android.animation.ObjectAnimator;
1617
import android.animation.ValueAnimator;
@@ -31,6 +32,7 @@
3132
import android.widget.OverScroller;
3233
import androidx.annotation.Nullable;
3334
import androidx.core.view.ViewCompat;
35+
import androidx.core.view.ViewCompat.FocusRealDirection;
3436
import com.facebook.common.logging.FLog;
3537
import com.facebook.infer.annotation.Assertions;
3638
import com.facebook.infer.annotation.Nullsafe;
@@ -772,6 +774,17 @@ protected void onDetachedFromWindow() {
772774
}
773775
}
774776

777+
@Override
778+
public @Nullable View focusSearch(View focused, @FocusRealDirection int direction) {
779+
@Nullable View nextfocusableView = findNextFocusableView(this, focused, direction, true);
780+
781+
if (nextfocusableView != null) {
782+
return nextfocusableView;
783+
}
784+
785+
return super.focusSearch(focused, direction);
786+
}
787+
775788
@Override
776789
public void updateClippingRect() {
777790
if (!mRemoveClippedSubviews) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java

+14
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED;
1212
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END;
1313
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START;
14+
import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView;
1415

1516
import android.animation.ObjectAnimator;
1617
import android.animation.ValueAnimator;
@@ -31,6 +32,7 @@
3132
import androidx.annotation.NonNull;
3233
import androidx.annotation.Nullable;
3334
import androidx.core.view.ViewCompat;
35+
import androidx.core.view.ViewCompat.FocusRealDirection;
3436
import com.facebook.common.logging.FLog;
3537
import com.facebook.infer.annotation.Assertions;
3638
import com.facebook.infer.annotation.Nullsafe;
@@ -359,6 +361,18 @@ protected void onDetachedFromWindow() {
359361
}
360362
}
361363

364+
@Override
365+
public @Nullable View focusSearch(View focused, @FocusRealDirection int direction) {
366+
367+
@Nullable View nextfocusableView = findNextFocusableView(this, focused, direction, false);
368+
369+
if (nextfocusableView != null) {
370+
return nextfocusableView;
371+
}
372+
373+
return super.focusSearch(focused, direction);
374+
}
375+
362376
/**
363377
* Since ReactScrollView handles layout changes on JS side, it does not call super.onlayout due to
364378
* which mIsLayoutDirty flag in ScrollView remains true and prevents scrolling to child when

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt

+87
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@ import android.animation.Animator
1111
import android.animation.ValueAnimator
1212
import android.content.Context
1313
import android.graphics.Point
14+
import android.view.FocusFinder
1415
import android.view.View
1516
import android.view.ViewGroup
1617
import android.widget.OverScroller
18+
import androidx.core.view.ViewCompat.FocusRealDirection
1719
import com.facebook.common.logging.FLog
1820
import com.facebook.react.bridge.ReactContext
1921
import com.facebook.react.bridge.WritableMap
2022
import com.facebook.react.bridge.WritableNativeMap
2123
import com.facebook.react.common.ReactConstants
24+
import com.facebook.react.fabric.FabricUIManager
2225
import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel
26+
import com.facebook.react.uimanager.PixelUtil.toPixelFromDIP
2327
import com.facebook.react.uimanager.StateWrapper
2428
import com.facebook.react.uimanager.UIManagerHelper
2529
import com.facebook.react.uimanager.common.UIManagerType
@@ -462,6 +466,89 @@ public object ReactScrollViewHelper {
462466
return Point(scroller.finalX, scroller.finalY)
463467
}
464468

469+
@JvmStatic
470+
public fun findNextFocusableView(
471+
host: ViewGroup,
472+
focused: View,
473+
@FocusRealDirection direction: Int,
474+
horizontal: Boolean
475+
): View? {
476+
val absDir = resolveAbsoluteDirection(direction, horizontal, host.getLayoutDirection())
477+
478+
/*
479+
* Check if we can focus the next element in the absolute direction within the ScrollView,
480+
* if we can't, look into the shadow tree to find the next focusable element
481+
*/
482+
val ff = FocusFinder.getInstance()
483+
val result = ff.findNextFocus(host, focused, absDir)
484+
485+
if (result != null) {
486+
return result
487+
}
488+
489+
/*
490+
* Attempt to focus the next focusable but clipped element on the list if there is one, since
491+
* the view is clipped it is not currently in the hierarchy so we scroll it into view and then
492+
* focus it.
493+
*/
494+
return findNextClippedElement(host, focused, absDir, host.context as ReactContext)
495+
}
496+
497+
/**
498+
* Attempts to focus the next element in the specified direction within the scrollView.
499+
*
500+
* @return true if a new element was successfully focused, otherwise false.
501+
*/
502+
@JvmStatic
503+
public fun findNextClippedElement(
504+
scrollView: ViewGroup,
505+
focused: View,
506+
@FocusRealDirection direction: Int,
507+
context: ReactContext,
508+
): View? {
509+
val uimanager = UIManagerHelper.getUIManager(context, UIManagerType.FABRIC) ?: return null
510+
511+
val nextFocusableViewMetrics =
512+
(uimanager as FabricUIManager).findNextFocusableElementMetrics(
513+
scrollView.id, focused.id, direction)
514+
515+
if (nextFocusableViewMetrics != null) {
516+
517+
when (direction) {
518+
View.FOCUS_UP,
519+
View.FOCUS_DOWN -> {
520+
scrollView.scrollBy(0, toPixelFromDIP(nextFocusableViewMetrics.deltaScroll).toInt())
521+
}
522+
View.FOCUS_RIGHT,
523+
View.FOCUS_LEFT -> {
524+
scrollView.scrollBy(toPixelFromDIP(nextFocusableViewMetrics.deltaScroll).toInt(), 0)
525+
}
526+
else -> return null
527+
}
528+
return scrollView.findViewById(nextFocusableViewMetrics.id)
529+
}
530+
return null
531+
}
532+
533+
@JvmStatic
534+
public fun resolveAbsoluteDirection(
535+
@FocusRealDirection direction: Int,
536+
horizontal: Boolean,
537+
layoutDirection: Int
538+
): Int {
539+
val rtl: Boolean = layoutDirection == View.LAYOUT_DIRECTION_RTL
540+
541+
return if (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD) {
542+
if (horizontal) {
543+
if ((direction == View.FOCUS_FORWARD) != rtl) View.FOCUS_RIGHT else View.FOCUS_LEFT
544+
} else {
545+
if (direction == View.FOCUS_FORWARD) View.FOCUS_DOWN else View.FOCUS_UP
546+
}
547+
} else {
548+
direction
549+
}
550+
}
551+
465552
public interface ScrollListener {
466553
public fun onScroll(
467554
scrollView: ViewGroup?,

0 commit comments

Comments
 (0)