/** * 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.os.SystemClock; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; 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.PixelUtil; import com.facebook.react.uimanager.RootView; import com.facebook.react.uimanager.SizeMonitoringFrameLayout; import com.facebook.react.uimanager.TouchTargetHelper; import com.facebook.react.uimanager.UIManagerModule; import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.events.TouchEvent; import com.facebook.react.uimanager.events.TouchEventType; /** * 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 is also responsible for handling touch events passed to any of it's child view's and sending * those events to JS via RCTEventEmitter module. 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 final KeyboardListener mKeyboardListener = new KeyboardListener(); private @Nullable ReactInstanceManager mReactInstanceManager; private @Nullable String mJSModuleName; private @Nullable Bundle mLaunchOptions; private int mTargetTag = -1; private boolean mChildIsHandlingNativeGesture = false; private boolean mWasMeasured = false; private boolean mAttachScheduled = false; private boolean mIsAttachedToWindow = false; private boolean mIsAttachedToInstance = false; 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."); } setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)); mWasMeasured = true; if (mAttachScheduled && mReactInstanceManager != null && mIsAttachedToWindow) { // Scheduled from {@link #startReactApplication} call in case when the view measurements are // not available mAttachScheduled = false; // Enqueue it to UIThread not to block onMeasure waiting for the catalyst instance creation UiThreadUtil.runOnUiThread(new Runnable() { @Override public void run() { Assertions.assertNotNull(mReactInstanceManager) .attachMeasuredRootView(ReactRootView.this); mIsAttachedToInstance = true; getViewTreeObserver().addOnGlobalLayoutListener(mKeyboardListener); } }); } } /** * Main catalyst view is responsible for collecting and sending touch events to JS. This method * reacts for an incoming android native touch events ({@link MotionEvent}) and calls into * {@link com.facebook.react.uimanager.events.EventDispatcher} when appropriate. * It uses {@link com.facebook.react.uimanager.TouchTargetManagerHelper#findTouchTargetView} * helper method for figuring out a react view ID in the case of ACTION_DOWN * event (when the gesture starts). */ private void handleTouchEvent(MotionEvent ev) { if (mReactInstanceManager == null || !mIsAttachedToInstance || mReactInstanceManager.getCurrentReactContext() == null) { FLog.w( ReactConstants.TAG, "Unable to handle touch in JS as the catalyst instance has not been attached"); return; } int action = ev.getAction() & MotionEvent.ACTION_MASK; ReactContext reactContext = mReactInstanceManager.getCurrentReactContext(); EventDispatcher eventDispatcher = reactContext.getNativeModule(UIManagerModule.class) .getEventDispatcher(); if (action == MotionEvent.ACTION_DOWN) { if (mTargetTag != -1) { FLog.e( ReactConstants.TAG, "Got DOWN touch before receiving UP or CANCEL from last gesture"); } // First event for this gesture. We expect tag to be set to -1, and we use helper method // {@link #findTargetTagForTouch} to find react view ID that will be responsible for handling // this gesture mChildIsHandlingNativeGesture = false; mTargetTag = TouchTargetHelper.findTargetTagForTouch(ev.getY(), ev.getX(), this); eventDispatcher.dispatchEvent( new TouchEvent(mTargetTag, SystemClock.uptimeMillis(),TouchEventType.START, ev)); } else if (mChildIsHandlingNativeGesture) { // If the touch was intercepted by a child, we've already sent a cancel event to JS for this // gesture, so we shouldn't send any more touches related to it. return; } else if (mTargetTag == -1) { // All the subsequent action types are expected to be called after ACTION_DOWN thus target // is supposed to be set for them. FLog.e( ReactConstants.TAG, "Unexpected state: received touch event but didn't get starting ACTION_DOWN for this " + "gesture before"); } else if (action == MotionEvent.ACTION_UP) { // End of the gesture. We reset target tag to -1 and expect no further event associated with // this gesture. eventDispatcher.dispatchEvent( new TouchEvent(mTargetTag, SystemClock.uptimeMillis(), TouchEventType.END, ev)); mTargetTag = -1; } else if (action == MotionEvent.ACTION_MOVE) { // Update pointer position for current gesture eventDispatcher.dispatchEvent( new TouchEvent(mTargetTag, SystemClock.uptimeMillis(), TouchEventType.MOVE, ev)); } else if (action == MotionEvent.ACTION_POINTER_DOWN) { // New pointer goes down, this can only happen after ACTION_DOWN is sent for the first pointer eventDispatcher.dispatchEvent( new TouchEvent(mTargetTag, SystemClock.uptimeMillis(), TouchEventType.START, ev)); } else if (action == MotionEvent.ACTION_POINTER_UP) { // Exactly onw of the pointers goes up eventDispatcher.dispatchEvent( new TouchEvent(mTargetTag, SystemClock.uptimeMillis(), TouchEventType.END, ev)); } else if (action == MotionEvent.ACTION_CANCEL) { dispatchCancelEvent(ev); mTargetTag = -1; } else { FLog.w( ReactConstants.TAG, "Warning : touch event was ignored. Action=" + action + " Target=" + mTargetTag); } } @Override public void onChildStartedNativeGesture(MotionEvent androidEvent) { if (mChildIsHandlingNativeGesture) { // This means we previously had another child start handling this native gesture and now a // different native parent of that child has decided to intercept the touch stream and handle // the gesture itself. Example where this can happen: HorizontalScrollView in a ScrollView. return; } dispatchCancelEvent(androidEvent); mChildIsHandlingNativeGesture = true; mTargetTag = -1; } private void dispatchCancelEvent(MotionEvent androidEvent) { // This means the gesture has already ended, via some other CANCEL or UP event. This is not // expected to happen very often as it would mean some child View has decided to intercept the // touch stream and start a native gesture only upon receiving the UP/CANCEL event. if (mTargetTag == -1) { FLog.w( ReactConstants.TAG, "Can't cancel already finished gesture. Is a child View trying to start a gesture from " + "an UP/CANCEL event?"); return; } EventDispatcher eventDispatcher = mReactInstanceManager.getCurrentReactContext() .getNativeModule(UIManagerModule.class) .getEventDispatcher(); Assertions.assertCondition( !mChildIsHandlingNativeGesture, "Expected to not have already sent a cancel for this gesture"); Assertions.assertNotNull(eventDispatcher).dispatchEvent( new TouchEvent( mTargetTag, SystemClock.uptimeMillis(), TouchEventType.CANCEL, androidEvent)); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { handleTouchEvent(ev); return super.onInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent ev) { handleTouchEvent(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; } @Override public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { // No-op - override in order to still receive events to onInterceptTouchEvent // even when some other view disallow that } @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 onDetachedFromWindow() { super.onDetachedFromWindow(); mIsAttachedToWindow = false; if (mReactInstanceManager != null && !mAttachScheduled) { mReactInstanceManager.detachRootView(this); mIsAttachedToInstance = false; getViewTreeObserver().removeOnGlobalLayoutListener(mKeyboardListener); } } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); mIsAttachedToWindow = true; // If the view re-attached and catalyst instance has been set before, we'd attach again to the // catalyst instance (expecting measure to be called after {@link onAttachedToWindow}) if (mReactInstanceManager != null) { mAttachScheduled = true; } } /** * {@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) { // 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; // We need to wait for the initial onMeasure, if this view has not yet been measured, we set // mAttachScheduled flag, which will make this view startReactApplication itself to instance // manager once onMeasure is called. if (mWasMeasured && mIsAttachedToWindow) { mReactInstanceManager.attachMeasuredRootView(this); mIsAttachedToInstance = true; getViewTreeObserver().addOnGlobalLayoutListener(mKeyboardListener); } else { mAttachScheduled = true; } } /* 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() { mIsAttachedToWindow = true; mIsAttachedToInstance = true; mWasMeasured = true; } private class KeyboardListener implements ViewTreeObserver.OnGlobalLayoutListener { private int mKeyboardHeight = 0; private final Rect mVisibleViewArea = new Rect(); @Override public void onGlobalLayout() { if (mReactInstanceManager == null || !mIsAttachedToInstance || mReactInstanceManager.getCurrentReactContext() == null) { FLog.w( ReactConstants.TAG, "Unable to dispatch keyboard events in JS as the react instance has not been attached"); return; } getRootView().getWindowVisibleDisplayFrame(mVisibleViewArea); final int heightDiff = DisplayMetricsHolder.getDisplayMetrics().heightPixels - mVisibleViewArea.bottom; if (mKeyboardHeight != heightDiff && heightDiff > 0) { // 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 == 0) { // keyboard is now hidden mKeyboardHeight = heightDiff; sendEvent("keyboardDidHide", null); } } private void sendEvent(String eventName, @Nullable WritableMap params) { if (mReactInstanceManager != null) { mReactInstanceManager.getCurrentReactContext() .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit(eventName, params); } } } }