diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js
index 4d8c4738321900..3669dba9da1e1d 100644
--- a/Libraries/Components/View/View.js
+++ b/Libraries/Components/View/View.js
@@ -258,6 +258,35 @@ const View = React.createClass({
* Used to locate this view in end-to-end tests.
*
* > This disables the 'layout-only view removal' optimization for this view!
+ *
+ * ### Support for resource-id on Android
+ *
+ * By default this value is passed to the underlying View's
+ * [setTag](https://developer.android.com/reference/android/view/View.html#setTag(java.lang.Object))
+ * method on android. Very few end to end testing frameworks support looking up
+ * a View by tag, let alone `uiautomatorviewer` which only supports `resource-id`, `text`, and `XPath`.
+ *
+ * While `react-native` does _not_ utilize XML based layouts for android Views it
+ * is still possible to add [android:id](https://developer.android.com/reference/android/view/View.html#attr_android:id)
+ * to the underlying View in order to support
+ * [findViewById](https://developer.android.com/reference/android/app/Activity.html#findViewById(int)).
+ *
+ * This is achieved by:
+ *
+ * 1. Defining a resource id in your android project's `res` folder (typically at
+ * `./android/app/src/main/res/values/ids.xml`.
+ *
+ * 1. Adding your resource ids to `ids.xml` E.G.
+ *
+ * ```xml
+ *
+ *
+ *
+ *
+ *
+ * ```
+ * 1. Using the resource id as `testID` E.G. ``.
+ *
*/
testID: PropTypes.string,
diff --git a/ReactAndroid/src/main/java/com/facebook/react/XReactInstanceManagerImpl.java b/ReactAndroid/src/main/java/com/facebook/react/XReactInstanceManagerImpl.java
index 239e2e5b3043b8..1ff2bff7b082fd 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/XReactInstanceManagerImpl.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/XReactInstanceManagerImpl.java
@@ -86,6 +86,7 @@
import static com.facebook.react.bridge.ReactMarkerConstants.RUN_JS_BUNDLE_START;
import static com.facebook.react.bridge.ReactMarkerConstants.SETUP_REACT_CONTEXT_END;
import static com.facebook.react.bridge.ReactMarkerConstants.SETUP_REACT_CONTEXT_START;
+import static com.facebook.react.uimanager.BaseViewManager.getOriginalReactTag;
import static com.facebook.systrace.Systrace.TRACE_TAG_REACT_JAVA_BRIDGE;
/**
@@ -811,7 +812,7 @@ private void detachViewFromInstance(
CatalystInstance catalystInstance) {
UiThreadUtil.assertOnUiThread();
catalystInstance.getJSModule(AppRegistry.class)
- .unmountApplicationComponentAtRootTag(rootView.getId());
+ .unmountApplicationComponentAtRootTag(getOriginalReactTag(rootView));
}
private void tearDownReactContext(ReactContext reactContext) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/touch/JSResponderHandler.java b/ReactAndroid/src/main/java/com/facebook/react/touch/JSResponderHandler.java
index 7140e2c303fea8..777ae432a09296 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/touch/JSResponderHandler.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/touch/JSResponderHandler.java
@@ -15,6 +15,8 @@
import android.view.ViewGroup;
import android.view.ViewParent;
+import static com.facebook.react.uimanager.BaseViewManager.getOriginalReactTag;
+
/**
* This class coordinates JSResponder commands for {@link UIManagerModule}. It should be set as
* OnInterceptTouchEventListener for all newly created native views that implements
@@ -70,7 +72,7 @@ public boolean onInterceptTouchEvent(ViewGroup v, MotionEvent event) {
// Therefore since "UP" event is the last event in a gesture, we should just let it reach the
// original target that is a child view of {@param v}.
// http://developer.android.com/reference/android/view/ViewGroup.html#onInterceptTouchEvent(android.view.MotionEvent)
- return v.getId() == currentJSResponder;
+ return getOriginalReactTag(v) == currentJSResponder;
}
return false;
}
diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java
index 815bad0faddcc5..917fc0e245ac71 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java
@@ -5,10 +5,11 @@
import android.graphics.Color;
import android.os.Build;
import android.view.View;
-import android.view.ViewGroup;
import com.facebook.react.bridge.ReadableArray;
+import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.uimanager.annotations.ReactProp;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Base class that should be suitable for the majority of subclasses of {@link ViewManager}.
@@ -35,6 +36,9 @@ public abstract class BaseViewManager TEST_IDS = new ConcurrentHashMap<>();
+ private static final ConcurrentHashMap ORIGINAL_REACT_TAGS = new ConcurrentHashMap<>();
+
/**
* Used to locate views in end-to-end (UI) tests.
*/
@@ -84,6 +88,14 @@ public void setRenderToHardwareTexture(T view, boolean useHWTexture) {
@ReactProp(name = PROP_TEST_ID)
public void setTestId(T view, String testId) {
+ if (!TEST_IDS.containsKey(testId)) {
+ TEST_IDS.put(testId, view.getResources().getIdentifier(testId, "id", view.getContext().getPackageName()));
+ }
+ int mappedTestId = TEST_IDS.get(testId);
+ if (mappedTestId != 0) {
+ ORIGINAL_REACT_TAGS.put(System.identityHashCode(view), view.getId());
+ view.setId(mappedTestId);
+ }
view.setTag(testId);
}
@@ -153,6 +165,28 @@ public void setAccessibilityLiveRegion(T view, String liveRegion) {
}
}
+ /**
+ * Returns the tag originally generated by the JS when the view was created prior to
+ * it being potentially overwritten by {@link #setTestId}.
+ *
+ * @param view
+ * @param
+ * @return
+ */
+ public static int getOriginalReactTag(T view) {
+ Integer idFromJs = ORIGINAL_REACT_TAGS.get(System.identityHashCode(view));
+ return idFromJs != null ? idFromJs.intValue() : view.getId();
+ }
+
+ /**
+ * Used internally to clear the state of test ids.
+ */
+ @VisibleForTesting
+ static void resetTestState() {
+ ORIGINAL_REACT_TAGS.clear();
+ TEST_IDS.clear();
+ }
+
private static void setTransformProperty(View view, ReadableArray transforms) {
TransformHelper.processTransform(transforms, sTransformDecompositionArray);
MatrixMathHelper.decomposeMatrix(sTransformDecompositionArray, sMatrixDecompositionContext);
diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java
index 3d6ad539a58129..9b7287d66d616a 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java
@@ -39,6 +39,8 @@
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
+import static com.facebook.react.uimanager.BaseViewManager.getOriginalReactTag;
+
/**
* Delegate of {@link UIManagerModule} that owns the native view hierarchy and mapping between
* native view names used in JS and corresponding instances of {@link ViewManager}. The
@@ -212,9 +214,10 @@ public void createView(
mTagsToViews.put(tag, view);
mTagsToViewManagers.put(tag, viewManager);
- // Use android View id field to store React tag. This is possible since we don't inflate
- // React views from layout xmls. Thus it is easier to just reuse that field instead of
- // creating another (potentially much more expensive) mapping from view to React tag
+ // Use android View id field to store React tag. Because testID will override this value
+ // (see {@link BaseViewManager#setTestId}) it is necessary to use
+ // {@link BaseViewManaget#getOriginalReactTag} whenever this original value is needed
+ // e.g. when communicating with the Shadow DOM or with JS about a particular node.
view.setId(tag);
if (initialProps != null) {
viewManager.updateProperties(view, initialProps);
@@ -358,7 +361,7 @@ public void manageChildren(
if (mLayoutAnimationEnabled &&
mLayoutAnimator.shouldAnimateLayout(viewToRemove) &&
- arrayContains(tagsToDelete, viewToRemove.getId())) {
+ arrayContains(tagsToDelete, getOriginalReactTag(viewToRemove))) {
// The view will be removed and dropped by the 'delete' layout animation
// instead, so do nothing
} else {
@@ -512,24 +515,25 @@ protected final void addRootViewGroup(
*/
protected void dropView(View view) {
UiThreadUtil.assertOnUiThread();
- if (!mRootTags.get(view.getId())) {
+ int originalReactTag = getOriginalReactTag(view);
+ if (!mRootTags.get(originalReactTag)) {
// For non-root views we notify viewmanager with {@link ViewManager#onDropInstance}
- resolveViewManager(view.getId()).onDropViewInstance(view);
+ resolveViewManager(originalReactTag).onDropViewInstance(view);
}
- ViewManager viewManager = mTagsToViewManagers.get(view.getId());
+ ViewManager viewManager = mTagsToViewManagers.get(originalReactTag);
if (view instanceof ViewGroup && viewManager instanceof ViewGroupManager) {
ViewGroup viewGroup = (ViewGroup) view;
ViewGroupManager viewGroupManager = (ViewGroupManager) viewManager;
for (int i = viewGroupManager.getChildCount(viewGroup) - 1; i >= 0; i--) {
View child = viewGroupManager.getChildAt(viewGroup, i);
- if (mTagsToViews.get(child.getId()) != null) {
+ if (mTagsToViews.get(getOriginalReactTag(child)) != null) {
dropView(child);
}
}
viewGroupManager.removeAllViews(viewGroup);
}
- mTagsToViews.remove(view.getId());
- mTagsToViewManagers.remove(view.getId());
+ mTagsToViews.remove(originalReactTag);
+ mTagsToViewManagers.remove(originalReactTag);
}
public void removeRootView(int rootViewTag) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java
index 4ef74fafc941f5..a98aa6130329be 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.java
@@ -22,6 +22,8 @@
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.touch.ReactHitSlopView;
+import static com.facebook.react.uimanager.BaseViewManager.getOriginalReactTag;
+
/**
* 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.
@@ -87,7 +89,7 @@ public static int findTargetTagAndCoordinatesForTouch(
float[] viewCoords,
@Nullable int[] nativeViewTag) {
UiThreadUtil.assertOnUiThread();
- int targetTag = viewGroup.getId();
+ int targetTag = getOriginalReactTag(viewGroup);
// Store eventCoords in array so that they are modified to be relative to the targetView found.
viewCoords[0] = eventX;
viewCoords[1] = eventY;
@@ -224,7 +226,7 @@ private static boolean isTransformedTouchPointInView(
// ViewGroup).
if (view instanceof ReactCompoundView) {
int reactTag = ((ReactCompoundView)view).reactTagForTouch(eventCoords[0], eventCoords[1]);
- if (reactTag != view.getId()) {
+ if (reactTag != getOriginalReactTag(view)) {
// make sure we exclude the View itself because of the PointerEvents.BOX_NONE
return view;
}
@@ -256,7 +258,7 @@ private static int getTouchTargetForView(View targetView, float eventX, float ev
// {@link #findTouchTargetView()}.
return ((ReactCompoundView) targetView).reactTagForTouch(eventX, eventY);
}
- return targetView.getId();
+ return getOriginalReactTag(targetView);
}
}
diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/Event.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/Event.java
index 4da9ed777d3758..1017526affd91b 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/Event.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/Event.java
@@ -9,6 +9,8 @@
package com.facebook.react.uimanager.events;
+import android.view.View;
+
import com.facebook.react.common.SystemClock;
/**
@@ -44,6 +46,11 @@ protected void init(int viewTag) {
}
/**
+ * TODO: Determine if this can be affected by testID. Specifically: When an event is dispatched
+ * to JS does the bridge need the Id or the tag? If the tag is needed, then
+ * {@link com.facebook.react.uimanager.BaseViewManager#getOriginalReactTag(View)}
+ * will be needed whenever an event is created.
+ *
* @return the view id for the view that generated this event
*/
public final int getViewTag() {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java
index 50828a80ba6818..cbf8b625ad8d61 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java
@@ -26,6 +26,8 @@
import com.facebook.react.uimanager.ViewDefaults;
import com.facebook.react.views.view.ReactViewBackgroundDrawable;
+import static com.facebook.react.uimanager.BaseViewManager.getOriginalReactTag;
+
public class ReactTextView extends TextView implements ReactCompoundView {
private static final ViewGroup.LayoutParams EMPTY_LAYOUT_PARAMS =
@@ -74,7 +76,7 @@ public void setText(ReactTextUpdate update) {
@Override
public int reactTagForTouch(float touchX, float touchY) {
Spanned text = (Spanned) getText();
- int target = getId();
+ int target = getOriginalReactTag(this);
int x = (int) touchX;
int y = (int) touchY;
diff --git a/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java b/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java
new file mode 100644
index 00000000000000..e57ed1f5f66987
--- /dev/null
+++ b/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java
@@ -0,0 +1,141 @@
+/**
+ * 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.uimanager;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.view.View;
+
+import com.facebook.csslayout.Spacing;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.powermock.core.classloader.annotations.PowerMockIgnore;
+import org.powermock.core.classloader.annotations.PrepareForTest;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.AdditionalMatchers.not;
+import static org.mockito.Matchers.anyFloat;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+@Config(manifest= Config.NONE)
+@RunWith(RobolectricTestRunner.class)
+@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"})
+public class BaseViewManagerTest {
+
+ @Mock
+ Resources resources;
+ @Mock
+ Context context;
+ @Mock
+ private View view;
+ private BaseViewManager sut;
+
+ private final String testID = "some-test-id";
+ private final int mappedTestID = 23457897;
+ private final int originalJsTag = 5;
+ private final String myPackage = "com.myApp";
+
+
+ @Before
+ public void setup() {
+ MockitoAnnotations.initMocks(this);
+ when(view.getContext()).thenReturn(context);
+ when(view.getResources()).thenReturn(resources);
+ when(view.getId()).thenReturn(originalJsTag);
+ when(resources.getIdentifier(eq(testID), eq("id"), eq(myPackage))).thenReturn(mappedTestID);
+ when(resources.getIdentifier(eq(testID), eq("id"), not(eq(myPackage)))).thenReturn(0);
+ sut = new ViewManagerStub();
+ }
+
+ @After
+ public void teardown() {
+ BaseViewManager.resetTestState();
+ }
+
+ @Test
+ public void testSetTestId_should_always_call_setTag() {
+ String expectedTestID1 = "asdfasdf1";
+ String expectedTestID2 = "asdfasdf2";
+ when(context.getPackageName()).thenReturn("com.foo");
+ sut.setTestId(view, expectedTestID1);
+ sut.setTestId(view, expectedTestID2);
+ verify(view).setTag(expectedTestID1);
+ verify(view).setTag(expectedTestID2);
+ }
+
+ @Test
+ public void testSetTestId_should_not_set_the_mapped_testID_on_the_view_when_a_resource_id_is_not_found() {
+ when(context.getPackageName()).thenReturn("com.foo");
+ sut.setTestId(view, testID);
+ verify(view, never()).setId(anyInt());
+ }
+
+ @Test
+ public void testSetTestId_should_set_the_mapped_testID_on_the_view_when_a_resource_id_is_found() {
+ when(context.getPackageName()).thenReturn(myPackage);
+ sut.setTestId(view, testID);
+ verify(view).setId(mappedTestID);
+ }
+
+ @Test
+ public void getOriginalReactTag_should_return_the_original_tag_set_by_js_if_no_testID_has_been_set() {
+ assertEquals("The original JS tag was not returned", BaseViewManager.getOriginalReactTag(view), originalJsTag);
+ }
+
+ @Test
+ public void getOriginalReactTag_should_return_the_original_tag_set_by_js_if_testID_has_been_set() {
+ when(context.getPackageName()).thenReturn(myPackage);
+ sut.setTestId(view, testID);
+ verify(view).setId(mappedTestID);
+ assertEquals("The original JS tag was not returned", BaseViewManager.getOriginalReactTag(view), originalJsTag);
+ }
+
+ private static class ViewManagerStub extends BaseViewManager {
+ @Override
+ public String getName() {
+ return null;
+ }
+
+ @Override
+ public LayoutShadowNode createShadowNodeInstance() {
+ return null;
+ }
+
+ @Override
+ public Class extends LayoutShadowNode> getShadowNodeClass() {
+ return null;
+ }
+
+ @Override
+ protected View createViewInstance(ThemedReactContext reactContext) {
+ return null;
+ }
+
+ @Override
+ public void updateExtraData(View root, Object extraData) {
+
+ }
+ }
+}