Skip to content

Commit 0b754cf

Browse files
committed
Fix to make taps on views outside parent bounds work on Android
See facebook#29039
1 parent a35cf50 commit 0b754cf

6 files changed

Lines changed: 225 additions & 63 deletions

File tree

RNTester/js/examples/PointerEvents/PointerEventsExample.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ const React = require('react');
1414

1515
const {StyleSheet, Text, View} = require('react-native');
1616

17-
class ExampleBox extends React.Component<$FlowFixMeProps, $FlowFixMeState> {
17+
type ExampleBoxComponentProps = $ReadOnly<{|
18+
onLog: (msg: string) => void,
19+
|}>;
20+
21+
type ExampleBoxProps = $ReadOnly<{|
22+
Component: React.ComponentType<ExampleBoxComponentProps>,
23+
|}>;
24+
25+
class ExampleBox extends React.Component<ExampleBoxProps, $FlowFixMeState> {
1826
state = {
1927
log: [],
2028
};
@@ -165,6 +173,50 @@ class BoxOnlyExample extends React.Component<$FlowFixMeProps> {
165173
}
166174
}
167175

176+
type OverflowExampleProps = $ReadOnly<{|
177+
overflow: 'hidden' | 'visible',
178+
onLog: (msg: string) => void,
179+
|}>;
180+
181+
class OverflowExample extends React.Component<OverflowExampleProps> {
182+
render() {
183+
const {overflow} = this.props;
184+
return (
185+
<View
186+
onTouchStart={() => this.props.onLog(`A overflow ${overflow} touched`)}
187+
style={[
188+
styles.box,
189+
styles.boxWithOverflowSet,
190+
{overflow: this.props.overflow},
191+
]}>
192+
<DemoText style={styles.text}>A: overflow: {overflow}</DemoText>
193+
<View
194+
onTouchStart={() => this.props.onLog('B overflowing touched')}
195+
style={[styles.box, styles.boxOverflowing]}>
196+
<DemoText style={styles.text}>B: overflowing</DemoText>
197+
</View>
198+
<View
199+
onTouchStart={() => this.props.onLog('C fully outside touched')}
200+
style={[styles.box, styles.boxFullyOutside]}>
201+
<DemoText style={styles.text}>C: fully outside</DemoText>
202+
</View>
203+
</View>
204+
);
205+
}
206+
}
207+
208+
class OverflowVisibleExample extends React.Component<ExampleBoxComponentProps> {
209+
render() {
210+
return <OverflowExample {...this.props} overflow="visible" />;
211+
}
212+
}
213+
214+
class OverflowHiddenExample extends React.Component<ExampleBoxComponentProps> {
215+
render() {
216+
return <OverflowExample {...this.props} overflow="hidden" />;
217+
}
218+
}
219+
168220
type ExampleClass = {
169221
Component: React.ComponentType<any>,
170222
title: string,
@@ -191,6 +243,18 @@ const exampleClasses: Array<ExampleClass> = [
191243
description:
192244
"`box-only` causes touch events on the container's child components to pass through and will only detect touch events on the container itself.",
193245
},
246+
{
247+
Component: OverflowVisibleExample,
248+
title: '`overflow: visible`',
249+
description:
250+
'`overflow: visible` style should allow subelements that are outside of the parent box to be touchable.',
251+
},
252+
{
253+
Component: OverflowHiddenExample,
254+
title: '`overflow: hidden`',
255+
description:
256+
'`overflow: hidden` style should only allow subelements within the parent box to be touchable. The part of the `position: absolute` extending outside its parent should not trigger touch events.',
257+
},
194258
];
195259

196260
const infoToExample = info => {
@@ -221,6 +285,20 @@ const styles = StyleSheet.create({
221285
boxPassedThrough: {
222286
borderColor: '#99bbee',
223287
},
288+
boxWithOverflowSet: {
289+
paddingBottom: 40,
290+
marginBottom: 50,
291+
},
292+
boxOverflowing: {
293+
position: 'absolute',
294+
top: 30,
295+
paddingBottom: 40,
296+
},
297+
boxFullyOutside: {
298+
position: 'absolute',
299+
left: 200,
300+
top: 65,
301+
},
224302
logText: {
225303
fontSize: 9,
226304
},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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.graphics.Rect;
11+
import android.view.View;
12+
13+
import androidx.annotation.Nullable;
14+
15+
/**
16+
* Interface that should be implemented by {@link View} subclasses that support {@code
17+
* overflow} style. This allows the overflow information to be used by {@link TouchTargetHelper}
18+
* to determine if a View is touchable.
19+
*/
20+
public interface ReactOverflowView {
21+
/**
22+
* Gets the overflow state of a view. If set, this should be one of {@link ViewProps#HIDDEN},
23+
* {@link ViewProps#VISIBLE} or {@link ViewProps#SCROLL}.
24+
*/
25+
@Nullable String getOverflow();
26+
}

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

Lines changed: 101 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import com.facebook.react.bridge.UiThreadUtil;
1919
import com.facebook.react.touch.ReactHitSlopView;
2020

21+
import java.util.EnumSet;
22+
2123
/**
2224
* Class responsible for identifying which react view should handle a given {@link MotionEvent}. It
2325
* uses the event coordinates to traverse the view hierarchy and return a suitable view.
@@ -80,7 +82,7 @@ public static int findTargetTagAndCoordinatesForTouch(
8082
// Store eventCoords in array so that they are modified to be relative to the targetView found.
8183
viewCoords[0] = eventX;
8284
viewCoords[1] = eventY;
83-
View nativeTargetView = findTouchTargetView(viewCoords, viewGroup);
85+
View nativeTargetView = findTouchTargetViewWithPointerEvents(viewCoords, viewGroup);
8486
if (nativeTargetView != null) {
8587
View reactTargetView = findClosestReactAncestor(nativeTargetView);
8688
if (reactTargetView != null) {
@@ -100,6 +102,20 @@ private static View findClosestReactAncestor(View view) {
100102
return view;
101103
}
102104

105+
/**
106+
* Types of allowed return values from {@link #findTouchTargetView}.
107+
*/
108+
private enum TouchTargetReturnType {
109+
/**
110+
* Allow returning the view passed in through the parameters.
111+
*/
112+
SELF,
113+
/**
114+
* Allow returning children of the view passed in through parameters.
115+
*/
116+
CHILD,
117+
}
118+
103119
/**
104120
* Returns the touch target View that is either viewGroup or one if its descendants. This is a
105121
* recursive DFS since view the entire tree must be parsed until the target is found. If the
@@ -111,18 +127,21 @@ private static View findClosestReactAncestor(View view) {
111127
* be relative to the current viewGroup. When the method returns, it will contain the eventCoords
112128
* relative to the targetView found.
113129
*/
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 =
130+
private static View findTouchTargetView(
131+
float[] eventCoords, View view, EnumSet<TouchTargetReturnType> allowReturnTouchTargetTypes) {
132+
if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.CHILD)
133+
&& view instanceof ViewGroup) {
134+
ViewGroup viewGroup = (ViewGroup) view;
135+
int childrenCount = viewGroup.getChildCount();
136+
// Consider z-index when determining the touch target.
137+
ReactZIndexedViewGroup zIndexedViewGroup =
118138
viewGroup instanceof ReactZIndexedViewGroup ? (ReactZIndexedViewGroup) viewGroup : null;
119-
for (int i = childrenCount - 1; i >= 0; i--) {
120-
int childIndex =
139+
for (int i = childrenCount - 1; i >= 0; i--) {
140+
int childIndex =
121141
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)) {
142+
View child = viewGroup.getChildAt(childIndex);
143+
PointF childPoint = mTempPoint;
144+
getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint);
126145
// If it is contained within the child View, the childPoint value will contain the view
127146
// coordinates relative to the child
128147
// We need to store the existing X,Y for the viewGroup away as it is possible this child
@@ -132,22 +151,66 @@ private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup
132151
eventCoords[0] = childPoint.x;
133152
eventCoords[1] = childPoint.y;
134153
View targetView = findTouchTargetViewWithPointerEvents(eventCoords, child);
154+
135155
if (targetView != null) {
136-
return targetView;
156+
// We don't allow touches on views that are outside the bounds of an `overflow: hidden`
157+
// View
158+
boolean inOverflowBounds = true;
159+
if (viewGroup instanceof ReactOverflowView) {
160+
@Nullable String overflow = ((ReactOverflowView) viewGroup).getOverflow();
161+
if (ViewProps.HIDDEN.equals(overflow)
162+
&& !isTouchPointInView(restoreX, restoreY, view)) {
163+
inOverflowBounds = false;
164+
}
165+
}
166+
if (inOverflowBounds) {
167+
return targetView;
168+
}
137169
}
138170
eventCoords[0] = restoreX;
139171
eventCoords[1] = restoreY;
140172
}
141173
}
142-
return viewGroup;
174+
175+
if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.SELF)
176+
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)) {
177+
return view;
178+
}
179+
180+
return null;
181+
}
182+
183+
/**
184+
* Checks whether a touch at {@code x} and {@code y} are within the bounds of the View. Both
185+
* {@code x} and {@code y} must be relative to the top-left corner of the view.
186+
*/
187+
private static boolean isTouchPointInView(float x, float y, View view) {
188+
if (view instanceof ReactHitSlopView && ((ReactHitSlopView) view).getHitSlopRect() != null) {
189+
Rect hitSlopRect = ((ReactHitSlopView) view).getHitSlopRect();
190+
if ((x >= -hitSlopRect.left
191+
&& x < (view.getRight() - view.getLeft()) + hitSlopRect.right)
192+
&& (y >= -hitSlopRect.top
193+
&& y < (view.getBottom() - view.getTop()) + hitSlopRect.bottom)) {
194+
return true;
195+
}
196+
197+
return false;
198+
} else {
199+
if ((x >= 0 && x < (view.getRight() - view.getLeft()))
200+
&& (y >= 0 && y < (view.getBottom() - view.getTop()))) {
201+
return true;
202+
}
203+
204+
return false;
205+
}
143206
}
144207

