Skip to content

Commit e35a963

Browse files
hsourcefacebook-github-bot
authored andcommitted
Fix to make taps on views outside parent bounds work on Android (#29039)
Summary: By default, Views in React Native have `overflow: visible`. When a child view is outside of the parent view's boundaries, it's visible on Android, but not tappable. This behaviour is incorrect, and doesn't match iOS behaviour. - Taps on Views outside the bounds of a parent with `overflow: visible` (or unset) should register - Taps on Views outside the bounds of a parent with `overflow: hidden` should continue to not register Related issues: - fixes #21455 - fixes #27061 - fixes #27232 ### Fix - Made `findTouchTargetView` not check that the touch was in the bounds of the immediate children, but instead - Check that the touch is in its own bounds when returning itself - Check that the touch for a child is in its own bounds only when `overflow: hidden` is set - Modified related code to adjust to this change - Added RNTesterApp test ## Changelog <!-- Help reviewers and the release process by writing your own changelog entry. For an example, see: https://github.com/facebook/react-native/wiki/Changelog --> [Android] [Fixed] - Allow taps on views outside the bounds of a parent with `overflow: hidden` Pull Request resolved: #29039 Test Plan: This can be tested with 2 examples added to the bottom of the PointerEvents page of the RNTesterApp: | Before | After | | --- | --- | | ![Before](https://user-images.githubusercontent.com/2937410/83610933-19079b00-a535-11ea-8add-22daae0191e1.gif) | ![After](https://user-images.githubusercontent.com/2937410/83610583-8830bf80-a534-11ea-97e2-71e180a70343.gif) | Reviewed By: ShikaSD Differential Revision: D30104853 Pulled By: JoshuaGross fbshipit-source-id: 644a109706258bfe829096354dfe477599e2db23
1 parent c677e19 commit e35a963

File tree

6 files changed

+248
-74
lines changed

6 files changed

+248
-74
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright (c) Facebook, Inc. and its 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.uimanager;
9+
10+
import android.view.View;
11+
import androidx.annotation.Nullable;
12+
13+
/**
14+
* Interface that should be implemented by {@link View} subclasses that support {@code overflow}
15+
* style. This allows the overflow information to be used by {@link TouchTargetHelper} to determine
16+
* if a View is touchable.
17+
*/
18+
public interface ReactOverflowView {
19+
/**
20+
* Gets the overflow state of a view. If set, this should be one of {@link ViewProps#HIDDEN},
21+
* {@link ViewProps#VISIBLE} or {@link ViewProps#SCROLL}.
22+
*/
23+
@Nullable
24+
String getOverflow();
25+
}

ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java

+101-68
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
1818
import com.facebook.react.bridge.UiThreadUtil;
1919
import com.facebook.react.touch.ReactHitSlopView;
20+
import java.util.EnumSet;
2021

2122
/**
2223
* Class responsible for identifying which react view should handle a given {@link MotionEvent}. It
@@ -80,7 +81,7 @@ public static int findTargetTagAndCoordinatesForTouch(
8081
// Store eventCoords in array so that they are modified to be relative to the targetView found.
8182
viewCoords[0] = eventX;
8283
viewCoords[1] = eventY;
83-
View nativeTargetView = findTouchTargetView(viewCoords, viewGroup);
84+
View nativeTargetView = findTouchTargetViewWithPointerEvents(viewCoords, viewGroup);
8485
if (nativeTargetView != null) {
8586
View reactTargetView = findClosestReactAncestor(nativeTargetView);
8687
if (reactTargetView != null) {
@@ -100,6 +101,14 @@ private static View findClosestReactAncestor(View view) {
100101
return view;
101102
}
102103

104+
/** Types of allowed return values from {@link #findTouchTargetView}. */
105+
private enum TouchTargetReturnType {
106+
/** Allow returning the view passed in through the parameters. */
107+
SELF,
108+
/** Allow returning children of the view passed in through parameters. */
109+
CHILD,
110+
}
111+
103112
/**
104113
* Returns the touch target View that is either viewGroup or one if its descendants. This is a
105114
* recursive DFS since view the entire tree must be parsed until the target is found. If the
@@ -111,43 +120,88 @@ private static View findClosestReactAncestor(View view) {
111120
* be relative to the current viewGroup. When the method returns, it will contain the eventCoords
112121
* relative to the targetView found.
113122
*/
114-
private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup) {
115-
int childrenCount = viewGroup.getChildCount();
116-
// Consider z-index when determining the touch target.
117-
ReactZIndexedViewGroup zIndexedViewGroup =
118-
viewGroup instanceof ReactZIndexedViewGroup ? (ReactZIndexedViewGroup) viewGroup : null;
119-
for (int i = childrenCount - 1; i >= 0; i--) {
120-
int childIndex =
121-
zIndexedViewGroup != null ? zIndexedViewGroup.getZIndexMappedChildIndex(i) : i;
122-
View child = viewGroup.getChildAt(childIndex);
123-
PointF childPoint = mTempPoint;
124-
if (isTransformedTouchPointInView(
125-
eventCoords[0], eventCoords[1], viewGroup, child, childPoint)) {
126-
// If it is contained within the child View, the childPoint value will contain the view
127-
// coordinates relative to the child
123+
private static View findTouchTargetView(
124+
float[] eventCoords, View view, EnumSet<TouchTargetReturnType> allowReturnTouchTargetTypes) {
125+
// We prefer returning a child, so we check for a child that can handle the touch first
126+
if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.CHILD)
127+
&& view instanceof ViewGroup) {
128+
ViewGroup viewGroup = (ViewGroup) view;
129+
int childrenCount = viewGroup.getChildCount();
130+
// Consider z-index when determining the touch target.
131+
ReactZIndexedViewGroup zIndexedViewGroup =
132+
viewGroup instanceof ReactZIndexedViewGroup ? (ReactZIndexedViewGroup) viewGroup : null;
133+
for (int i = childrenCount - 1; i >= 0; i--) {
134+
int childIndex =
135+
zIndexedViewGroup != null ? zIndexedViewGroup.getZIndexMappedChildIndex(i) : i;
136+
View child = viewGroup.getChildAt(childIndex);
137+
PointF childPoint = mTempPoint;
138+
getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint);
139+
// The childPoint value will contain the view coordinates relative to the child.
128140
// We need to store the existing X,Y for the viewGroup away as it is possible this child
129141
// will not actually be the target and so we restore them if not
130142
float restoreX = eventCoords[0];
131143
float restoreY = eventCoords[1];
132144
eventCoords[0] = childPoint.x;
133145
eventCoords[1] = childPoint.y;
134146
View targetView = findTouchTargetViewWithPointerEvents(eventCoords, child);
147+
135148
if (targetView != null) {
136-
return targetView;
149+
// We don't allow touches on views that are outside the bounds of an `overflow: hidden`
150+
// View
151+
boolean inOverflowBounds = true;
152+
if (viewGroup instanceof ReactOverflowView) {
153+
@Nullable String overflow = ((ReactOverflowView) viewGroup).getOverflow();
154+
if ((ViewProps.HIDDEN.equals(overflow) || ViewProps.SCROLL.equals(overflow))
155+
&& !isTouchPointInView(restoreX, restoreY, view)) {
156+
inOverflowBounds = false;
157+
}
158+
}
159+
if (inOverflowBounds) {
160+
return targetView;
161+
}
137162
}
138163
eventCoords[0] = restoreX;
139164
eventCoords[1] = restoreY;
140165
}
141166
}
142-
return viewGroup;
167+
168+
// Check if parent can handle the touch after the children
169+
if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.SELF)
170+
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)) {
171+
return view;
172+
}
173+
174+
return null;
143175
}
144176

