mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
89cf5c49b9
Summary: In FlatViewGroup, we flatten some react nodes into parent while mounting others into child Views. This is causing touch events being dispatched to wrong targets because child Views are \"stealing\" touch events from flattened Views. To fix the issue, implement ReactCompoundViewGroup to provide information about both virtual and non-virtual nodes. Reviewed By: ahmedre Differential Revision: D3018054
496 lines
16 KiB
Java
496 lines
16 KiB
Java
/**
|
|
* Copyright (c) 2015-present, Facebook, Inc.
|
|
* All rights reserved.
|
|
*
|
|
* This source code is licensed under the BSD-style license found in the
|
|
* LICENSE file in the root directory of this source tree. An additional grant
|
|
* of patent rights can be found in the PATENTS file in the same directory.
|
|
*/
|
|
|
|
package com.facebook.react.flat;
|
|
|
|
import javax.annotation.Nullable;
|
|
|
|
import java.lang.ref.WeakReference;
|
|
import java.util.ArrayList;
|
|
|
|
import android.content.Context;
|
|
import android.graphics.Canvas;
|
|
import android.graphics.Rect;
|
|
import android.graphics.drawable.Drawable;
|
|
import android.view.MotionEvent;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import android.view.ViewParent;
|
|
|
|
import com.facebook.react.bridge.ReactContext;
|
|
import com.facebook.react.bridge.SoftAssertions;
|
|
import com.facebook.react.common.SystemClock;
|
|
import com.facebook.react.touch.OnInterceptTouchEventListener;
|
|
import com.facebook.react.touch.ReactInterceptingViewGroup;
|
|
import com.facebook.react.uimanager.PointerEvents;
|
|
import com.facebook.react.uimanager.ReactCompoundViewGroup;
|
|
import com.facebook.react.uimanager.ReactPointerEventsView;
|
|
import com.facebook.react.uimanager.UIManagerModule;
|
|
import com.facebook.react.views.image.ImageLoadEvent;
|
|
|
|
/**
|
|
* A view that FlatShadowNode hierarchy maps to. Performs drawing by iterating over
|
|
* array of DrawCommands, executing them one by one.
|
|
*/
|
|
/* package */ final class FlatViewGroup extends ViewGroup
|
|
implements ReactInterceptingViewGroup, ReactCompoundViewGroup, ReactPointerEventsView {
|
|
/**
|
|
* Helper class that allows AttachDetachListener to invalidate the hosting View.
|
|
*/
|
|
static final class InvalidateCallback extends WeakReference<FlatViewGroup> {
|
|
|
|
private InvalidateCallback(FlatViewGroup view) {
|
|
super(view);
|
|
}
|
|
|
|
/**
|
|
* Propagates invalidate() call up to the hosting View (if it's still alive)
|
|
*/
|
|
public void invalidate() {
|
|
FlatViewGroup view = get();
|
|
if (view != null) {
|
|
view.invalidate();
|
|
}
|
|
}
|
|
|
|
public void dispatchImageLoadEvent(int reactTag, int imageLoadEvent) {
|
|
FlatViewGroup view = get();
|
|
if (view == null) {
|
|
return;
|
|
}
|
|
|
|
ReactContext reactContext = ((ReactContext) view.getContext());
|
|
UIManagerModule uiManagerModule = reactContext.getNativeModule(UIManagerModule.class);
|
|
uiManagerModule.getEventDispatcher().dispatchEvent(
|
|
new ImageLoadEvent(reactTag, SystemClock.nanoTime(), imageLoadEvent));
|
|
}
|
|
}
|
|
|
|
private static final ArrayList<FlatViewGroup> LAYOUT_REQUESTS = new ArrayList<>();
|
|
private static final Rect VIEW_BOUNDS = new Rect();
|
|
|
|
private @Nullable InvalidateCallback mInvalidateCallback;
|
|
private DrawCommand[] mDrawCommands = DrawCommand.EMPTY_ARRAY;
|
|
private AttachDetachListener[] mAttachDetachListeners = AttachDetachListener.EMPTY_ARRAY;
|
|
private NodeRegion[] mNodeRegions = NodeRegion.EMPTY_ARRAY;
|
|
private int mDrawChildIndex = 0;
|
|
private boolean mIsAttached = false;
|
|
private boolean mIsLayoutRequested = false;
|
|
private boolean mNeedsOffscreenAlphaCompositing = false;
|
|
private Drawable mHotspot;
|
|
private PointerEvents mPointerEvents = PointerEvents.AUTO;
|
|
private @Nullable OnInterceptTouchEventListener mOnInterceptTouchEventListener;
|
|
|
|
/* package */ FlatViewGroup(Context context) {
|
|
super(context);
|
|
setClipChildren(false);
|
|
}
|
|
|
|
@Override
|
|
protected void detachAllViewsFromParent() {
|
|
super.detachAllViewsFromParent();
|
|
}
|
|
|
|
@Override
|
|
public void requestLayout() {
|
|
if (mIsLayoutRequested) {
|
|
return;
|
|
}
|
|
|
|
mIsLayoutRequested = true;
|
|
LAYOUT_REQUESTS.add(this);
|
|
}
|
|
|
|
@Override
|
|
public int reactTagForTouch(float touchX, float touchY) {
|
|
/**
|
|
* Make sure we don't find any children if the pointer events are set to BOX_ONLY.
|
|
* There is no need to special-case any other modes, because if PointerEvents are set to:
|
|
* a) PointerEvents.AUTO - all children are included, nothing to exclude
|
|
* b) PointerEvents.NONE - this method will NOT be executed, because the View will be filtered
|
|
* out by TouchTargetHelper.
|
|
* c) PointerEvents.BOX_NONE - TouchTargetHelper will make sure that {@link #reactTagForTouch()}
|
|
* doesn't return getId().
|
|
*/
|
|
SoftAssertions.assertCondition(
|
|
mPointerEvents != PointerEvents.NONE,
|
|
"TouchTargetHelper should not allow calling this method when pointer events are NONE");
|
|
|
|
if (mPointerEvents != PointerEvents.BOX_ONLY) {
|
|
NodeRegion nodeRegion = virtualNodeRegionWithinBounds(touchX, touchY);
|
|
if (nodeRegion != null) {
|
|
return nodeRegion.getReactTag(touchX, touchY);
|
|
}
|
|
}
|
|
|
|
// no children found
|
|
return getId();
|
|
}
|
|
|
|
@Override
|
|
public boolean interceptsTouchEvent(float touchX, float touchY) {
|
|
NodeRegion nodeRegion = anyNodeRegionWithinBounds(touchX, touchY);
|
|
if (nodeRegion != null) {
|
|
return nodeRegion.mIsVirtual;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void dispatchDraw(Canvas canvas) {
|
|
super.dispatchDraw(canvas);
|
|
|
|
for (DrawCommand drawCommand : mDrawCommands) {
|
|
drawCommand.draw(this, canvas);
|
|
}
|
|
|
|
if (mDrawChildIndex != getChildCount()) {
|
|
throw new RuntimeException(
|
|
"Did not draw all children: " + mDrawChildIndex + " / " + getChildCount());
|
|
}
|
|
mDrawChildIndex = 0;
|
|
|
|
if (mHotspot != null) {
|
|
mHotspot.draw(canvas);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
|
|
// suppress
|
|
// no drawing -> no invalidate -> return false
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
|
// nothing to do here
|
|
}
|
|
|
|
@Override
|
|
protected boolean verifyDrawable(Drawable who) {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
protected void onAttachedToWindow() {
|
|
if (mIsAttached) {
|
|
// this is possible, unfortunately.
|
|
return;
|
|
}
|
|
|
|
mIsAttached = true;
|
|
|
|
super.onAttachedToWindow();
|
|
dispatchOnAttached(mAttachDetachListeners);
|
|
}
|
|
|
|
@Override
|
|
protected void onDetachedFromWindow() {
|
|
if (!mIsAttached) {
|
|
throw new RuntimeException("Double detach");
|
|
}
|
|
|
|
mIsAttached = false;
|
|
|
|
super.onDetachedFromWindow();
|
|
dispatchOnDetached(mAttachDetachListeners);
|
|
}
|
|
|
|
@Override
|
|
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
|
if (mHotspot != null) {
|
|
mHotspot.setBounds(0, 0, w, h);
|
|
invalidate();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void dispatchDrawableHotspotChanged(float x, float y) {
|
|
if (mHotspot != null) {
|
|
mHotspot.setHotspot(x, y);
|
|
invalidate();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void drawableStateChanged() {
|
|
super.drawableStateChanged();
|
|
|
|
if (mHotspot != null && mHotspot.isStateful()) {
|
|
mHotspot.setState(getDrawableState());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void jumpDrawablesToCurrentState() {
|
|
super.jumpDrawablesToCurrentState();
|
|
if (mHotspot != null) {
|
|
mHotspot.jumpToCurrentState();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void invalidate() {
|
|
// By default, invalidate() only invalidates the View's boundaries, which works great in most
|
|
// cases but may fail with overflow: visible (i.e. View clipping disabled) when View width or
|
|
// height is 0. This is because invalidate() has an optimization where it will not invalidate
|
|
// empty Views at all. A quick fix is to invalidate a slightly larger region to make sure we
|
|
// never hit that optimization.
|
|
//
|
|
// Another thing to note is that this may not work correctly with software rendering because
|
|
// in software, Android tracks dirty regions to redraw. We would need to collect information
|
|
// about all children boundaries (recursively) to track dirty region precisely.
|
|
invalidate(0, 0, getWidth() + 1, getHeight() + 1);
|
|
}
|
|
|
|
/**
|
|
* We override this to allow developers to determine whether they need offscreen alpha compositing
|
|
* or not. See the documentation of needsOffscreenAlphaCompositing in View.js.
|
|
*/
|
|
@Override
|
|
public boolean hasOverlappingRendering() {
|
|
return mNeedsOffscreenAlphaCompositing;
|
|
}
|
|
|
|
@Override
|
|
public void setOnInterceptTouchEventListener(OnInterceptTouchEventListener listener) {
|
|
mOnInterceptTouchEventListener = listener;
|
|
}
|
|
|
|
@Override
|
|
public boolean onInterceptTouchEvent(MotionEvent ev) {
|
|
if (interceptsTouchEvent(ev.getX(), ev.getY())) {
|
|
return true;
|
|
}
|
|
|
|
if (mOnInterceptTouchEventListener != null &&
|
|
mOnInterceptTouchEventListener.onInterceptTouchEvent(this, ev)) {
|
|
return true;
|
|
}
|
|
// We intercept the touch event if the children are not supposed to receive it.
|
|
if (mPointerEvents == PointerEvents.NONE || mPointerEvents == PointerEvents.BOX_ONLY) {
|
|
return true;
|
|
}
|
|
return super.onInterceptTouchEvent(ev);
|
|
}
|
|
|
|
@Override
|
|
public boolean onTouchEvent(MotionEvent ev) {
|
|
// We do not accept the touch event if this view is not supposed to receive it.
|
|
if (mPointerEvents == PointerEvents.NONE) {
|
|
return false;
|
|
}
|
|
|
|
if (mPointerEvents == PointerEvents.BOX_NONE) {
|
|
// We cannot always return false here because some child nodes could be flatten into this View
|
|
NodeRegion nodeRegion = virtualNodeRegionWithinBounds(ev.getX(), ev.getY());
|
|
if (nodeRegion == null) {
|
|
// no child to handle this touch event, bailing out.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The root view always assumes any view that was tapped wants the touch
|
|
// and sends the event to JS as such.
|
|
// We don't need to do bubbling in native (it's already happening in JS).
|
|
// For an explanation of bubbling and capturing, see
|
|
// http://javascript.info/tutorial/bubbling-and-capturing#capturing
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public PointerEvents getPointerEvents() {
|
|
return mPointerEvents;
|
|
}
|
|
|
|
/*package*/ void setPointerEvents(PointerEvents pointerEvents) {
|
|
mPointerEvents = pointerEvents;
|
|
}
|
|
|
|
/**
|
|
* See the documentation of needsOffscreenAlphaCompositing in View.js.
|
|
*/
|
|
/* package */ void setNeedsOffscreenAlphaCompositing(boolean needsOffscreenAlphaCompositing) {
|
|
mNeedsOffscreenAlphaCompositing = needsOffscreenAlphaCompositing;
|
|
}
|
|
|
|
/* package */ void setHotspot(Drawable hotspot) {
|
|
if (mHotspot != null) {
|
|
mHotspot.setCallback(null);
|
|
unscheduleDrawable(mHotspot);
|
|
}
|
|
|
|
if (hotspot != null) {
|
|
hotspot.setCallback(this);
|
|
if (hotspot.isStateful()) {
|
|
hotspot.setState(getDrawableState());
|
|
}
|
|
}
|
|
|
|
mHotspot = hotspot;
|
|
invalidate();
|
|
}
|
|
|
|
/* package */ void drawNextChild(Canvas canvas) {
|
|
View child = getChildAt(mDrawChildIndex);
|
|
if (child instanceof FlatViewGroup) {
|
|
super.drawChild(canvas, child, getDrawingTime());
|
|
} else {
|
|
// Make sure non-React Views clip properly.
|
|
canvas.save();
|
|
child.getHitRect(VIEW_BOUNDS);
|
|
canvas.clipRect(VIEW_BOUNDS);
|
|
super.drawChild(canvas, child, getDrawingTime());
|
|
canvas.restore();
|
|
}
|
|
|
|
++mDrawChildIndex;
|
|
}
|
|
|
|
/* package */ void mountDrawCommands(DrawCommand[] drawCommands) {
|
|
mDrawCommands = drawCommands;
|
|
invalidate();
|
|
}
|
|
|
|
/* package */ void mountAttachDetachListeners(AttachDetachListener[] listeners) {
|
|
if (mIsAttached) {
|
|
// Ordering of the following 2 statements is very important. While logically it makes sense to
|
|
// detach old listeners first, and only then attach new listeners, this is not very efficient,
|
|
// because a listener can be in both lists. In this case, it will be detached first and then
|
|
// re-attached immediately. This is undesirable for a couple of reasons:
|
|
// 1) performance. Detaching is slow because it may cancel an ongoing network request
|
|
// 2) it may cause flicker: an image that was already loaded may get unloaded.
|
|
//
|
|
// For this reason, we are attaching new listeners first. What this means is that listeners
|
|
// that are in both lists need to gracefully handle a secondary attach and detach events,
|
|
// (i.e. onAttach() being called when already attached, followed by a detach that should be
|
|
// ignored) turning them into no-ops. This will result in no performance loss and no flicker,
|
|
// because ongoing network requests don't get cancelled.
|
|
dispatchOnAttached(listeners);
|
|
dispatchOnDetached(mAttachDetachListeners);
|
|
}
|
|
mAttachDetachListeners = listeners;
|
|
}
|
|
|
|
/* package */ void mountNodeRegions(NodeRegion[] nodeRegions) {
|
|
mNodeRegions = nodeRegions;
|
|
}
|
|
|
|
/* package */ void mountViews(ViewResolver viewResolver, int[] viewsToAdd, int[] viewsToDetach) {
|
|
for (int viewToAdd : viewsToAdd) {
|
|
if (viewToAdd > 0) {
|
|
View view = ensureViewHasNoParent(viewResolver.getView(viewToAdd));
|
|
addViewInLayout(view, -1, ensureLayoutParams(view.getLayoutParams()), true);
|
|
} else {
|
|
View view = ensureViewHasNoParent(viewResolver.getView(-viewToAdd));
|
|
attachViewToParent(view, -1, ensureLayoutParams(view.getLayoutParams()));
|
|
}
|
|
}
|
|
|
|
for (int viewToDetach : viewsToDetach) {
|
|
removeDetachedView(viewResolver.getView(viewToDetach), false);
|
|
}
|
|
|
|
invalidate();
|
|
}
|
|
|
|
/* package */ void processLayoutRequest() {
|
|
mIsLayoutRequested = false;
|
|
for (int i = 0, childCount = getChildCount(); i != childCount; ++i) {
|
|
View child = getChildAt(i);
|
|
if (!child.isLayoutRequested()) {
|
|
continue;
|
|
}
|
|
|
|
child.measure(
|
|
MeasureSpec.makeMeasureSpec(child.getWidth(), MeasureSpec.EXACTLY),
|
|
MeasureSpec.makeMeasureSpec(child.getHeight(), MeasureSpec.EXACTLY));
|
|
child.layout(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
|
|
}
|
|
}
|
|
|
|
/* package */ static void processLayoutRequests() {
|
|
for (int i = 0, numLayoutRequests = LAYOUT_REQUESTS.size(); i != numLayoutRequests; ++i) {
|
|
FlatViewGroup flatViewGroup = LAYOUT_REQUESTS.get(i);
|
|
flatViewGroup.processLayoutRequest();
|
|
}
|
|
LAYOUT_REQUESTS.clear();
|
|
}
|
|
|
|
private @Nullable NodeRegion virtualNodeRegionWithinBounds(float touchX, float touchY) {
|
|
for (int i = mNodeRegions.length - 1; i >= 0; --i) {
|
|
NodeRegion nodeRegion = mNodeRegions[i];
|
|
if (!nodeRegion.mIsVirtual) {
|
|
// only interested in virtual nodes
|
|
continue;
|
|
}
|
|
if (nodeRegion.withinBounds(touchX, touchY)) {
|
|
return nodeRegion;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private @Nullable NodeRegion anyNodeRegionWithinBounds(float touchX, float touchY) {
|
|
for (int i = mNodeRegions.length - 1; i >= 0; --i) {
|
|
NodeRegion nodeRegion = mNodeRegions[i];
|
|
if (nodeRegion.withinBounds(touchX, touchY)) {
|
|
return nodeRegion;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private View ensureViewHasNoParent(View view) {
|
|
ViewParent oldParent = view.getParent();
|
|
if (oldParent != null) {
|
|
throw new RuntimeException(
|
|
"Cannot add view " + view + " to " + this + " while it has a parent " + oldParent);
|
|
}
|
|
|
|
return view;
|
|
}
|
|
|
|
private void dispatchOnAttached(AttachDetachListener[] listeners) {
|
|
int numListeners = listeners.length;
|
|
if (numListeners == 0) {
|
|
return;
|
|
}
|
|
|
|
InvalidateCallback callback = getInvalidateCallback();
|
|
for (AttachDetachListener listener : listeners) {
|
|
listener.onAttached(callback);
|
|
}
|
|
}
|
|
|
|
private InvalidateCallback getInvalidateCallback() {
|
|
if (mInvalidateCallback == null) {
|
|
mInvalidateCallback = new InvalidateCallback(this);
|
|
}
|
|
return mInvalidateCallback;
|
|
}
|
|
|
|
private static void dispatchOnDetached(AttachDetachListener[] listeners) {
|
|
for (AttachDetachListener listener : listeners) {
|
|
listener.onDetached();
|
|
}
|
|
}
|
|
|
|
private ViewGroup.LayoutParams ensureLayoutParams(ViewGroup.LayoutParams lp) {
|
|
if (checkLayoutParams(lp)) {
|
|
return lp;
|
|
}
|
|
return generateDefaultLayoutParams();
|
|
}
|
|
}
|