Android: Enable views to be nested within <Text> (#23195)

Summary:
Potential breaking change: The signature of ReactShadowNode's onBeforeLayout method was changed
  - Before: public void onBeforeLayout()
  - After:  public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer)

Implements same feature as this iOS PR: https://github.com/facebook/react-native/pull/7304

Previously, only Text and Image could be nested within Text. Now, any view can be nested within Text. One restriction of this feature is that developers must give inline views a width and a height via the style prop.

Previously, inline Images were supported via FrescoBasedReactTextInlineImageSpan. To get support for nesting views within Text, we create one special kind of span per inline view. This span is called TextInlineViewPlaceholderSpan. It is the same size as the inline view. Its job is just to occupy space -- it doesn't render any visual. After the text is rendered, we query the Android Layout object associated with the TextView to find out where it has positioned each TextInlineViewPlaceholderSpan. We then position the views to be at those locations.

One tricky aspect of the implementation is that the Text component needs to be able to render native children (the inline views) but the Android TextView cannot have children. This is solved by having the native parent of the ReactTextView also host the inline views. Implementation-wise, this was accomplished by extending the NativeViewHierarchyOptimizer to handle this case. The optimizer now handles these cases:
  - Node is not in the native tree. An ancestor must host its children.
  - Node is in the native tree and it can host its own children.
  - (new) Node is in the native tree but it cannot host its own children. An ancestor must host both this node and its children.

I added the `onInlineViewLayout` event which is useful for writing tests for verifying that the inline views are positioned properly.

Limitation: Clipping
----------

