Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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: #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: #23195

Differential Revision: D14014668

Pulled By: shergin

fbshipit-source-id: d46130f3d19cc83ac7ddf423adcc9e23988245d3
  • Loading branch information
Adam Comella authored and facebook-github-bot committed Apr 2, 2019
1 parent 770da3a commit a2285b1
Show file tree
Hide file tree
Showing 19 changed files with 672 additions and 135 deletions.
15 changes: 1 addition & 14 deletions Libraries/Components/View/View.js
Expand Up @@ -11,11 +11,8 @@
'use strict'; 'use strict';


const React = require('React'); const React = require('React');
const TextAncestor = require('TextAncestor');
const ViewNativeComponent = require('ViewNativeComponent'); const ViewNativeComponent = require('ViewNativeComponent');


const invariant = require('invariant');

import type {ViewProps} from 'ViewPropTypes'; import type {ViewProps} from 'ViewPropTypes';


export type Props = ViewProps; export type Props = ViewProps;
Expand All @@ -35,17 +32,7 @@ if (__DEV__) {
props: Props, props: Props,
forwardedRef: React.Ref<typeof ViewNativeComponent>, forwardedRef: React.Ref<typeof ViewNativeComponent>,
) => { ) => {
return ( return <ViewNativeComponent {...props} ref={forwardedRef} />;
<TextAncestor.Consumer>
{hasTextAncestor => {
invariant(
!hasTextAncestor,
'Nesting of <View> within <Text> is not currently supported.',
);
return <ViewNativeComponent {...props} ref={forwardedRef} />;
}}
</TextAncestor.Consumer>
);
}; };
ViewToExport = React.forwardRef(View); ViewToExport = React.forwardRef(View);
ViewToExport.displayName = 'View'; ViewToExport.displayName = 'View';
Expand Down
4 changes: 4 additions & 0 deletions Libraries/Text/Text.js
Expand Up @@ -66,12 +66,16 @@ const viewConfig = {
minimumFontScale: true, minimumFontScale: true,
textBreakStrategy: true, textBreakStrategy: true,
onTextLayout: true, onTextLayout: true,
onInlineViewLayout: true,
dataDetectorType: true, dataDetectorType: true,
}, },
directEventTypes: { directEventTypes: {
topTextLayout: { topTextLayout: {
registrationName: 'onTextLayout', registrationName: 'onTextLayout',
}, },
topInlineViewLayout: {
registrationName: 'onInlineViewLayout',
},
}, },
uiViewClassName: 'RCTText', uiViewClassName: 'RCTText',
}; };
Expand Down
@@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.uimanager;

