Skip to content

Commit

Permalink
Fix to make taps on views outside parent bounds work on Android
Browse files Browse the repository at this point in the history
  • Loading branch information
hsource committed Jun 3, 2020
1 parent a35cf50 commit 0b754cf
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 63 deletions.
80 changes: 79 additions & 1 deletion RNTester/js/examples/PointerEvents/PointerEventsExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ const React = require('react');

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

class ExampleBox extends React.Component<$FlowFixMeProps, $FlowFixMeState> {
type ExampleBoxComponentProps = $ReadOnly<{|
onLog: (msg: string) => void,
|}>;

type ExampleBoxProps = $ReadOnly<{|
Component: React.ComponentType<ExampleBoxComponentProps>,
|}>;

class ExampleBox extends React.Component<ExampleBoxProps, $FlowFixMeState> {
state = {
log: [],
};
Expand Down Expand Up @@ -165,6 +173,50 @@ class BoxOnlyExample extends React.Component<$FlowFixMeProps> {
}
}

type OverflowExampleProps = $ReadOnly<{|
overflow: 'hidden' | 'visible',
onLog: (msg: string) => void,
|}>;

class OverflowExample extends React.Component<OverflowExampleProps> {
render() {
const {overflow} = this.props;
return (
<View
onTouchStart={() => this.props.onLog(`A overflow ${overflow} touched`)}
style={[
styles.box,
styles.boxWithOverflowSet,
{overflow: this.props.overflow},
]}>
<DemoText style={styles.text}>A: overflow: {overflow}</DemoText>
<View
onTouchStart={() => this.props.onLog('B overflowing touched')}
style={[styles.box, styles.boxOverflowing]}>
<DemoText style={styles.text}>B: overflowing</DemoText>
</View>
<View
onTouchStart={() => this.props.onLog('C fully outside touched')}
style={[styles.box, styles.boxFullyOutside]}>
<DemoText style={styles.text}>C: fully outside</DemoText>
</View>
</View>
);
}
}

class OverflowVisibleExample extends React.Component<ExampleBoxComponentProps> {
render() {
return <OverflowExample {...this.props} overflow="visible" />;
}
}

class OverflowHiddenExample extends React.Component<ExampleBoxComponentProps> {
render() {
return <OverflowExample {...this.props} overflow="hidden" />;
}
}

type ExampleClass = {
Component: React.ComponentType<any>,
title: string,
Expand All @@ -191,6 +243,18 @@ const exampleClasses: Array<ExampleClass> = [
description:
"`box-only` causes touch events on the container's child components to pass through and will only detect touch events on the container itself.",
},
{
Component: OverflowVisibleExample,
title: '`overflow: visible`',
description:
'`overflow: visible` style should allow subelements that are outside of the parent box to be touchable.',
},
{
Component: OverflowHiddenExample,
title: '`overflow: hidden`',
description:
'`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.',
},
];

const infoToExample = info => {
Expand Down Expand Up @@ -221,6 +285,20 @@ const styles = StyleSheet.create({
boxPassedThrough: {
borderColor: '#99bbee',
},
boxWithOverflowSet: {
paddingBottom: 40,
marginBottom: 50,
},
boxOverflowing: {
position: 'absolute',
top: 30,
paddingBottom: 40,
},
boxFullyOutside: {
position: 'absolute',
left: 200,
top: 65,
},
logText: {
fontSize: 9,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.uimanager;

import android.graphics.Rect;
import android.view.View;

import androidx.annotation.Nullable;

/**
* Interface that should be implemented by {@link View} subclasses that support {@code
* overflow} style. This allows the overflow information to be used by {@link TouchTargetHelper}
* to determine if a View is touchable.
*/
public interface ReactOverflowView {
/**
* Gets the overflow state of a view. If set, this should be one of {@link ViewProps#HIDDEN},
* {@link ViewProps#VISIBLE} or {@link ViewProps#SCROLL}.
*/
@Nullable String getOverflow();
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.touch.ReactHitSlopView;

import java.util.EnumSet;

/**
* Class responsible for identifying which react view should handle a given {@link MotionEvent}. It
* uses the event coordinates to traverse the view hierarchy and return a suitable view.
Expand Down Expand Up @@ -80,7 +82,7 @@ public static int findTargetTagAndCoordinatesForTouch(
// Store eventCoords in array so that they are modified to be relative to the targetView found.
viewCoords[0] = eventX;
viewCoords[1] = eventY;
View nativeTargetView = findTouchTargetView(viewCoords, viewGroup);
View nativeTargetView = findTouchTargetViewWithPointerEvents(viewCoords, viewGroup);
if (nativeTargetView != null) {
View reactTargetView = findClosestReactAncestor(nativeTargetView);
if (reactTargetView != null) {
Expand All @@ -100,6 +102,20 @@ private static View findClosestReactAncestor(View view) {
return view;
}

/**
* Types of allowed return values from {@link #findTouchTargetView}.
*/
private enum TouchTargetReturnType {
/**
* Allow returning the view passed in through the parameters.
*/
SELF,
/**
* Allow returning children of the view passed in through parameters.
*/
CHILD,
}

/**
* Returns the touch target View that is either viewGroup or one if its descendants. This is a
* recursive DFS since view the entire tree must be parsed until the target is found. If the
Expand All @@ -111,18 +127,21 @@ private static View findClosestReactAncestor(View view) {
* be relative to the current viewGroup. When the method returns, it will contain the eventCoords
* relative to the targetView found.
*/
private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup) {
int childrenCount = viewGroup.getChildCount();
// Consider z-index when determining the touch target.
ReactZIndexedViewGroup zIndexedViewGroup =
private static View findTouchTargetView(
float[] eventCoords, View view, EnumSet<TouchTargetReturnType> allowReturnTouchTargetTypes) {
if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.CHILD)
&& view instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) view;
int childrenCount = viewGroup.getChildCount();
// Consider z-index when determining the touch target.
ReactZIndexedViewGroup zIndexedViewGroup =
viewGroup instanceof ReactZIndexedViewGroup ? (ReactZIndexedViewGroup) viewGroup : null;
for (int i = childrenCount - 1; i >= 0; i--) {
int childIndex =
for (int i = childrenCount - 1; i >= 0; i--) {
int childIndex =
zIndexedViewGroup != null ? zIndexedViewGroup.getZIndexMappedChildIndex(i) : i;
View child = viewGroup.getChildAt(childIndex);
PointF childPoint = mTempPoint;
if (isTransformedTouchPointInView(
eventCoords[0], eventCoords[1], viewGroup, child, childPoint)) {
View child = viewGroup.getChildAt(childIndex);
PointF childPoint = mTempPoint;
getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint);
// If it is contained within the child View, the childPoint value will contain the view
// coordinates relative to the child
// We need to store the existing X,Y for the viewGroup away as it is possible this child
Expand All @@ -132,22 +151,66 @@ private static View findTouchTargetView(float[] eventCoords, ViewGroup viewGroup
eventCoords[0] = childPoint.x;
eventCoords[1] = childPoint.y;
View targetView = findTouchTargetViewWithPointerEvents(eventCoords, child);

if (targetView != null) {
return targetView;
// We don't allow touches on views that are outside the bounds of an `overflow: hidden`
// View
boolean inOverflowBounds = true;
if (viewGroup instanceof ReactOverflowView) {
@Nullable String overflow = ((ReactOverflowView) viewGroup).getOverflow();
if (ViewProps.HIDDEN.equals(overflow)
&& !isTouchPointInView(restoreX, restoreY, view)) {
inOverflowBounds = false;
}
}
if (inOverflowBounds) {
return targetView;
}
}
eventCoords[0] = restoreX;
eventCoords[1] = restoreY;
}
}
return viewGroup;

if (allowReturnTouchTargetTypes.contains(TouchTargetReturnType.SELF)
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)) {
return view;
}

return null;
}

/**
* Checks whether a touch at {@code x} and {@code y} are within the bounds of the View. Both
* {@code x} and {@code y} must be relative to the top-left corner of the view.
*/
private static boolean isTouchPointInView(float x, float y, View view) {
if (view instanceof ReactHitSlopView && ((ReactHitSlopView) view).getHitSlopRect() != null) {
Rect hitSlopRect = ((ReactHitSlopView) view).getHitSlopRect();
if ((x >= -hitSlopRect.left
&& x < (view.getRight() - view.getLeft()) + hitSlopRect.right)
&& (y >= -hitSlopRect.top
&& y < (view.getBottom() - view.getTop()) + hitSlopRect.bottom)) {
return true;
}

return false;
} else {
if ((x >= 0 && x < (view.getRight() - view.getLeft()))
&& (y >= 0 && y < (view.getBottom() - view.getTop()))) {
return true;
}

return false;
}
}

/**
* Returns whether the touch point is within the child View It is transform aware and will invert
* Returns the coordinates of a touch in the child View. It is transform aware and will invert
* the transform Matrix to find the true local points This code is taken from {@link
* ViewGroup#isTransformedTouchPointInView()}
*/
private static boolean isTransformedTouchPointInView(
private static void getChildPoint(
float x, float y, ViewGroup parent, View child, PointF outLocalPoint) {
float localX = x + parent.getScrollX() - child.getLeft();
float localY = y + parent.getScrollY() - child.getTop();
Expand All @@ -162,26 +225,7 @@ private static boolean isTransformedTouchPointInView(
localX = localXY[0];
localY = localXY[1];
}
if (child instanceof ReactHitSlopView && ((ReactHitSlopView) child).getHitSlopRect() != null) {
Rect hitSlopRect = ((ReactHitSlopView) child).getHitSlopRect();
if ((localX >= -hitSlopRect.left
&& localX < (child.getRight() - child.getLeft()) + hitSlopRect.right)
&& (localY >= -hitSlopRect.top
&& localY < (child.getBottom() - child.getTop()) + hitSlopRect.bottom)) {
outLocalPoint.set(localX, localY);
return true;
}

return false;
} else {
if ((localX >= 0 && localX < (child.getRight() - child.getLeft()))
&& (localY >= 0 && localY < (child.getBottom() - child.getTop()))) {
outLocalPoint.set(localX, localY);
return true;
}

return false;
}
outLocalPoint.set(localX, localY);
}

/**
Expand Down Expand Up @@ -211,32 +255,32 @@ private static boolean isTransformedTouchPointInView(
return null;

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

} else if (pointerEvents == PointerEvents.BOX_NONE) {
// This view can't be the target, but its children might.
if (view instanceof ViewGroup) {
View targetView = findTouchTargetView(eventCoords, (ViewGroup) view);
if (targetView != view) {
return targetView;
}
View targetView =
findTouchTargetView(eventCoords, view, EnumSet.of(TouchTargetReturnType.CHILD));
if (targetView != null) {
return targetView;
}

// PointerEvents.BOX_NONE means that this react element cannot receive pointer events.
// However, there might be virtual children that can receive pointer events, in which case
// we still want to return this View and dispatch a pointer event to the virtual element.
// Note that this currently only applies to Nodes/FlatViewGroup as it's the only class that
// is both a ViewGroup and ReactCompoundView (ReactTextView is a ReactCompoundView but not a
// ViewGroup).
if (view instanceof ReactCompoundView) {
int reactTag =
((ReactCompoundView) view).reactTagForTouch(eventCoords[0], eventCoords[1]);
if (reactTag != view.getId()) {
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
return view;
}
// PointerEvents.BOX_NONE means that this react element cannot receive pointer events.
// However, there might be virtual children that can receive pointer events, in which case
// we still want to return this View and dispatch a pointer event to the virtual element.
// Note that this currently only applies to Nodes/FlatViewGroup as it's the only class that
// is both a ViewGroup and ReactCompoundView (ReactTextView is a ReactCompoundView but not a
// ViewGroup).
if (view instanceof ReactCompoundView) {
int reactTag =
((ReactCompoundView) view).reactTagForTouch(eventCoords[0], eventCoords[1]);
if (reactTag != view.getId()) {
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
return view;
}
}

return null;

} else if (pointerEvents == PointerEvents.AUTO) {
Expand All @@ -246,10 +290,8 @@ private static boolean isTransformedTouchPointInView(
return view;
}
}
if (view instanceof ViewGroup) {
return findTouchTargetView(eventCoords, (ViewGroup) view);
}
return view;
return findTouchTargetView(eventCoords, view,
EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD));

} else {
throw new JSApplicationIllegalArgumentException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.facebook.react.uimanager.MeasureSpecAssertions;
import com.facebook.react.uimanager.ReactClippingViewGroup;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.ReactOverflowView;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.view.ReactViewBackgroundManager;
Expand All @@ -39,7 +40,7 @@

/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
public class ReactHorizontalScrollView extends HorizontalScrollView
implements ReactClippingViewGroup {
implements ReactClippingViewGroup, ReactOverflowView {

private static @Nullable Field sScrollerField;
private static boolean sTriedToGetScrollerField = false;
Expand Down Expand Up @@ -191,6 +192,11 @@ public void setOverflow(String overflow) {
invalidate();
}

@Override
public @Nullable String getOverflow() {
return mOverflow;
}

@Override
protected void onDraw(Canvas canvas) {
getDrawingRect(mRect);
Expand Down
Loading

0 comments on commit 0b754cf

Please sign in to comment.