145177
/**
146-
* Returns whether the touch point is within the child View It is transform aware and will invert
147-
* the transform Matrix to find the true local points This code is taken from {@link
178+
* Checks whether a touch at {@code x} and {@code y} are within the bounds of the View. Both
179+
* {@code x} and {@code y} must be relative to the top-left corner of the view.
180+
*/
181+
private static boolean isTouchPointInView(float x, float y, View view) {
182+
if (view instanceof ReactHitSlopView && ((ReactHitSlopView) view).getHitSlopRect() != null) {
183+
Rect hitSlopRect = ((ReactHitSlopView) view).getHitSlopRect();
184+
if ((x >= -hitSlopRect.left && x < (view.getWidth()) + hitSlopRect.right)
185+
&& (y >= -hitSlopRect.top && y < (view.getHeight()) + hitSlopRect.bottom)) {
186+
return true;
187+
}
188+
189+
return false;
190+
} else {
191+
if ((x >= 0 && x < (view.getWidth())) && (y >= 0 && y < (view.getHeight()))) {
192+
return true;
193+
}
194+
195+
return false;
196+
}
197+
}
198+
199+
/**
200+
* Returns the coordinates of a touch in the child View. It is transform aware and will invert the
201+
* transform Matrix to find the true local points This code is taken from {@link
148202
* ViewGroup#isTransformedTouchPointInView()}
149203
*/
150-
private static boolean isTransformedTouchPointInView(
204+
private static void getChildPoint(
151205
float x, float y, ViewGroup parent, View child, PointF outLocalPoint) {
152206
float localX = x + parent.getScrollX() - child.getLeft();
153207
float localY = y + parent.getScrollY() - child.getTop();
@@ -162,26 +216,7 @@ private static boolean isTransformedTouchPointInView(
162216
localX = localXY[0];
163217
localY = localXY[1];
164218
}
165-
if (child instanceof ReactHitSlopView && ((ReactHitSlopView) child).getHitSlopRect() != null) {
166-
Rect hitSlopRect = ((ReactHitSlopView) child).getHitSlopRect();
167-
if ((localX >= -hitSlopRect.left
168-
&& localX < (child.getRight() - child.getLeft()) + hitSlopRect.right)
169-
&& (localY >= -hitSlopRect.top
170-
&& localY < (child.getBottom() - child.getTop()) + hitSlopRect.bottom)) {
171-
outLocalPoint.set(localX, localY);
172-
return true;
173-
}
174-
175-
return false;
176-
} else {
177-
if ((localX >= 0 && localX < (child.getRight() - child.getLeft()))
178-
&& (localY >= 0 && localY < (child.getBottom() - child.getTop()))) {
179-
outLocalPoint.set(localX, localY);
180-
return true;
181-
}
182-
183-
return false;
184-
}
219+
outLocalPoint.set(localX, localY);
185220
}
186221

187222
/**
@@ -211,45 +246,43 @@ private static boolean isTransformedTouchPointInView(
211246
return null;
212247

213248
} else if (pointerEvents == PointerEvents.BOX_ONLY) {
214-
// This view is the target, its children don't matter
215-
return view;
249+
// This view may be the target, its children don't matter
250+
return findTouchTargetView(eventCoords, view, EnumSet.of(TouchTargetReturnType.SELF));
216251

217252
} else if (pointerEvents == PointerEvents.BOX_NONE) {
218253
// This view can't be the target, but its children might.
219-
if (view instanceof ViewGroup) {
220-
View targetView = findTouchTargetView(eventCoords, (ViewGroup) view);
221-
if (targetView != view) {
222-
return targetView;
223-
}
254+
View targetView =
255+
findTouchTargetView(eventCoords, view, EnumSet.of(TouchTargetReturnType.CHILD));
256+
if (targetView != null) {
257+
return targetView;
258+
}
224259

225-
// PointerEvents.BOX_NONE means that this react element cannot receive pointer events.
226-
// However, there might be virtual children that can receive pointer events, in which case
227-
// we still want to return this View and dispatch a pointer event to the virtual element.
228-
// Note that this currently only applies to Nodes/FlatViewGroup as it's the only class that
229-
// is both a ViewGroup and ReactCompoundView (ReactTextView is a ReactCompoundView but not a
230-
// ViewGroup).
231-
if (view instanceof ReactCompoundView) {
232-
int reactTag =
233-
((ReactCompoundView) view).reactTagForTouch(eventCoords[0], eventCoords[1]);
234-
if (reactTag != view.getId()) {
235-
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
236-
return view;
237-
}
260+
// PointerEvents.BOX_NONE means that this react element cannot receive pointer events.
261+
// However, there might be virtual children that can receive pointer events, in which case
262+
// we still want to return this View and dispatch a pointer event to the virtual element.
263+
// Note that this currently only applies to Nodes/FlatViewGroup as it's the only class that
264+
// is both a ViewGroup and ReactCompoundView (ReactTextView is a ReactCompoundView but not a
265+
// ViewGroup).
266+
if (view instanceof ReactCompoundView
267+
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)) {
268+
int reactTag = ((ReactCompoundView) view).reactTagForTouch(eventCoords[0], eventCoords[1]);
269+
if (reactTag != view.getId()) {
270+
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
271+
return view;
238272
}
239273
}
274+
240275
return null;
241276

242277
} else if (pointerEvents == PointerEvents.AUTO) {
243278
// Either this view or one of its children is the target
244-
if (view instanceof ReactCompoundViewGroup) {
245-
if (((ReactCompoundViewGroup) view).interceptsTouchEvent(eventCoords[0], eventCoords[1])) {
246-
return view;
247-
}
279+
if (view instanceof ReactCompoundViewGroup
280+
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)
281+
&& ((ReactCompoundViewGroup) view).interceptsTouchEvent(eventCoords[0], eventCoords[1])) {
282+
return view;
248283
}
249-
if (view instanceof ViewGroup) {
250-
return findTouchTargetView(eventCoords, (ViewGroup) view);
251-
}
252-
return view;
284+
return findTouchTargetView(
285+
eventCoords, view, EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD));
253286

254287
} else {
255288
throw new JSApplicationIllegalArgumentException(

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

+9-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import com.facebook.react.uimanager.PixelUtil;
4141
import com.facebook.react.uimanager.ReactClippingViewGroup;
4242
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
43+
import com.facebook.react.uimanager.ReactOverflowView;
4344
import com.facebook.react.uimanager.ViewProps;
4445
import com.facebook.react.uimanager.events.NativeGestureUtil;
4546
import com.facebook.react.views.view.ReactViewBackgroundManager;
@@ -49,7 +50,9 @@
4950

5051
/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
5152
public class ReactHorizontalScrollView extends HorizontalScrollView
52-
implements ReactClippingViewGroup, FabricViewStateManager.HasFabricViewStateManager {
53+
implements ReactClippingViewGroup,
54+
FabricViewStateManager.HasFabricViewStateManager,
55+
ReactOverflowView {
5356

5457
private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG;
5558
private static String TAG = ReactHorizontalScrollView.class.getSimpleName();
@@ -245,6 +248,11 @@ public void setOverflow(String overflow) {
245248
invalidate();
246249
}
247250

251+
@Override
252+
public @Nullable String getOverflow() {
253+
return mOverflow;
254+
}
255+
248256
@Override
249257
protected void onDraw(Canvas canvas) {
250258
if (DEBUG_MODE) {

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import com.facebook.react.uimanager.PixelUtil;
3838
import com.facebook.react.uimanager.ReactClippingViewGroup;
3939
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
40+
import com.facebook.react.uimanager.ReactOverflowView;
4041
import com.facebook.react.uimanager.ViewProps;
4142
import com.facebook.react.uimanager.common.UIManagerType;
4243
import com.facebook.react.uimanager.common.ViewUtil;
@@ -56,7 +57,8 @@ public class ReactScrollView extends ScrollView
5657
implements ReactClippingViewGroup,
5758
ViewGroup.OnHierarchyChangeListener,
5859
View.OnLayoutChangeListener,
59-
FabricViewStateManager.HasFabricViewStateManager {
60+
FabricViewStateManager.HasFabricViewStateManager,
61+
ReactOverflowView {
6062

6163
private static @Nullable Field sScrollerField;
6264
private static boolean sTriedToGetScrollerField = false;
@@ -225,6 +227,11 @@ public void setOverflow(String overflow) {
225227
invalidate();
226228
}
227229

230+
@Override
231+
public @Nullable String getOverflow() {
232+
return mOverflow;
233+
}
234+
228235
@Override
229236
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
230237
MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec);

ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import com.facebook.react.uimanager.ReactClippingProhibitedView;
4444
import com.facebook.react.uimanager.ReactClippingViewGroup;
4545
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
46+
import com.facebook.react.uimanager.ReactOverflowView;
4647
import com.facebook.react.uimanager.ReactPointerEventsView;
4748
import com.facebook.react.uimanager.ReactZIndexedViewGroup;
4849
import com.facebook.react.uimanager.RootView;
@@ -63,7 +64,8 @@ public class ReactViewGroup extends ViewGroup
6364
ReactClippingViewGroup,
6465
ReactPointerEventsView,
6566
ReactHitSlopView,
66-
ReactZIndexedViewGroup {
67+
ReactZIndexedViewGroup,
68+
ReactOverflowView {
6769

6870
private static final int ARRAY_CAPACITY_INCREMENT = 12;
6971
private static final int DEFAULT_BACKGROUND_COLOR = Color.TRANSPARENT;
@@ -718,6 +720,7 @@ public void setOverflow(String overflow) {
718720
invalidate();
719721
}
720722

723+
@Override
721724
public @Nullable String getOverflow() {
722725
return mOverflow;
723726
}

0 commit comments

Comments
 (0)