public interface IViewManagerWithChildren {
/**
* Returns whether this View type needs to handle laying out its own children instead of
* deferring to the standard css-layout algorithm.
* Returns true for the layout to *not* be automatically invoked. Instead onLayout will be
* invoked as normal and it is the View instance's responsibility to properly call layout on its
* children.
* Returns false for the default behavior of automatically laying out children without going
* through the ViewGroup's onLayout method. In that case, onLayout for this View type must *not*
* call layout on its children.
*/
public boolean needsCustomLayoutForChildren();
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.uimanager;

// Common conditionals:
// - `kind == PARENT` checks whether the node can host children in the native tree.
// - `kind != NONE` checks whether the node appears in the native tree.

public enum NativeKind {
// Node is in the native hierarchy and the HierarchyOptimizer should assume it can host children
// (e.g. because it's a ViewGroup). Note that it's okay if the node doesn't support children. When
// the HierarchyOptimizer generates children manipulation commands for that node, the
// HierarchyManager will catch this case and throw an exception.
PARENT,
// Node is in the native hierarchy, it may have children, but it cannot host them itself (e.g.
// because it isn't a ViewGroup). Consequently, its children need to be hosted by an ancestor.
LEAF,
// Node is not in the native hierarchy.
NONE
}
Expand Up @@ -195,16 +195,16 @@ public synchronized void updateLayout(
// Check if the parent of the view has to layout the view, or the child has to lay itself out. // Check if the parent of the view has to layout the view, or the child has to lay itself out.
if (!mRootTags.get(parentTag)) { if (!mRootTags.get(parentTag)) {
ViewManager parentViewManager = mTagsToViewManagers.get(parentTag); ViewManager parentViewManager = mTagsToViewManagers.get(parentTag);
ViewGroupManager parentViewGroupManager; IViewManagerWithChildren parentViewManagerWithChildren;
if (parentViewManager instanceof ViewGroupManager) { if (parentViewManager instanceof IViewManagerWithChildren) {
parentViewGroupManager = (ViewGroupManager) parentViewManager; parentViewManagerWithChildren = (IViewManagerWithChildren) parentViewManager;
} else { } else {
throw new IllegalViewOperationException( throw new IllegalViewOperationException(
"Trying to use view with tag " + parentTag + "Trying to use view with tag " + parentTag +
" as a parent, but its Manager doesn't extends ViewGroupManager"); " as a parent, but its Manager doesn't implement IViewManagerWithChildren");
} }
if (parentViewGroupManager != null if (parentViewManagerWithChildren != null
&& !parentViewGroupManager.needsCustomLayoutForChildren()) { && !parentViewManagerWithChildren.needsCustomLayoutForChildren()) {
updateLayout(viewToUpdate, x, y, width, height); updateLayout(viewToUpdate, x, y, width, height);
} }
} else { } else {
Expand Down
Expand Up @@ -64,6 +64,15 @@ private static class NodeIndexPair {
private final ShadowNodeRegistry mShadowNodeRegistry; private final ShadowNodeRegistry mShadowNodeRegistry;
private final SparseBooleanArray mTagsWithLayoutVisited = new SparseBooleanArray(); 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( public NativeViewHierarchyOptimizer(
UIViewOperationQueue uiViewOperationQueue, UIViewOperationQueue uiViewOperationQueue,
ShadowNodeRegistry shadowNodeRegistry) { ShadowNodeRegistry shadowNodeRegistry) {
Expand All @@ -79,6 +88,7 @@ public void handleCreateView(
ThemedReactContext themedContext, ThemedReactContext themedContext,
@Nullable ReactStylesDiffMap initialProps) { @Nullable ReactStylesDiffMap initialProps) {
if (!ENABLED) { if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
int tag = node.getReactTag(); int tag = node.getReactTag();
mUIViewOperationQueue.enqueueCreateView( mUIViewOperationQueue.enqueueCreateView(
themedContext, themedContext,
Expand All @@ -92,7 +102,7 @@ public void handleCreateView(
isLayoutOnlyAndCollapsable(initialProps); isLayoutOnlyAndCollapsable(initialProps);
node.setIsLayoutOnly(isLayoutOnly); node.setIsLayoutOnly(isLayoutOnly);


if (!isLayoutOnly) { if (node.getNativeKind() != NativeKind.NONE) {
mUIViewOperationQueue.enqueueCreateView( mUIViewOperationQueue.enqueueCreateView(
themedContext, themedContext,
node.getReactTag(), node.getReactTag(),
Expand All @@ -118,6 +128,7 @@ public void handleUpdateView(
String className, String className,
ReactStylesDiffMap props) { ReactStylesDiffMap props) {
if (!ENABLED) { if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props); mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props);
return; return;
} }
Expand Down Expand Up @@ -148,6 +159,7 @@ public void handleManageChildren(
int[] tagsToDelete, int[] tagsToDelete,
int[] indicesToDelete) { int[] indicesToDelete) {
if (!ENABLED) { if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(nodeToManage);
mUIViewOperationQueue.enqueueManageChildren( mUIViewOperationQueue.enqueueManageChildren(
nodeToManage.getReactTag(), nodeToManage.getReactTag(),
indicesToRemove, indicesToRemove,
Expand Down Expand Up @@ -189,6 +201,7 @@ public void handleSetChildren(
ReadableArray childrenTags ReadableArray childrenTags
) { ) {
if (!ENABLED) { if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(nodeToManage);
mUIViewOperationQueue.enqueueSetChildren( mUIViewOperationQueue.enqueueSetChildren(
nodeToManage.getReactTag(), nodeToManage.getReactTag(),
childrenTags); childrenTags);
Expand All @@ -208,8 +221,9 @@ public void handleSetChildren(
*/ */
public void handleUpdateLayout(ReactShadowNode node) { public void handleUpdateLayout(ReactShadowNode node) {
if (!ENABLED) { if (!ENABLED) {
assertNodeSupportedWithoutOptimizer(node);
mUIViewOperationQueue.enqueueUpdateLayout( mUIViewOperationQueue.enqueueUpdateLayout(
Assertions.assertNotNull(node.getParent()).getReactTag(), Assertions.assertNotNull(node.getLayoutParent()).getReactTag(),
node.getReactTag(), node.getReactTag(),
node.getScreenX(), node.getScreenX(),
node.getScreenY(), node.getScreenY(),
Expand All @@ -221,6 +235,12 @@ public void handleUpdateLayout(ReactShadowNode node) {
applyLayoutBase(node); 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 * 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. * hierarchy. Should be called after all updateLayout calls for a batch have been handled.
Expand All @@ -229,16 +249,18 @@ public void onBatchComplete() {
mTagsWithLayoutVisited.clear(); mTagsWithLayoutVisited.clear();
} }


private NodeIndexPair walkUpUntilNonLayoutOnly( private NodeIndexPair walkUpUntilNativeKindIsParent(
ReactShadowNode node, ReactShadowNode node,
int indexInNativeChildren) { int indexInNativeChildren) {
while (node.isLayoutOnly()) { while (node.getNativeKind() != NativeKind.PARENT) {
ReactShadowNode parent = node.getParent(); ReactShadowNode parent = node.getParent();
if (parent == null) { if (parent == null) {
return null; return null;
} }


indexInNativeChildren = indexInNativeChildren + parent.getNativeOffsetForChild(node); indexInNativeChildren = indexInNativeChildren +
(node.getNativeKind() == NativeKind.LEAF ? 1 : 0) +
parent.getNativeOffsetForChild(node);
node = parent; node = parent;
} }


Expand All @@ -247,8 +269,8 @@ private NodeIndexPair walkUpUntilNonLayoutOnly(


private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int index) { private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int index) {
int indexInNativeChildren = parent.getNativeOffsetForChild(parent.getChildAt(index)); int indexInNativeChildren = parent.getNativeOffsetForChild(parent.getChildAt(index));
if (parent.isLayoutOnly()) { if (parent.getNativeKind() != NativeKind.PARENT) {
NodeIndexPair result = walkUpUntilNonLayoutOnly(parent, indexInNativeChildren); NodeIndexPair result = walkUpUntilNativeKindIsParent(parent, indexInNativeChildren);
if (result == null) { if (result == null) {
// If the parent hasn't been attached to its native parent yet, don't issue commands to the // 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. // native hierarchy. We'll do that when the parent node actually gets attached somewhere.
Expand All @@ -258,20 +280,26 @@ private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int in
indexInNativeChildren = result.index; indexInNativeChildren = result.index;
} }


if (!child.isLayoutOnly()) { if (child.getNativeKind() != NativeKind.NONE) {
addNonLayoutNode(parent, child, indexInNativeChildren); addNativeChild(parent, child, indexInNativeChildren);
} else { } 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 * For handling node removal from manageChildren. In the case of removing a node which isn't
* need to instead recursively remove all its children from their native parents. * 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) { 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) { if (nativeNodeToRemoveFrom != null) {
int index = nativeNodeToRemoveFrom.indexOfNativeChild(nodeToRemove); int index = nativeNodeToRemoveFrom.indexOfNativeChild(nodeToRemove);
nativeNodeToRemoveFrom.removeNativeChildAt(index); nativeNodeToRemoveFrom.removeNativeChildAt(index);
Expand All @@ -282,21 +310,17 @@ private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDe
null, null,
shouldDelete ? new int[] {nodeToRemove.getReactTag()} : null, shouldDelete ? new int[] {nodeToRemove.getReactTag()} : null,
shouldDelete ? new int[] {index} : null); shouldDelete ? new int[] {index} : null);
} else {
for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) {
removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete);
}
} }
} }


private void addLayoutOnlyNode( private void addNonNativeChild(
ReactShadowNode nonLayoutOnlyNode, ReactShadowNode nativeParent,
ReactShadowNode layoutOnlyNode, ReactShadowNode nonNativeChild,
int index) { int index) {
addGrandchildren(nonLayoutOnlyNode, layoutOnlyNode, index); addGrandchildren(nativeParent, nonNativeChild, index);
} }


private void addNonLayoutNode( private void addNativeChild(
ReactShadowNode parent, ReactShadowNode parent,
ReactShadowNode child, ReactShadowNode child,
int index) { int index) {
Expand All @@ -307,30 +331,33 @@ private void addNonLayoutNode(
new ViewAtIndex[] {new ViewAtIndex(child.getReactTag(), index)}, new ViewAtIndex[] {new ViewAtIndex(child.getReactTag(), index)},
null, null,
null); null);

if (child.getNativeKind() != NativeKind.PARENT) {
addGrandchildren(parent, child, index + 1);
}
} }


private void addGrandchildren( private void addGrandchildren(
ReactShadowNode nativeParent, ReactShadowNode nativeParent,
ReactShadowNode child, ReactShadowNode child,
int index) { 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`. // `child` can't hold native children. Add all of `child`'s children to `parent`.
int currentIndex = index; int currentIndex = index;
for (int i = 0; i < child.getChildCount(); i++) { for (int i = 0; i < child.getChildCount(); i++) {
ReactShadowNode grandchild = child.getChildAt(i); ReactShadowNode grandchild = child.getChildAt(i);
Assertions.assertCondition(grandchild.getNativeParent() == null); Assertions.assertCondition(grandchild.getNativeParent() == null);


if (grandchild.isLayoutOnly()) { // Adding this child could result in adding multiple native views
// Adding this child could result in adding multiple native views int grandchildCountBefore = nativeParent.getNativeChildCount();
int grandchildCountBefore = nativeParent.getNativeChildCount(); if (grandchild.getNativeKind() == NativeKind.NONE) {
addLayoutOnlyNode(nativeParent, grandchild, currentIndex); addNonNativeChild(nativeParent, grandchild, currentIndex);
int grandchildCountAfter = nativeParent.getNativeChildCount();
currentIndex += grandchildCountAfter - grandchildCountBefore;
} else { } else {
addNonLayoutNode(nativeParent, grandchild, currentIndex); addNativeChild(nativeParent, grandchild, currentIndex);
currentIndex++;
} }
int grandchildCountAfter = nativeParent.getNativeChildCount();
currentIndex += grandchildCountAfter - grandchildCountBefore;
} }
} }


Expand All @@ -349,10 +376,16 @@ private void applyLayoutBase(ReactShadowNode node) {
int x = node.getScreenX(); int x = node.getScreenX();
int y = node.getScreenY(); int y = node.getScreenY();


while (parent != null && parent.isLayoutOnly()) { while (parent != null && parent.getNativeKind() != NativeKind.PARENT) {
// TODO(7854667): handle and test proper clipping if (!parent.isVirtual()) {
x += Math.round(parent.getLayoutX()); // Skip these additions for virtual nodes. This has the same effect as `getLayout*`
y += Math.round(parent.getLayoutY()); // 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(); parent = parent.getParent();
} }
Expand All @@ -361,10 +394,10 @@ private void applyLayoutBase(ReactShadowNode node) {
} }


private void applyLayoutRecursive(ReactShadowNode toUpdate, int x, int y) { 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(); int tag = toUpdate.getReactTag();
mUIViewOperationQueue.enqueueUpdateLayout( mUIViewOperationQueue.enqueueUpdateLayout(
toUpdate.getNativeParent().getReactTag(), toUpdate.getLayoutParent().getReactTag(),
tag, tag,
x, x,
y, y,
Expand Down

0 comments on commit a2285b1

Please sign in to comment.