Skip to content

Commit f5211c6

Browse files
jorge-cabfacebook-github-bot
authored andcommitted
Fix keyboard navigation for FlatList with removeClippedSubviews enabled (#50105)
Summary: Pull Request resolved: #50105 Pull Request resolved: #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. We then prevent the clipping of the topmost parent of the next focusable view to lay out the entire containing element in case we have some necessary context in the parent Changelog: [Android][Fixed] - Fix keyboard navigation on lists with `removeClippedSubviews` enabled Reviewed By: joevilches Differential Revision: D71324219
1 parent a3d1078 commit f5211c6

File tree

7 files changed

+192
-7
lines changed

7 files changed

+192
-7
lines changed

Diff for: packages/react-native/ReactAndroid/api/ReactAndroid.api

+10
Original file line numberDiff line numberDiff line change
@@ -2343,6 +2343,8 @@ public class com/facebook/react/fabric/FabricUIManager : com/facebook/react/brid
23432343
public fun dispatchCommand (IILcom/facebook/react/bridge/ReadableArray;)V
23442344
public fun dispatchCommand (IILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
23452345
public fun dispatchCommand (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
2346+
public fun findNextFocusableElement (III)Ljava/lang/Integer;
2347+
public fun findRelativeTopMostParent (II)Ljava/lang/Integer;
23462348
public fun getColor (I[Ljava/lang/String;)I
23472349
public fun getEventDispatcher ()Lcom/facebook/react/uimanager/events/EventDispatcher;
23482350
public fun getPerformanceCounters ()Ljava/util/Map;
@@ -3982,6 +3984,7 @@ public abstract interface class com/facebook/react/uimanager/ReactClippingViewGr
39823984
public abstract fun getRemoveClippedSubviews ()Z
39833985
public abstract fun setRemoveClippedSubviews (Z)V
39843986
public abstract fun updateClippingRect ()V
3987+
public abstract fun updateClippingRect (Ljava/util/Set;)V
39853988
}
39863989

39873990
public class com/facebook/react/uimanager/ReactClippingViewGroupHelper {
@@ -5894,6 +5897,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
58945897
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
58955898
public fun flashScrollIndicators ()V
58965899
public fun fling (I)V
5900+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
58975901
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
58985902
public fun getClippingRect (Landroid/graphics/Rect;)V
58995903
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -5956,6 +5960,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
59565960
public fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V
59575961
public fun startFlingAnimator (II)V
59585962
public fun updateClippingRect ()V
5963+
public fun updateClippingRect (Ljava/util/Set;)V
59595964
}
59605965

59615966
public class com/facebook/react/views/scroll/ReactHorizontalScrollViewManager : com/facebook/react/uimanager/ViewGroupManager, com/facebook/react/views/scroll/ReactScrollViewCommandHelper$ScrollCommandHandler {
@@ -6016,6 +6021,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
60166021
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
60176022
public fun flashScrollIndicators ()V
60186023
public fun fling (I)V
6024+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
60196025
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
60206026
public fun getClippingRect (Landroid/graphics/Rect;)V
60216027
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -6079,6 +6085,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
60796085
public fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V
60806086
public fun startFlingAnimator (II)V
60816087
public fun updateClippingRect ()V
6088+
public fun updateClippingRect (Ljava/util/Set;)V
60826089
}
60836090

60846091
public final class com/facebook/react/views/scroll/ReactScrollViewCommandHelper {
@@ -6136,6 +6143,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
61366143
public static final fun emitScrollEvent (Landroid/view/ViewGroup;FF)V
61376144
public static final fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V
61386145
public static final fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V
6146+
public static final fun findNextFocusableView (Landroid/view/ViewGroup;Landroid/view/View;IZ)Landroid/view/View;
61396147
public static final fun forceUpdateState (Landroid/view/ViewGroup;)V
61406148
public static final fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I
61416149
public static final fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I
@@ -6145,6 +6153,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
61456153
public final fun registerFlingAnimator (Landroid/view/ViewGroup;)V
61466154
public static final fun removeLayoutChangeListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$LayoutChangeListener;)V
61476155
public static final fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V
6156+
public static final fun resolveAbsoluteDirection (IZI)I
61486157
public static final fun smoothScrollTo (Landroid/view/ViewGroup;II)V
61496158
public static final fun updateFabricScrollState (Landroid/view/ViewGroup;)V
61506159
public final fun updateFabricScrollState (Landroid/view/ViewGroup;II)V
@@ -6932,6 +6941,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
69326941
public fun setRemoveClippedSubviews (Z)V
69336942
public fun setTranslucentBackgroundDrawable (Landroid/graphics/drawable/Drawable;)V
69346943
public fun updateClippingRect ()V
6944+
public fun updateClippingRect (Ljava/util/Set;)V
69356945
public fun updateDrawingOrder ()V
69366946
}
69376947

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

+47
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import androidx.annotation.AnyThread;
2828
import androidx.annotation.Nullable;
2929
import androidx.annotation.UiThread;
30+
import androidx.core.view.ViewCompat.FocusRealDirection;
3031
import com.facebook.common.logging.FLog;
3132
import com.facebook.infer.annotation.Assertions;
3233
import com.facebook.infer.annotation.Nullsafe;
@@ -262,6 +263,52 @@ public <T extends View> int addRootView(
262263
return rootTag;
263264
}
264265

266+
/**
267+
* Find the next focusable element's id and position relative to the parent from the shadow tree
268+
* based on the current focusable element and the direction.
269+
*
270+
* @return A NextFocusableNode object where the 'id' is the reactId/Tag of the next focusable
271+
* view, returns null if no view could be found
272+
*/
273+
public @Nullable Integer findNextFocusableElement(
274+
int parentTag, int focusedTag, @FocusRealDirection int direction) {
275+
if (mBinding == null) {
276+
return null;
277+
}
278+
279+
int generalizedDirection;
280+
281+
switch (direction) {
282+
case View.FOCUS_DOWN:
283+
generalizedDirection = 0;
284+
break;
285+
case View.FOCUS_UP:
286+
generalizedDirection = 1;
287+
break;
288+
case View.FOCUS_RIGHT:
289+
generalizedDirection = 2;
290+
break;
291+
case View.FOCUS_LEFT:
292+
generalizedDirection = 3;
293+
break;
294+
default:
295+
return null;
296+
}
297+
298+
int serializedNextFocusableNodeMetrics =
299+
mBinding.findNextFocusableElement(parentTag, focusedTag, generalizedDirection);
300+
301+
if (serializedNextFocusableNodeMetrics == -1) {
302+
return null;
303+
}
304+
305+
return serializedNextFocusableNodeMetrics;
306+
}
307+
308+
public @Nullable Integer findRelativeTopMostParent(int rootTag, int childTag) {
309+
return mBinding != null ? mBinding.findRelativeTopMostParent(rootTag, childTag) : null;
310+
}
311+
265312
@Override
266313
@AnyThread
267314
@ThreadConfined(ANY)

Diff for: packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactClippingViewGroup.kt

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public interface ReactClippingViewGroup {
3232
*/
3333
public fun updateClippingRect()
3434

35+
public fun updateClippingRect(excludedView: Set<Int>?)
36+
3537
/**
3638
* Get rectangular bounds to which view is currently clipped to. Called only on views that has set
3739
* `removeCLippedSubviews` property value to `true`.

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

+20-1
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;
@@ -64,6 +66,7 @@
6466
import java.lang.reflect.Field;
6567
import java.util.ArrayList;
6668
import java.util.List;
69+
import java.util.Set;
6770

6871
/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
6972
@Nullsafe(Nullsafe.Mode.LOCAL)
@@ -771,8 +774,24 @@ protected void onDetachedFromWindow() {
771774
}
772775
}
773776

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+
774788
@Override
775789
public void updateClippingRect() {
790+
updateClippingRect(null);
791+
}
792+
793+
@Override
794+
public void updateClippingRect(@Nullable Set<Integer> excludedViewId) {
776795
if (!mRemoveClippedSubviews) {
777796
return;
778797
}
@@ -784,7 +803,7 @@ public void updateClippingRect() {
784803
ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect);
785804
View contentView = getContentView();
786805
if (contentView instanceof ReactClippingViewGroup) {
787-
((ReactClippingViewGroup) contentView).updateClippingRect();
806+
((ReactClippingViewGroup) contentView).updateClippingRect(excludedViewId);
788807
}
789808
} finally {
790809
Systrace.endSection(Systrace.TRACE_TAG_REACT);

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

+21-1
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;
@@ -63,6 +65,7 @@
6365
import com.facebook.systrace.Systrace;
6466
import java.lang.reflect.Field;
6567
import java.util.List;
68+
import java.util.Set;
6669

6770
/**
6871
* A simple subclass of ScrollView that doesn't dispatch measure and layout to its children and has
@@ -359,6 +362,18 @@ protected void onDetachedFromWindow() {
359362
}
360363
}
361364

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

529544
@Override
530545
public void updateClippingRect() {
546+
updateClippingRect(null);
547+
}
548+
549+
@Override
550+
public void updateClippingRect(@Nullable Set<Integer> excludedViewsSet) {
531551
if (!mRemoveClippedSubviews) {
532552
return;
533553
}
@@ -539,7 +559,7 @@ public void updateClippingRect() {
539559
ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect);
540560
View contentView = getContentView();
541561
if (contentView instanceof ReactClippingViewGroup) {
542-
((ReactClippingViewGroup) contentView).updateClippingRect();
562+
((ReactClippingViewGroup) contentView).updateClippingRect(excludedViewsSet);
543563
}
544564
} finally {
545565
Systrace.endSection(Systrace.TRACE_TAG_REACT);

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

+65
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.ReactClippingViewGroup
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,67 @@ 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 this
480+
* would mean the view is not clipped, if we can't, look into the shadow tree to find the next
481+
* focusable element
482+
*/
483+
val ff = FocusFinder.getInstance()
484+
val result = ff.findNextFocus(host, focused, absDir)
485+
486+
if (result != null) {
487+
return result
488+
}
489+
490+
if (host !is ReactClippingViewGroup) {
491+
return null
492+
}
493+
494+
val uimanager =
495+
UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC)
496+
?: return null
497+
498+
val nextFocusableViewId =
499+
(uimanager as FabricUIManager).findNextFocusableElement(
500+
host.getChildAt(0).id, focused.id, absDir) ?: return null
501+
502+
val nextFocusTopMostParentId =
503+
uimanager.findRelativeTopMostParent(host.getChildAt(0).id, nextFocusableViewId)
504+
?: return null
505+
506+
host.updateClippingRect(setOf(nextFocusableViewId, nextFocusTopMostParentId))
507+
508+
return host.findViewById(nextFocusableViewId)
509+
}
510+
511+
@JvmStatic
512+
public fun resolveAbsoluteDirection(
513+
@FocusRealDirection direction: Int,
514+
horizontal: Boolean,
515+
layoutDirection: Int
516+
): Int {
517+
val rtl: Boolean = layoutDirection == View.LAYOUT_DIRECTION_RTL
518+
519+
return if (direction == View.FOCUS_FORWARD || direction == View.FOCUS_BACKWARD) {
520+
if (horizontal) {
521+
if ((direction == View.FOCUS_FORWARD) != rtl) View.FOCUS_RIGHT else View.FOCUS_LEFT
522+
} else {
523+
if (direction == View.FOCUS_FORWARD) View.FOCUS_DOWN else View.FOCUS_UP
524+
}
525+
} else {
526+
direction
527+
}
528+
}
529+
465530
public interface ScrollListener {
466531
public fun onScroll(
467532
scrollView: ViewGroup?,

0 commit comments

Comments
 (0)