145208
/**
146-
* Returns whether the touch point is within the child View It is transform aware and will invert
209+
* Returns the coordinates of a touch in the child View. It is transform aware and will invert
147210
* the transform Matrix to find the true local points This code is taken from {@link
148211
* ViewGroup#isTransformedTouchPointInView()}
149212
*/
150-
private static boolean isTransformedTouchPointInView(
213+
private static void getChildPoint(
151214
float x, float y, ViewGroup parent, View child, PointF outLocalPoint) {
152215
float localX = x + parent.getScrollX() - child.getLeft();
153216
float localY = y + parent.getScrollY() - child.getTop();
@@ -162,26 +225,7 @@ private static boolean isTransformedTouchPointInView(
162225
localX = localXY[0];
163226
localY = localXY[1];
164227
}
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-
}
228+
outLocalPoint.set(localX, localY);
185229
}
186230

187231
/**
@@ -211,32 +255,32 @@ private static boolean isTransformedTouchPointInView(
211255
return null;
212256

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

217261
} else if (pointerEvents == PointerEvents.BOX_NONE) {
218262
// 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-
}
263+
View targetView =
264+
findTouchTargetView(eventCoords, view, EnumSet.of(TouchTargetReturnType.CHILD));
265+
if (targetView != null) {
266+
return targetView;
267+
}
224268

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-
}
269+
// PointerEvents.BOX_NONE means that this react element cannot receive pointer events.
270+
// However, there might be virtual children that can receive pointer events, in which case
271+
// we still want to return this View and dispatch a pointer event to the virtual element.
272+
// Note that this currently only applies to Nodes/FlatViewGroup as it's the only class that
273+
// is both a ViewGroup and ReactCompoundView (ReactTextView is a ReactCompoundView but not a
274+
// ViewGroup).
275+
if (view instanceof ReactCompoundView) {
276+
int reactTag =
277+
((ReactCompoundView) view).reactTagForTouch(eventCoords[0], eventCoords[1]);
278+
if (reactTag != view.getId()) {
279+
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
280+
return view;
238281
}
239282
}
283+
240284
return null;
241285

242286
} else if (pointerEvents == PointerEvents.AUTO) {
@@ -246,10 +290,8 @@ private static boolean isTransformedTouchPointInView(
246290
return view;
247291
}
248292
}
249-
if (view instanceof ViewGroup) {
250-
return findTouchTargetView(eventCoords, (ViewGroup) view);
251-
}
252-
return view;
293+
return findTouchTargetView(eventCoords, view,
294+
EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD));
253295

254296
} else {
255297
throw new JSApplicationIllegalArgumentException(

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import com.facebook.react.uimanager.MeasureSpecAssertions;
3030
import com.facebook.react.uimanager.ReactClippingViewGroup;
3131
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
32+
import com.facebook.react.uimanager.ReactOverflowView;
3233
import com.facebook.react.uimanager.ViewProps;
3334
import com.facebook.react.uimanager.events.NativeGestureUtil;
3435
import com.facebook.react.views.view.ReactViewBackgroundManager;
@@ -39,7 +40,7 @@
3940

4041
/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
4142
public class ReactHorizontalScrollView extends HorizontalScrollView
42-
implements ReactClippingViewGroup {
43+
implements ReactClippingViewGroup, ReactOverflowView {
4344

4445
private static @Nullable Field sScrollerField;
4546
private static boolean sTriedToGetScrollerField = false;
@@ -191,6 +192,11 @@ public void setOverflow(String overflow) {
191192
invalidate();
192193
}
193194

195+
@Override
196+
public @Nullable String getOverflow() {
197+
return mOverflow;
198+
}
199+
194200
@Override
195201
protected void onDraw(Canvas canvas) {
196202
getDrawingRect(mRect);

0 commit comments

Comments
 (0)