Skip to content

Commit ae231c8

Browse files
ontzicfacebook-github-bot
authored andcommitted
Scrolling fixes (#25105)
Summary: Scrolling improvements in ReactAndroid: 1. Issue: With current ReactHorizontalScrollView behavior, it treats all views as focusable, regardless of if they are in view or not. This is fine for non-paged horizontal scroll view, but when paged this allows focus on elements that are not within the current page. Combined with logic to scroll to the focused view, this breaks the paging for ReactHorizontalScrollView. Fix: limit the focusable elements to only elements that are currently in view when ReactHorizontalScrollView has paging enabled 2. Issue: When keyboard is attached and user tries to navigate through Tab key, Scroll views do not scroll to the focused child. Since ReactScrollView handles layout changes on JS side, it does not call super.onlayout due to which mIsLayoutDirty flag in android ScrollView remains true and prevents scrolling to child when requestChildFocus is called. Fix: To fix the focus navigation, we are overriding requestChildFocus method in ReactScrollView. We are not checking any dirty layout flag and scrolling to child directly. This will fix focus navigation issue for KeyEvents which are not handled by android's ScrollView, for example: KEYCODE_TAB. Same applies to ReactHorizontalScrollView. 3. Set Android ScrollView to be non-focusable when scroll is disabled. Prior to this change, non-scrollable Scrollview would still be focusable, causing a poor keyboarding experience ## Changelog [Android] [Fixed] Scrolling improvements in ReactAndroid Pull Request resolved: #25105 Differential Revision: D15737563 Pulled By: mdvacca fbshipit-source-id: 0d57563415c68668dc1acb05fb3399e6645c9595
1 parent d0792d4 commit ae231c8

File tree

3 files changed

+180
-0
lines changed

3 files changed

+180
-0
lines changed

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

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import androidx.core.view.ViewCompat;
1919
import androidx.core.text.TextUtilsCompat;
2020
import android.util.Log;
21+
import android.view.FocusFinder;
2122
import android.view.MotionEvent;
2223
import android.view.View;
2324
import android.view.ViewConfiguration;
@@ -37,6 +38,8 @@
3738
import java.util.List;
3839
import java.util.Locale;
3940
import javax.annotation.Nullable;
41+
import java.util.ArrayList;
42+
import java.util.List;
4043

4144
/**
4245
* Similar to {@link ReactScrollView} but only supports horizontal scrolling.
@@ -72,6 +75,9 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
7275
private boolean mSnapToStart = true;
7376
private boolean mSnapToEnd = true;
7477
private ReactViewBackgroundManager mReactBackgroundManager;
78+
private boolean mPagedArrowScrolling = false;
79+
80+
private final Rect mTempRect = new Rect();
7581

7682
public ReactHorizontalScrollView(Context context) {
7783
this(context, null);
@@ -221,6 +227,82 @@ protected void onLayout(boolean changed, int l, int t, int r, int b) {
221227
scrollTo(getScrollX(), getScrollY());
222228
}
223229

230+
/**
231+
* Since ReactHorizontalScrollView handles layout changes on JS side, it does not call super.onlayout
232+
* due to which mIsLayoutDirty flag in HorizontalScrollView remains true and prevents scrolling to child
233+
* when requestChildFocus is called.
234+
* Overriding this method and scrolling to child without checking any layout dirty flag. This will fix
235+
* focus navigation issue for KeyEvents which are not handled in HorizontalScrollView, for example: KEYCODE_TAB.
236+
*/
237+
@Override
238+
public void requestChildFocus(View child, View focused) {
239+
if (focused != null && !mPagingEnabled) {
240+
scrollToChild(focused);
241+
}
242+
super.requestChildFocus(child, focused);
243+
}
244+
245+
@Override
246+
public void addFocusables(ArrayList<View> views, int direction, int focusableMode) {
247+
if (mPagingEnabled && !mPagedArrowScrolling) {
248+
// Only add elements within the current page to list of focusables
249+
ArrayList<View> candidateViews = new ArrayList<View>();
250+
super.addFocusables(candidateViews, direction, focusableMode);
251+
for (View candidate : candidateViews) {
252+
// We must also include the currently focused in the focusables list or focus search will always
253+
// return the first element within the focusables list
254+
if (isScrolledInView(candidate) || isPartiallyScrolledInView(candidate) || candidate.isFocused()) {
255+
views.add(candidate);
256+
}
257+
}
258+
} else {
259+
super.addFocusables(views, direction, focusableMode);
260+
}
261+
}
262+
263+
/**
264+
* Calculates the x delta required to scroll the given descendent into view
265+
*/
266+
private int getScrollDelta(View descendent) {
267+
descendent.getDrawingRect(mTempRect);
268+
offsetDescendantRectToMyCoords(descendent, mTempRect);
269+
return computeScrollDeltaToGetChildRectOnScreen(mTempRect);
270+
}
271+
272+
/**
273+
* Returns whether the given descendent is scrolled fully in view
274+
*/
275+
private boolean isScrolledInView(View descendent) {
276+
return getScrollDelta(descendent) == 0;
277+
}
278+
279+
280+
/**
281+
* Returns whether the given descendent is partially scrolled in view
282+
*/
283+
private boolean isPartiallyScrolledInView(View descendent) {
284+
int scrollDelta = getScrollDelta(descendent);
285+
descendent.getDrawingRect(mTempRect);
286+
return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width();
287+
}
288+
289+
/**
290+
* Returns whether the given descendent is "mostly" (>50%) scrolled in view
291+
*/
292+
private boolean isMostlyScrolledInView(View descendent) {
293+
int scrollDelta = getScrollDelta(descendent);
294+
descendent.getDrawingRect(mTempRect);
295+
return scrollDelta != 0 && Math.abs(scrollDelta) < (mTempRect.width() / 2);
296+
}
297+
298+
private void scrollToChild(View child) {
299+
int scrollDelta = getScrollDelta(child);
300+
301+
if (scrollDelta != 0) {
302+
scrollBy(scrollDelta, 0);
303+
}
304+
}
305+
224306
@Override
225307
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
226308
super.onScrollChanged(x, y, oldX, oldY);
@@ -263,6 +345,48 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
263345
return false;
264346
}
265347

