Skip to content

Commit d08c827

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 16e50fb commit d08c827

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 {
@@ -5899,6 +5902,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
58995902
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
59005903
public fun flashScrollIndicators ()V
59015904
public fun fling (I)V
5905+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
59025906
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
59035907
public fun getClippingRect (Landroid/graphics/Rect;)V
59045908
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -5961,6 +5965,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
59615965
public fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V
59625966
public fun startFlingAnimator (II)V
59635967
public fun updateClippingRect ()V
5968+
public fun updateClippingRect (Ljava/util/Set;)V
59645969
}
59655970

59665971
public class com/facebook/react/views/scroll/ReactHorizontalScrollViewManager : com/facebook/react/uimanager/ViewGroupManager, com/facebook/react/views/scroll/ReactScrollViewCommandHelper$ScrollCommandHandler {
@@ -6021,6 +6026,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
60216026
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
60226027
public fun flashScrollIndicators ()V
60236028
public fun fling (I)V
6029+
public fun focusSearch (Landroid/view/View;I)Landroid/view/View;
60246030
public fun getChildVisibleRect (Landroid/view/View;Landroid/graphics/Rect;Landroid/graphics/Point;)Z
60256031
public fun getClippingRect (Landroid/graphics/Rect;)V
60266032
public fun getFlingAnimator ()Landroid/animation/ValueAnimator;
@@ -6084,6 +6090,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
60846090
public fun setStateWrapper (Lcom/facebook/react/uimanager/StateWrapper;)V
60856091
public fun startFlingAnimator (II)V
60866092
public fun updateClippingRect ()V
6093+
public fun updateClippingRect (Ljava/util/Set;)V
60876094
}
60886095

60896096
public final class com/facebook/react/views/scroll/ReactScrollViewCommandHelper {
@@ -6141,6 +6148,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
61416148
public static final fun emitScrollEvent (Landroid/view/ViewGroup;FF)V
61426149
public static final fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V
61436150
public static final fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V
6151+
public static final fun findNextFocusableView (Landroid/view/ViewGroup;Landroid/view/View;IZ)Landroid/view/View;
61446152
public static final fun forceUpdateState (Landroid/view/ViewGroup;)V
61456153
public static final fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I
61466154
public static final fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I
@@ -6150,6 +6158,7 @@ public final class com/facebook/react/views/scroll/ReactScrollViewHelper {
61506158
public final fun registerFlingAnimator (Landroid/view/ViewGroup;)V
61516159
public static final fun removeLayoutChangeListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$LayoutChangeListener;)V
61526160
public static final fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V
6161+
public static final fun resolveAbsoluteDirection (IZI)I
61536162
public static final fun smoothScrollTo (Landroid/view/ViewGroup;II)V
61546163
public static final fun updateFabricScrollState (Landroid/view/ViewGroup;)V
61556164
public final fun updateFabricScrollState (Landroid/view/ViewGroup;II)V
@@ -6937,6 +6946,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
69376946
public fun setRemoveClippedSubviews (Z)V
69386947
public fun setTranslucentBackgroundDrawable (Landroid/graphics/drawable/Drawable;)V
69396948
public fun updateClippingRect ()V
6949+
public fun updateClippingRect (Ljava/util/Set;)V
69406950
public fun updateDrawingOrder ()V
69416951
}
69426952

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)