mirror of
https://github.com/zhigang1992/react-native.git
synced 2026-04-01 13:03:08 +08:00
Summary: ReactRootView currently intercepts and swallows all `requestDisallowInterceptTouchEvent` calls, which made sense when the ReactNativeView was the root of all views. In the context of react native views embedded in other views though, we want to propagate the call to all parents views, but not set it on the ReactRootView itself (because we still need the `onInterceptTouchEvent` calls to dispatch the touch events to JS). Reviewed By: foghina Differential Revision: D3819255 fbshipit-source-id: 21f2dd173c76e98342193de384292fef2b407250
427 lines
17 KiB
Java
427 lines
17 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;
|
|
|
|
import javax.annotation.Nullable;
|
|
|
|
import android.content.Context;
|
|
import android.graphics.Rect;
|
|
import android.os.Bundle;
|
|
import android.util.AttributeSet;
|
|
import android.util.DisplayMetrics;
|
|
import android.view.MotionEvent;
|
|
import android.view.Surface;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import android.view.ViewTreeObserver;
|
|
import android.view.WindowManager;
|
|
|
|
import com.facebook.common.logging.FLog;
|
|
import com.facebook.infer.annotation.Assertions;
|
|
import com.facebook.react.bridge.Arguments;
|
|
import com.facebook.react.bridge.ReactContext;
|
|
import com.facebook.react.bridge.UiThreadUtil;
|
|
import com.facebook.react.bridge.WritableMap;
|
|
import com.facebook.react.common.ReactConstants;
|
|
import com.facebook.react.common.annotations.VisibleForTesting;
|
|
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
|
import com.facebook.react.uimanager.DisplayMetricsHolder;
|
|
import com.facebook.react.uimanager.JSTouchDispatcher;
|
|
import com.facebook.react.uimanager.PixelUtil;
|
|
import com.facebook.react.uimanager.RootView;
|
|
import com.facebook.react.uimanager.SizeMonitoringFrameLayout;
|
|
import com.facebook.react.uimanager.UIManagerModule;
|
|
import com.facebook.react.uimanager.events.EventDispatcher;
|
|
|
|
/**
|
|
* Default root view for catalyst apps. Provides the ability to listen for size changes so that a UI
|
|
* manager can re-layout its elements.
|
|
* It delegates handling touch events for itself and child views and sending those events to JS by
|
|
* using JSTouchDispatcher.
|
|
* This view is overriding {@link ViewGroup#onInterceptTouchEvent} method in order to be notified
|
|
* about the events for all of it's children and it's also overriding
|
|
* {@link ViewGroup#requestDisallowInterceptTouchEvent} to make sure that
|
|
* {@link ViewGroup#onInterceptTouchEvent} will get events even when some child view start
|
|
* intercepting it. In case when no child view is interested in handling some particular
|
|
* touch event this view's {@link View#onTouchEvent} will still return true in order to be notified
|
|
* about all subsequent touch events related to that gesture (in case when JS code want to handle
|
|
* that gesture).
|
|
*/
|
|
public class ReactRootView extends SizeMonitoringFrameLayout implements RootView {
|
|
|
|
private @Nullable ReactInstanceManager mReactInstanceManager;
|
|
private @Nullable String mJSModuleName;
|
|
private @Nullable Bundle mLaunchOptions;
|
|
private @Nullable CustomGlobalLayoutListener mCustomGlobalLayoutListener;
|
|
private @Nullable OnGenericMotionListener mOnGenericMotionListener;
|
|
private int mRootViewTag;
|
|
private boolean mWasMeasured = false;
|
|
private boolean mIsAttachedToInstance = false;
|
|
private final JSTouchDispatcher mJSTouchDispatcher = new JSTouchDispatcher(this);
|
|
|
|
public ReactRootView(Context context) {
|
|
super(context);
|
|
}
|
|
|
|
public ReactRootView(Context context, AttributeSet attrs) {
|
|
super(context, attrs);
|
|
}
|
|
|
|
public ReactRootView(Context context, AttributeSet attrs, int defStyle) {
|
|
super(context, attrs, defStyle);
|
|
}
|
|
|
|
@Override
|
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
|
|
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
|
|
|
|
if (widthMode == MeasureSpec.UNSPECIFIED || heightMode == MeasureSpec.UNSPECIFIED) {
|
|
throw new IllegalStateException(
|
|
"The root catalyst view must have a width and height given to it by it's parent view. " +
|
|
"You can do this by specifying MATCH_PARENT or explicit width and height in the " +
|
|
"layout. widthMode=" + widthMode + ", heightMode=" + heightMode);
|
|
}
|
|
|
|
setMeasuredDimension(
|
|
MeasureSpec.getSize(widthMeasureSpec),
|
|
MeasureSpec.getSize(heightMeasureSpec));
|
|
|
|
mWasMeasured = true;
|
|
// Check if we were waiting for onMeasure to attach the root view
|
|
if (mReactInstanceManager != null && !mIsAttachedToInstance) {
|
|
// Enqueue it to UIThread not to block onMeasure waiting for the catalyst instance creation
|
|
UiThreadUtil.runOnUiThread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
attachToReactInstanceManager();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onChildStartedNativeGesture(MotionEvent androidEvent) {
|
|
if (mReactInstanceManager == null || !mIsAttachedToInstance ||
|
|
mReactInstanceManager.getCurrentReactContext() == null) {
|
|
FLog.w(
|
|
ReactConstants.TAG,
|
|
"Unable to dispatch touch to JS as the catalyst instance has not been attached");
|
|
return;
|
|
}
|
|
ReactContext reactContext = mReactInstanceManager.getCurrentReactContext();
|
|
EventDispatcher eventDispatcher = reactContext.getNativeModule(UIManagerModule.class)
|
|
.getEventDispatcher();
|
|
mJSTouchDispatcher.onChildStartedNativeGesture(androidEvent, eventDispatcher);
|
|
// Hook for containers or fragments to get informed of the on touch events to perform actions.
|
|
if (mOnGenericMotionListener != null) {
|
|
mOnGenericMotionListener.onGenericMotion(this, androidEvent);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean onInterceptTouchEvent(MotionEvent ev) {
|
|
dispatchJSTouchEvent(ev);
|
|
return super.onInterceptTouchEvent(ev);
|
|
}
|
|
|
|
@Override
|
|
public boolean onTouchEvent(MotionEvent ev) {
|
|
dispatchJSTouchEvent(ev);
|
|
super.onTouchEvent(ev);
|
|
// In case when there is no children interested in handling touch event, we return true from
|
|
// the root view in order to receive subsequent events related to that gesture
|
|
return true;
|
|
}
|
|
|
|
public void setOnGenericMotionListener(OnGenericMotionListener listener) {
|
|
mOnGenericMotionListener = listener;
|
|
}
|
|
|
|
private void dispatchJSTouchEvent(MotionEvent event) {
|
|
if (mReactInstanceManager == null || !mIsAttachedToInstance ||
|
|
mReactInstanceManager.getCurrentReactContext() == null) {
|
|
FLog.w(
|
|
ReactConstants.TAG,
|
|
"Unable to dispatch touch to JS as the catalyst instance has not been attached");
|
|
return;
|
|
}
|
|
ReactContext reactContext = mReactInstanceManager.getCurrentReactContext();
|
|
EventDispatcher eventDispatcher = reactContext.getNativeModule(UIManagerModule.class)
|
|
.getEventDispatcher();
|
|
mJSTouchDispatcher.handleTouchEvent(event, eventDispatcher);
|
|
}
|
|
|
|
@Override
|
|
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
|
|
// Override in order to still receive events to onInterceptTouchEvent even when some other
|
|
// views disallow that, but propagate it up the tree if possible.
|
|
if (getParent() != null) {
|
|
getParent().requestDisallowInterceptTouchEvent(disallowIntercept);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
|
// No-op since UIManagerModule handles actually laying out children.
|
|
}
|
|
|
|
@Override
|
|
protected void onAttachedToWindow() {
|
|
super.onAttachedToWindow();
|
|
if (mIsAttachedToInstance) {
|
|
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onDetachedFromWindow() {
|
|
super.onDetachedFromWindow();
|
|
if (mIsAttachedToInstance) {
|
|
getViewTreeObserver().removeOnGlobalLayoutListener(getCustomGlobalLayoutListener());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@see #startReactApplication(ReactInstanceManager, String, android.os.Bundle)}
|
|
*/
|
|
public void startReactApplication(ReactInstanceManager reactInstanceManager, String moduleName) {
|
|
startReactApplication(reactInstanceManager, moduleName, null);
|
|
}
|
|
|
|
/**
|
|
* Schedule rendering of the react component rendered by the JS application from the given JS
|
|
* module (@{param moduleName}) using provided {@param reactInstanceManager} to attach to the
|
|
* JS context of that manager. Extra parameter {@param launchOptions} can be used to pass initial
|
|
* properties for the react component.
|
|
*/
|
|
public void startReactApplication(
|
|
ReactInstanceManager reactInstanceManager,
|
|
String moduleName,
|
|
@Nullable Bundle launchOptions) {
|
|
UiThreadUtil.assertOnUiThread();
|
|
|
|
// TODO(6788889): Use POJO instead of bundle here, apparently we can't just use WritableMap
|
|
// here as it may be deallocated in native after passing via JNI bridge, but we want to reuse
|
|
// it in the case of re-creating the catalyst instance
|
|
Assertions.assertCondition(
|
|
mReactInstanceManager == null,
|
|
"This root view has already been attached to a catalyst instance manager");
|
|
|
|
mReactInstanceManager = reactInstanceManager;
|
|
mJSModuleName = moduleName;
|
|
mLaunchOptions = launchOptions;
|
|
|
|
if (!mReactInstanceManager.hasStartedCreatingInitialContext()) {
|
|
mReactInstanceManager.createReactContextInBackground();
|
|
}
|
|
|
|
// We need to wait for the initial onMeasure, if this view has not yet been measured, we set which
|
|
// will make this view startReactApplication itself to instance manager once onMeasure is called.
|
|
if (mWasMeasured) {
|
|
attachToReactInstanceManager();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unmount the react application at this root view, reclaiming any JS memory associated with that
|
|
* application. If {@link #startReactApplication} is called, this method must be called before the
|
|
* ReactRootView is garbage collected (typically in your Activity's onDestroy, or in your Fragment's
|
|
* onDestroyView).
|
|
*/
|
|
public void unmountReactApplication() {
|
|
if (mReactInstanceManager != null && mIsAttachedToInstance) {
|
|
mReactInstanceManager.detachRootView(this);
|
|
mIsAttachedToInstance = false;
|
|
}
|
|
}
|
|
|
|
/* package */ String getJSModuleName() {
|
|
return Assertions.assertNotNull(mJSModuleName);
|
|
}
|
|
|
|
/* package */ @Nullable Bundle getLaunchOptions() {
|
|
return mLaunchOptions;
|
|
}
|
|
|
|
/**
|
|
* Is used by unit test to setup mWasMeasured and mIsAttachedToWindow flags, that will let this
|
|
* view to be properly attached to catalyst instance by startReactApplication call
|
|
*/
|
|
@VisibleForTesting
|
|
/* package */ void simulateAttachForTesting() {
|
|
mIsAttachedToInstance = true;
|
|
mWasMeasured = true;
|
|
}
|
|
|
|
private CustomGlobalLayoutListener getCustomGlobalLayoutListener() {
|
|
if (mCustomGlobalLayoutListener == null) {
|
|
mCustomGlobalLayoutListener = new CustomGlobalLayoutListener();
|
|
}
|
|
return mCustomGlobalLayoutListener;
|
|
}
|
|
|
|
private void attachToReactInstanceManager() {
|
|
if (mIsAttachedToInstance) {
|
|
return;
|
|
}
|
|
|
|
mIsAttachedToInstance = true;
|
|
Assertions.assertNotNull(mReactInstanceManager).attachMeasuredRootView(this);
|
|
getViewTreeObserver().addOnGlobalLayoutListener(getCustomGlobalLayoutListener());
|
|
}
|
|
|
|
@Override
|
|
protected void finalize() throws Throwable {
|
|
super.finalize();
|
|
Assertions.assertCondition(
|
|
!mIsAttachedToInstance,
|
|
"The application this ReactRootView was rendering was not unmounted before the ReactRootView " +
|
|
"was garbage collected. This usually means that your application is leaking large amounts of " +
|
|
"memory. To solve this, make sure to call ReactRootView#unmountReactApplication in the onDestroy() " +
|
|
"of your hosting Activity or in the onDestroyView() of your hosting Fragment.");
|
|
}
|
|
|
|
public int getRootViewTag() {
|
|
return mRootViewTag;
|
|
}
|
|
|
|
public void setRootViewTag(int rootViewTag) {
|
|
mRootViewTag = rootViewTag;
|
|
}
|
|
|
|
private class CustomGlobalLayoutListener implements ViewTreeObserver.OnGlobalLayoutListener {
|
|
private final Rect mVisibleViewArea;
|
|
private final int mMinKeyboardHeightDetected;
|
|
|
|
private int mKeyboardHeight = 0;
|
|
private int mDeviceRotation = 0;
|
|
|
|
/* package */ CustomGlobalLayoutListener() {
|
|
mVisibleViewArea = new Rect();
|
|
mMinKeyboardHeightDetected = (int) PixelUtil.toPixelFromDIP(60);
|
|
}
|
|
|
|
@Override
|
|
public void onGlobalLayout() {
|
|
if (mReactInstanceManager == null || !mIsAttachedToInstance ||
|
|
mReactInstanceManager.getCurrentReactContext() == null) {
|
|
return;
|
|
}
|
|
checkForKeyboardEvents();
|
|
checkForDeviceOrientationChanges();
|
|
}
|
|
|
|
private void checkForKeyboardEvents() {
|
|
getRootView().getWindowVisibleDisplayFrame(mVisibleViewArea);
|
|
final int heightDiff =
|
|
DisplayMetricsHolder.getWindowDisplayMetrics().heightPixels - mVisibleViewArea.bottom;
|
|
if (mKeyboardHeight != heightDiff && heightDiff > mMinKeyboardHeightDetected) {
|
|
// keyboard is now showing, or the keyboard height has changed
|
|
mKeyboardHeight = heightDiff;
|
|
WritableMap params = Arguments.createMap();
|
|
WritableMap coordinates = Arguments.createMap();
|
|
coordinates.putDouble("screenY", PixelUtil.toDIPFromPixel(mVisibleViewArea.bottom));
|
|
coordinates.putDouble("screenX", PixelUtil.toDIPFromPixel(mVisibleViewArea.left));
|
|
coordinates.putDouble("width", PixelUtil.toDIPFromPixel(mVisibleViewArea.width()));
|
|
coordinates.putDouble("height", PixelUtil.toDIPFromPixel(mKeyboardHeight));
|
|
params.putMap("endCoordinates", coordinates);
|
|
sendEvent("keyboardDidShow", params);
|
|
} else if (mKeyboardHeight != 0 && heightDiff <= mMinKeyboardHeightDetected) {
|
|
// keyboard is now hidden
|
|
mKeyboardHeight = 0;
|
|
sendEvent("keyboardDidHide", null);
|
|
}
|
|
}
|
|
|
|
private void checkForDeviceOrientationChanges() {
|
|
final int rotation =
|
|
((WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE))
|
|
.getDefaultDisplay().getRotation();
|
|
if (mDeviceRotation == rotation) {
|
|
return;
|
|
}
|
|
mDeviceRotation = rotation;
|
|
// It's important to repopulate DisplayMetrics and export them before emitting the
|
|
// orientation change event, so that the Dimensions object returns the correct new values.
|
|
DisplayMetricsHolder.initDisplayMetrics(getContext());
|
|
emitUpdateDimensionsEvent();
|
|
emitOrientationChanged(rotation);
|
|
}
|
|
|
|
private void emitOrientationChanged(final int newRotation) {
|
|
String name;
|
|
double rotationDegrees;
|
|
boolean isLandscape = false;
|
|
|
|
switch (newRotation) {
|
|
case Surface.ROTATION_0:
|
|
name = "portrait-primary";
|
|
rotationDegrees = 0.0;
|
|
break;
|
|
case Surface.ROTATION_90:
|
|
name = "landscape-primary";
|
|
rotationDegrees = -90.0;
|
|
isLandscape = true;
|
|
break;
|
|
case Surface.ROTATION_180:
|
|
name = "portrait-secondary";
|
|
rotationDegrees = 180.0;
|
|
break;
|
|
case Surface.ROTATION_270:
|
|
name = "landscape-secondary";
|
|
rotationDegrees = 90.0;
|
|
isLandscape = true;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
WritableMap map = Arguments.createMap();
|
|
map.putString("name", name);
|
|
map.putDouble("rotationDegrees", rotationDegrees);
|
|
map.putBoolean("isLandscape", isLandscape);
|
|
|
|
sendEvent("namedOrientationDidChange", map);
|
|
}
|
|
|
|
private void emitUpdateDimensionsEvent() {
|
|
DisplayMetrics windowDisplayMetrics = DisplayMetricsHolder.getWindowDisplayMetrics();
|
|
DisplayMetrics screenDisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics();
|
|
|
|
WritableMap windowDisplayMetricsMap = Arguments.createMap();
|
|
windowDisplayMetricsMap.putInt("width", windowDisplayMetrics.widthPixels);
|
|
windowDisplayMetricsMap.putInt("height", windowDisplayMetrics.heightPixels);
|
|
windowDisplayMetricsMap.putDouble("scale", windowDisplayMetrics.density);
|
|
windowDisplayMetricsMap.putDouble("fontScale", windowDisplayMetrics.scaledDensity);
|
|
windowDisplayMetricsMap.putDouble("densityDpi", windowDisplayMetrics.densityDpi);
|
|
|
|
WritableMap screenDisplayMetricsMap = Arguments.createMap();
|
|
screenDisplayMetricsMap.putInt("width", screenDisplayMetrics.widthPixels);
|
|
screenDisplayMetricsMap.putInt("height", screenDisplayMetrics.heightPixels);
|
|
screenDisplayMetricsMap.putDouble("scale", screenDisplayMetrics.density);
|
|
screenDisplayMetricsMap.putDouble("fontScale", screenDisplayMetrics.scaledDensity);
|
|
screenDisplayMetricsMap.putDouble("densityDpi", screenDisplayMetrics.densityDpi);
|
|
|
|
WritableMap dimensionsMap = Arguments.createMap();
|
|
dimensionsMap.putMap("windowPhysicalPixels", windowDisplayMetricsMap);
|
|
dimensionsMap.putMap("screenPhysicalPixels", screenDisplayMetricsMap);
|
|
sendEvent("didUpdateDimensions", dimensionsMap);
|
|
}
|
|
|
|
private void sendEvent(String eventName, @Nullable WritableMap params) {
|
|
if (mReactInstanceManager != null) {
|
|
mReactInstanceManager.getCurrentReactContext()
|
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
|
.emit(eventName, params);
|
|
}
|
|
}
|
|
}
|
|
}
|