348+
@Override
349+
public boolean pageScroll(int direction) {
350+
boolean handled = super.pageScroll(direction);
351+
352+
if (mPagingEnabled && handled) {
353+
handlePostTouchScrolling(0, 0);
354+
}
355+
356+
return handled;
357+
}
358+
359+
@Override
360+
public boolean arrowScroll(int direction) {
361+
boolean handled = false;
362+
363+
if (mPagingEnabled) {
364+
mPagedArrowScrolling = true;
365+
366+
if (getChildCount() > 0) {
367+
View currentFocused = findFocus();
368+
View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction);
369+
View rootChild = getChildAt(0);
370+
if (rootChild != null && nextFocused != null && nextFocused.getParent() == rootChild) {
371+
if (!isScrolledInView(nextFocused) && !isMostlyScrolledInView(nextFocused)) {
372+
smoothScrollToNextPage(direction);
373+
}
374+
nextFocused.requestFocus();
375+
handled = true;
376+
} else {
377+
smoothScrollToNextPage(direction);
378+
handled = true;
379+
}
380+
}
381+
382+
mPagedArrowScrolling = false;
383+
} else {
384+
handled = super.arrowScroll(direction);
385+
}
386+
387+
return handled;
388+
}
389+
266390
@Override
267391
public boolean onTouchEvent(MotionEvent ev) {
268392
if (!mScrollEnabled) {
@@ -706,6 +830,29 @@ private void flingAndSnap(int velocityX) {
706830
}
707831
}
708832

833+
private void smoothScrollToNextPage(int direction) {
834+
int width = getWidth();
835+
int currentX = getScrollX();
836+
837+
int page = currentX / width;
838+
if (currentX % width != 0) {
839+
page++;
840+
}
841+
842+
if (direction == View.FOCUS_LEFT) {
843+
page = page - 1;
844+
} else {
845+
page = page + 1;
846+
}
847+
848+
if (page < 0) {
849+
page = 0;
850+
}
851+
852+
smoothScrollTo(page * width, getScrollY());
853+
handlePostTouchScrolling(0, 0);
854+
}
855+
709856
@Override
710857
public void setBackgroundColor(int color) {
711858
mReactBackgroundManager.setBackgroundColor(color);

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ protected void onAttachedToWindow() {
205205
}
206206
}
207207

208+
/**
209+
* Since ReactScrollView handles layout changes on JS side, it does not call super.onlayout
210+
* due to which mIsLayoutDirty flag in ScrollView remains true and prevents scrolling to child
211+
* when requestChildFocus is called.
212+
* Overriding this method and scrolling to child without checking any layout dirty flag. This will fix
213+
* focus navigation issue for KeyEvents which are not handled by ScrollView, for example: KEYCODE_TAB.
214+
*/
215+
@Override
216+
public void requestChildFocus(View child, View focused) {
217+
if (focused != null) {
218+
scrollToChild(focused);
219+
}
220+
super.requestChildFocus(child, focused);
221+
}
222+
223+
private void scrollToChild(View child) {
224+
Rect tempRect = new Rect();
225+
child.getDrawingRect(tempRect);
226+
227+
/* Offset from child's local coordinates to ScrollView coordinates */
228+
offsetDescendantRectToMyCoords(child, tempRect);
229+
230+
int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(tempRect);
231+
232+
if (scrollDelta != 0) {
233+
scrollBy(0, scrollDelta);
234+
}
235+
}
236+
208237
@Override
209238
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
210239
super.onScrollChanged(x, y, oldX, oldY);

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ public ReactScrollView createViewInstance(ThemedReactContext context) {
7070
@ReactProp(name = "scrollEnabled", defaultBoolean = true)
7171
public void setScrollEnabled(ReactScrollView view, boolean value) {
7272
view.setScrollEnabled(value);
73+
74+
// Set focusable to match whether scroll is enabled. This improves keyboarding
75+
// experience by not making scrollview a tab stop when you cannot interact with it.
76+
view.setFocusable(value);
7377
}
7478

7579
@ReactProp(name = "showsVerticalScrollIndicator")

0 commit comments

Comments
 (0)