mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
e35a963bfb
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 https://github.com/facebook/react-native/issues/21455 - fixes https://github.com/facebook/react-native/issues/27061 - fixes https://github.com/facebook/react-native/issues/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: https://github.com/facebook/react-native/pull/29039 Test Plan: This can be tested with 2 examples added to the bottom of the PointerEvents page of the RNTesterApp: | Before | After | | --- | --- | |  |  | Reviewed By: ShikaSD Differential Revision: D30104853 Pulled By: JoshuaGross fbshipit-source-id: 644a109706258bfe829096354dfe477599e2db23
302 lines
13 KiB
Java
302 lines
13 KiB
Java
/*
|
|
* 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.Matrix;
|
|
import android.graphics.PointF;
|
|
import android.graphics.Rect;
|
|
import android.view.MotionEvent;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import androidx.annotation.Nullable;
|
|
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
|
|
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.
|
|
*/
|
|
public class TouchTargetHelper {
|
|
|
|
private static final float[] mEventCoords = new float[2];
|
|
private static final PointF mTempPoint = new PointF();
|
|
private static final float[] mMatrixTransformCoords = new float[2];
|
|
private static final Matrix mInverseMatrix = new Matrix();
|
|
|
|
/**
|
|
* Find touch event target view within the provided container given the coordinates provided via
|
|
* {@link MotionEvent}.
|
|
*
|
|
* @param eventX the X screen coordinate of the touch location
|
|
* @param eventY the Y screen coordinate of the touch location
|
|
* @param viewGroup the container view to traverse
|
|
* @return the react tag ID of the child view that should handle the event
|
|
*/
|
|
public static int findTargetTagForTouch(float eventX, float eventY, ViewGroup viewGroup) {
|
|
return findTargetTagAndCoordinatesForTouch(eventX, eventY, viewGroup, mEventCoords, null);
|
|
}
|
|
|
|
/**
|
|
* Find touch event target view within the provided container given the coordinates provided via
|
|
* {@link MotionEvent}.
|
|
*
|
|
* @param eventX the X screen coordinate of the touch location
|
|
* @param eventY the Y screen coordinate of the touch location
|
|
* @param viewGroup the container view to traverse
|
|
* @param nativeViewId the native react view containing this touch target
|
|
* @return the react tag ID of the child view that should handle the event
|
|
*/
|
|
public static int findTargetTagForTouch(
|
|
float eventX, float eventY, ViewGroup viewGroup, @Nullable int[] nativeViewId) {
|
|
return findTargetTagAndCoordinatesForTouch(
|
|
eventX, eventY, viewGroup, mEventCoords, nativeViewId);
|
|
}
|
|
|
|
/**
|
|
* Find touch event target view within the provided container given the coordinates provided via
|
|
* {@link MotionEvent}.
|
|
*
|
|
* @param eventX the X screen coordinate of the touch location
|
|
* @param eventY the Y screen coordinate of the touch location
|
|
* @param viewGroup the container view to traverse
|
|
* @param viewCoords an out parameter that will return the X,Y value in the target view
|
|
* @param nativeViewTag an out parameter that will return the native view id
|
|
* @return the react tag ID of the child view that should handle the event
|
|
*/
|
|
public static int findTargetTagAndCoordinatesForTouch(
|
|
float eventX,
|
|
float eventY,
|
|
ViewGroup viewGroup,
|
|
float[] viewCoords,
|
|
@Nullable int[] nativeViewTag) {
|
|
UiThreadUtil.assertOnUiThread();
|
|
int targetTag = viewGroup.getId();
|
|
// Store eventCoords in array so that they are modified to be relative to the targetView found.
|
|
viewCoords[0] = eventX;
|
|
viewCoords[1] = eventY;
|
|
View nativeTargetView = findTouchTargetViewWithPointerEvents(viewCoords, viewGroup);
|
|
if (nativeTargetView != null) {
|
|
View reactTargetView = findClosestReactAncestor(nativeTargetView);
|
|
if (reactTargetView != null) {
|
|
if (nativeViewTag != null) {
|
|
nativeViewTag[0] = reactTargetView.getId();
|
|
}
|
|
targetTag = getTouchTargetForView(reactTargetView, viewCoords[0], viewCoords[1]);
|
|
}
|
|
}
|
|
return targetTag;
|
|
}
|
|
|
|
private static View findClosestReactAncestor(View view) {
|
|
while (view != null && view.getId() <= 0) {
|
|
view = (View) view.getParent();
|
|
}
|
|
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
|
|
* search does not backtrack, it is possible to follow a branch that cannot be a target (because
|
|
* of pointerEvents). For example, if both C and E can be the target of an event: A
|
|
* (pointerEvents: auto) - B (pointerEvents: box-none) - C (pointerEvents: none) \ D
|
|
* (pointerEvents: auto) - E (pointerEvents: auto) If the search goes down the first branch, it
|
|
* would return A as the target, which is incorrect. NB: This modifies the eventCoords to always
|
|
* 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, View view, EnumSet<TouchTargetReturnType> allowReturnTouchTargetTypes) {
|
|
// We prefer returning a child, so we check for a child that can handle the touch first
|
|
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 =
|
|
zIndexedViewGroup != null ? zIndexedViewGroup.getZIndexMappedChildIndex(i) : i;
|
|
View child = viewGroup.getChildAt(childIndex);
|
|
PointF childPoint = mTempPoint;
|
|
getChildPoint(eventCoords[0], eventCoords[1], viewGroup, child, childPoint);
|
|
// 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
|
|
// will not actually be the target and so we restore them if not
|
|
float restoreX = eventCoords[0];
|
|
float restoreY = eventCoords[1];
|
|
eventCoords[0] = childPoint.x;
|
|
eventCoords[1] = childPoint.y;
|
|
View targetView = findTouchTargetViewWithPointerEvents(eventCoords, child);
|
|
|
|
if (targetView != null) {
|
|
// 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) || ViewProps.SCROLL.equals(overflow))
|
|
&& !isTouchPointInView(restoreX, restoreY, view)) {
|
|
inOverflowBounds = false;
|
|
}
|
|
}
|
|
if (inOverflowBounds) {
|
|
return targetView;
|
|
}
|
|
}
|
|
eventCoords[0] = restoreX;
|
|
eventCoords[1] = restoreY;
|
|
}
|
|
}
|
|
|
|
// Check if parent can handle the touch after the children
|
|
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.getWidth()) + hitSlopRect.right)
|
|
&& (y >= -hitSlopRect.top && y < (view.getHeight()) + hitSlopRect.bottom)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} else {
|
|
if ((x >= 0 && x < (view.getWidth())) && (y >= 0 && y < (view.getHeight()))) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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();
|
|
Matrix matrix = child.getMatrix();
|
|
if (!matrix.isIdentity()) {
|
|
float[] localXY = mMatrixTransformCoords;
|
|
localXY[0] = localX;
|
|
localXY[1] = localY;
|
|
Matrix inverseMatrix = mInverseMatrix;
|
|
matrix.invert(inverseMatrix);
|
|
inverseMatrix.mapPoints(localXY);
|
|
localX = localXY[0];
|
|
localY = localXY[1];
|
|
}
|
|
outLocalPoint.set(localX, localY);
|
|
}
|
|
|
|
/**
|
|
* Returns the touch target View of the event given, or null if neither the given View nor any of
|
|
* its descendants are the touch target.
|
|
*/
|
|
private static @Nullable View findTouchTargetViewWithPointerEvents(
|
|
float eventCoords[], View view) {
|
|
PointerEvents pointerEvents =
|
|
view instanceof ReactPointerEventsView
|
|
? ((ReactPointerEventsView) view).getPointerEvents()
|
|
: PointerEvents.AUTO;
|
|
|
|
// Views that are disabled should never be the target of pointer events. However, their children
|
|
// can be because some views (SwipeRefreshLayout) use enabled but still have children that can
|
|
// be valid targets.
|
|
if (!view.isEnabled()) {
|
|
if (pointerEvents == PointerEvents.AUTO) {
|
|
pointerEvents = PointerEvents.BOX_NONE;
|
|
} else if (pointerEvents == PointerEvents.BOX_ONLY) {
|
|
pointerEvents = PointerEvents.NONE;
|
|
}
|
|
}
|
|
|
|
if (pointerEvents == PointerEvents.NONE) {
|
|
// This view and its children can't be the target
|
|
return null;
|
|
|
|
} else if (pointerEvents == PointerEvents.BOX_ONLY) {
|
|
// 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.
|
|
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
|
|
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)) {
|
|
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) {
|
|
// Either this view or one of its children is the target
|
|
if (view instanceof ReactCompoundViewGroup
|
|
&& isTouchPointInView(eventCoords[0], eventCoords[1], view)
|
|
&& ((ReactCompoundViewGroup) view).interceptsTouchEvent(eventCoords[0], eventCoords[1])) {
|
|
return view;
|
|
}
|
|
return findTouchTargetView(
|
|
eventCoords, view, EnumSet.of(TouchTargetReturnType.SELF, TouchTargetReturnType.CHILD));
|
|
|
|
} else {
|
|
throw new JSApplicationIllegalArgumentException(
|
|
"Unknown pointer event type: " + pointerEvents.toString());
|
|
}
|
|
}
|
|
|
|
private static int getTouchTargetForView(View targetView, float eventX, float eventY) {
|
|
if (targetView instanceof ReactCompoundView) {
|
|
// Use coordinates relative to the view, which have been already computed by
|
|
// {@link #findTouchTargetView()}.
|
|
return ((ReactCompoundView) targetView).reactTagForTouch(eventX, eventY);
|
|
}
|
|
return targetView.getId();
|
|
}
|
|
}
|