If Text's height/width is small such that an inline view doesn't completely fit, the inline view may still be fully visible due to hoisting (the inline view isn't actually parented to the Text which has the limited size. It is parented to an ancestor which may have a different clipping rectangle.). Prior to this change, layout-only views had a similar limitation.
Pull Request resolved: https://github.com/facebook/react-native/pull/23195

Differential Revision: D14014668

Pulled By: shergin

fbshipit-source-id: d46130f3d19cc83ac7ddf423adcc9e23988245d3
This commit is contained in:
Adam Comella
2019-04-01 19:52:38 -07:00
committed by Facebook Github Bot
parent 770da3ac67
commit a2285b1790
19 changed files with 672 additions and 135 deletions
@@ -64,6 +64,15 @@ public class NativeViewHierarchyOptimizer {
private final ShadowNodeRegistry mShadowNodeRegistry;
private final SparseBooleanArray mTagsWithLayoutVisited = new SparseBooleanArray();
public static void assertNodeSupportedWithoutOptimizer(ReactShadowNode node) {
// NativeKind.LEAF nodes require the optimizer. They are not ViewGroups so they cannot host
// their native children themselves. Their native children need to be hoisted by the optimizer
// to an ancestor which is a ViewGroup.
Assertions.assertCondition(
node.getNativeKind() != NativeKind.LEAF,
"Nodes with NativeKind.LEAF are not supported when the optimizer is disabled");
}
public NativeViewHierarchyOptimizer(
UIViewOperationQueue uiViewOperationQueue,
ShadowNodeRegistry shadowNodeRegistry) {
@@ -79,6 +88,7 @@ public class NativeViewHierarchyOptimizer {
ThemedReactContext themedContext,
@Nullable ReactStylesDiffMap initialProps) {
if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
int tag = node.getReactTag();
mUIViewOperationQueue.enqueueCreateView(
themedContext,
@@ -92,7 +102,7 @@ public class NativeViewHierarchyOptimizer {
isLayoutOnlyAndCollapsable(initialProps);
node.setIsLayoutOnly(isLayoutOnly);
if (!isLayoutOnly) {
if (node.getNativeKind() != NativeKind.NONE) {
mUIViewOperationQueue.enqueueCreateView(
themedContext,
node.getReactTag(),
@@ -118,6 +128,7 @@ public class NativeViewHierarchyOptimizer {
String className,
ReactStylesDiffMap props) {
if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props);
return;
}
@@ -148,6 +159,7 @@ public class NativeViewHierarchyOptimizer {
int[] tagsToDelete,
int[] indicesToDelete) {
if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(nodeToManage);
mUIViewOperationQueue.enqueueManageChildren(
nodeToManage.getReactTag(),
indicesToRemove,
@@ -189,6 +201,7 @@ public class NativeViewHierarchyOptimizer {
ReadableArray childrenTags
) {
if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(nodeToManage);
mUIViewOperationQueue.enqueueSetChildren(
nodeToManage.getReactTag(),
childrenTags);
@@ -208,8 +221,9 @@ public class NativeViewHierarchyOptimizer {
*/
public void handleUpdateLayout(ReactShadowNode node) {
if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
mUIViewOperationQueue.enqueueUpdateLayout(
Assertions.assertNotNull(node.getParent()).getReactTag(),
Assertions.assertNotNull(node.getLayoutParent()).getReactTag(),
node.getReactTag(),
node.getScreenX(),
node.getScreenY(),
@@ -221,6 +235,12 @@ public class NativeViewHierarchyOptimizer {
applyLayoutBase(node);
}
public void handleForceViewToBeNonLayoutOnly(ReactShadowNode node) {
if (node.isLayoutOnly()) {
transitionLayoutOnlyViewToNativeView(node, null);
}
}
/**
* Processes the shadow hierarchy to dispatch all necessary updateLayout calls to the native
* hierarchy. Should be called after all updateLayout calls for a batch have been handled.
@@ -229,16 +249,18 @@ public class NativeViewHierarchyOptimizer {
mTagsWithLayoutVisited.clear();
}
private NodeIndexPair walkUpUntilNonLayoutOnly(
private NodeIndexPair walkUpUntilNativeKindIsParent(
ReactShadowNode node,
int indexInNativeChildren) {
while (node.isLayoutOnly()) {
while (node.getNativeKind() != NativeKind.PARENT) {
ReactShadowNode parent = node.getParent();
if (parent == null) {
return null;
}
indexInNativeChildren = indexInNativeChildren + parent.getNativeOffsetForChild(node);
indexInNativeChildren = indexInNativeChildren +
(node.getNativeKind() == NativeKind.LEAF ? 1 : 0) +
parent.getNativeOffsetForChild(node);
node = parent;
}
@@ -247,8 +269,8 @@ public class NativeViewHierarchyOptimizer {
private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int index) {
int indexInNativeChildren = parent.getNativeOffsetForChild(parent.getChildAt(index));
if (parent.isLayoutOnly()) {
NodeIndexPair result = walkUpUntilNonLayoutOnly(parent, indexInNativeChildren);
if (parent.getNativeKind() != NativeKind.PARENT) {
NodeIndexPair result = walkUpUntilNativeKindIsParent(parent, indexInNativeChildren);
if (result == null) {
// If the parent hasn't been attached to its native parent yet, don't issue commands to the
// native hierarchy. We'll do that when the parent node actually gets attached somewhere.
@@ -258,20 +280,26 @@ public class NativeViewHierarchyOptimizer {
indexInNativeChildren = result.index;
}
if (!child.isLayoutOnly()) {
addNonLayoutNode(parent, child, indexInNativeChildren);
if (child.getNativeKind() != NativeKind.NONE) {
addNativeChild(parent, child, indexInNativeChildren);
} else {
addLayoutOnlyNode(parent, child, indexInNativeChildren);
addNonNativeChild(parent, child, indexInNativeChildren);
}
}
/**
* For handling node removal from manageChildren. In the case of removing a layout-only node, we
* need to instead recursively remove all its children from their native parents.
* For handling node removal from manageChildren. In the case of removing a node which isn't
* hosting its own children (e.g. layout-only or NativeKind.LEAF), we need to recursively remove
* all its children from their native parents.
*/
private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDelete) {
ReactShadowNode nativeNodeToRemoveFrom = nodeToRemove.getNativeParent();
if (nodeToRemove.getNativeKind() != NativeKind.PARENT) {
for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) {
removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete);
}
}
ReactShadowNode nativeNodeToRemoveFrom = nodeToRemove.getNativeParent();
if (nativeNodeToRemoveFrom != null) {
int index = nativeNodeToRemoveFrom.indexOfNativeChild(nodeToRemove);
nativeNodeToRemoveFrom.removeNativeChildAt(index);
@@ -282,21 +310,17 @@ public class NativeViewHierarchyOptimizer {
null,
shouldDelete ? new int[] {nodeToRemove.getReactTag()} : null,
shouldDelete ? new int[] {index} : null);
} else {
for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) {
removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete);
}
}
}
private void addLayoutOnlyNode(
ReactShadowNode nonLayoutOnlyNode,
ReactShadowNode layoutOnlyNode,
private void addNonNativeChild(
ReactShadowNode nativeParent,
ReactShadowNode nonNativeChild,
int index) {
addGrandchildren(nonLayoutOnlyNode, layoutOnlyNode, index);
addGrandchildren(nativeParent, nonNativeChild, index);
}
private void addNonLayoutNode(
private void addNativeChild(
ReactShadowNode parent,
ReactShadowNode child,
int index) {
@@ -307,13 +331,17 @@ public class NativeViewHierarchyOptimizer {
new ViewAtIndex[] {new ViewAtIndex(child.getReactTag(), index)},
null,
null);
if (child.getNativeKind() != NativeKind.PARENT) {
addGrandchildren(parent, child, index + 1);
}
}
private void addGrandchildren(
ReactShadowNode nativeParent,
ReactShadowNode child,
int index) {
Assertions.assertCondition(!nativeParent.isLayoutOnly());
Assertions.assertCondition(child.getNativeKind() != NativeKind.PARENT);
// `child` can't hold native children. Add all of `child`'s children to `parent`.
int currentIndex = index;
@@ -321,16 +349,15 @@ public class NativeViewHierarchyOptimizer {
ReactShadowNode grandchild = child.getChildAt(i);
Assertions.assertCondition(grandchild.getNativeParent() == null);
if (grandchild.isLayoutOnly()) {
// Adding this child could result in adding multiple native views
int grandchildCountBefore = nativeParent.getNativeChildCount();
addLayoutOnlyNode(nativeParent, grandchild, currentIndex);
int grandchildCountAfter = nativeParent.getNativeChildCount();
currentIndex += grandchildCountAfter - grandchildCountBefore;
// Adding this child could result in adding multiple native views
int grandchildCountBefore = nativeParent.getNativeChildCount();
if (grandchild.getNativeKind() == NativeKind.NONE) {
addNonNativeChild(nativeParent, grandchild, currentIndex);
} else {
addNonLayoutNode(nativeParent, grandchild, currentIndex);
currentIndex++;
addNativeChild(nativeParent, grandchild, currentIndex);
}
int grandchildCountAfter = nativeParent.getNativeChildCount();
currentIndex += grandchildCountAfter - grandchildCountBefore;
}
}
@@ -349,10 +376,16 @@ public class NativeViewHierarchyOptimizer {
int x = node.getScreenX();
int y = node.getScreenY();
while (parent != null && parent.isLayoutOnly()) {
// TODO(7854667): handle and test proper clipping
x += Math.round(parent.getLayoutX());
y += Math.round(parent.getLayoutY());
while (parent != null && parent.getNativeKind() != NativeKind.PARENT) {
if (!parent.isVirtual()) {
// Skip these additions for virtual nodes. This has the same effect as `getLayout*`
// returning `0`. Virtual nodes aren't in the Yoga tree so we can't call `getLayout*` on
// them.
// TODO(7854667): handle and test proper clipping
x += Math.round(parent.getLayoutX());
y += Math.round(parent.getLayoutY());
}
parent = parent.getParent();
}
@@ -361,10 +394,10 @@ public class NativeViewHierarchyOptimizer {
}
private void applyLayoutRecursive(ReactShadowNode toUpdate, int x, int y) {
if (!toUpdate.isLayoutOnly() && toUpdate.getNativeParent() != null) {
if (toUpdate.getNativeKind() != NativeKind.NONE && toUpdate.getNativeParent() != null) {
int tag = toUpdate.getReactTag();
mUIViewOperationQueue.enqueueUpdateLayout(
toUpdate.getNativeParent().getReactTag(),
toUpdate.getLayoutParent().getReactTag(),
tag,
x,
y,