From 249ef5061001fb364bda49c733d1300b3c590ad1 Mon Sep 17 00:00:00 2001 From: Adam Comella Date: Wed, 6 Jul 2016 14:43:48 -0700 Subject: [PATCH] Android: Enable views to be nested within Potential breaking change: The signature of ReactShadowNode's onBeforeLayout method was changed - Before: public void onBeforeLayout() - After: public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) Depends on this css-layout PR: https://github.com/facebook/css-layout/pull/202 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. 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. --- Examples/UIExplorer/js/TextExample.android.js | 5 +- .../java/com/facebook/csslayout/CSSNode.java | 12 +- .../uimanager/IViewManagerWithChildren.java | 24 ++++ .../facebook/react/uimanager/NativeKind.java | 23 +++ .../uimanager/NativeViewHierarchyManager.java | 14 +- .../NativeViewHierarchyOptimizer.java | 95 ++++++++----- .../react/uimanager/ReactShadowNode.java | 74 ++++++++-- .../react/uimanager/UIImplementation.java | 50 +++---- .../react/uimanager/UIManagerModule.java | 8 ++ .../react/uimanager/ViewGroupManager.java | 4 +- .../react/views/text/ReactTextShadowNode.java | 132 ++++++++++++++---- .../react/views/text/ReactTextView.java | 69 +++++++++ .../views/text/ReactTextViewManager.java | 9 +- .../text/TextInlineViewPlaceholderSpan.java | 63 +++++++++ .../textinput/ReactTextInputShadowNode.java | 10 +- docs/Text.md | 4 +- 16 files changed, 481 insertions(+), 115 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java diff --git a/Examples/UIExplorer/js/TextExample.android.js b/Examples/UIExplorer/js/TextExample.android.js index 53715fa84948f0..fe4dcac317a881 100644 --- a/Examples/UIExplorer/js/TextExample.android.js +++ b/Examples/UIExplorer/js/TextExample.android.js @@ -390,9 +390,10 @@ var TextExample = React.createClass({ This text is selectable if you click-and-hold, and will offer the native Android selection menus. - + - This text contains an inline image . Neat, huh? + This text contains an inline blue view and + an inline image . Neat, huh? diff --git a/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java b/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java index 6bb7cf663a59a5..dae23c8e1717cd 100644 --- a/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java +++ b/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java @@ -7,6 +7,11 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +// NOTE: this file is auto-copied from https://github.com/facebook/css-layout +// @generated SignedSource<> + +// NOTE: Changes in this file must be imported from this css-layout PR: https://github.com/facebook/css-layout/pull/202 + package com.facebook.csslayout; import javax.annotation.Nullable; @@ -24,7 +29,7 @@ /** * A CSS Node. It has a style object you can manipulate at {@link #style}. After calling - * {@link #calculateLayout()}, {@link #layout} will be filled with the results of the layout. + * {@link #calculateLayout(CSSLayoutContext)}, {@link #layout} will be filled with the results of the layout. */ public class CSSNode { @@ -72,6 +77,11 @@ public static interface MeasureFunction { private LayoutState mLayoutState = LayoutState.DIRTY; private boolean mIsTextNode = false; + public static final Iterable NO_CSS_NODES = new ArrayList(0); + public Iterable getChildrenIterable() { + return mChildren == null ? NO_CSS_NODES : mChildren; + } + public int getChildCount() { return mChildren == null ? 0 : mChildren.size(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java new file mode 100644 index 00000000000000..8b69e3c379e2f3 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java @@ -0,0 +1,24 @@ +/** + * 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; + +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(); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java new file mode 100644 index 00000000000000..8dc3309189700d --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java @@ -0,0 +1,23 @@ +/** + * 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; + +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 +} 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 b6c66d30ffdba5..dca6af00cb345b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java @@ -164,16 +164,16 @@ public void updateLayout( // Check if the parent of the view has to layout the view, or the child has to lay itself out. if (!mRootTags.get(parentTag)) { ViewManager parentViewManager = mTagsToViewManagers.get(parentTag); - ViewGroupManager parentViewGroupManager; - if (parentViewManager instanceof ViewGroupManager) { - parentViewGroupManager = (ViewGroupManager) parentViewManager; + IViewManagerWithChildren parentViewManagerWithChildren; + if (parentViewManager instanceof IViewManagerWithChildren) { + parentViewManagerWithChildren = (IViewManagerWithChildren) parentViewManager; } else { throw new IllegalViewOperationException( - "Trying to use view with tag " + tag + - " as a parent, but its Manager doesn't extends ViewGroupManager"); + "Trying to use view with tag " + parentTag + + " as a parent, but its Manager doesn't implement IViewManagerWithChildren"); } - if (parentViewGroupManager != null - && !parentViewGroupManager.needsCustomLayoutForChildren()) { + if (parentViewManagerWithChildren != null + && !parentViewManagerWithChildren.needsCustomLayoutForChildren()) { updateLayout(viewToUpdate, x, y, width, height); } } else { diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java index 2d921bb0f1c230..d62ba3e15aeae3 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java @@ -68,6 +68,15 @@ private static class NodeIndexPair { 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) { @@ -83,6 +92,7 @@ public void handleCreateView( ThemedReactContext themedContext, @Nullable ReactStylesDiffMap initialProps) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(node); int tag = node.getReactTag(); mUIViewOperationQueue.enqueueCreateView( themedContext, @@ -96,7 +106,7 @@ public void handleCreateView( isLayoutOnlyAndCollapsable(initialProps); node.setIsLayoutOnly(isLayoutOnly); - if (!isLayoutOnly) { + if (node.getNativeKind() != NativeKind.NONE) { mUIViewOperationQueue.enqueueCreateView( themedContext, node.getReactTag(), @@ -122,6 +132,7 @@ public void handleUpdateView( String className, ReactStylesDiffMap props) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(node); mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props); return; } @@ -151,6 +162,7 @@ public void handleManageChildren( ViewAtIndex[] viewsToAdd, int[] tagsToDelete) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(nodeToManage); mUIViewOperationQueue.enqueueManageChildren( nodeToManage.getReactTag(), indicesToRemove, @@ -191,6 +203,7 @@ public void handleSetChildren( ReadableArray childrenTags ) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(nodeToManage); mUIViewOperationQueue.enqueueSetChildren( nodeToManage.getReactTag(), childrenTags); @@ -210,8 +223,9 @@ public void handleSetChildren( */ 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(), @@ -223,6 +237,12 @@ public void handleUpdateLayout(ReactShadowNode 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 * hierarchy. Should be called after all updateLayout calls for a batch have been handled. @@ -231,16 +251,18 @@ public void onBatchComplete() { 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; } @@ -249,8 +271,8 @@ private NodeIndexPair walkUpUntilNonLayoutOnly( 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. @@ -260,20 +282,26 @@ private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int in 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); @@ -283,21 +311,17 @@ private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDe new int[]{index}, null, shouldDelete ? new int[]{nodeToRemove.getReactTag()} : 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 @@ private void addNonLayoutNode( null, new ViewAtIndex[]{new ViewAtIndex(child.getReactTag(), index)}, 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 @@ private void addGrandchildren( 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,7 +376,7 @@ private void applyLayoutBase(ReactShadowNode node) { int x = node.getScreenX(); int y = node.getScreenY(); - while (parent != null && parent.isLayoutOnly()) { + while (parent != null && parent.getNativeKind() != NativeKind.PARENT) { // TODO(7854667): handle and test proper clipping x += Math.round(parent.getLayoutX()); y += Math.round(parent.getLayoutY()); @@ -361,10 +388,10 @@ private void applyLayoutBase(ReactShadowNode node) { } 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, diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java index df8ad675d294e1..e62ddd5ad9670e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java @@ -13,6 +13,7 @@ import java.util.ArrayList; +import com.facebook.csslayout.CSSLayoutContext; import com.facebook.csslayout.CSSNode; import com.facebook.infer.annotation.Assertions; import com.facebook.react.uimanager.annotations.ReactPropertyHolder; @@ -49,6 +50,7 @@ public class ReactShadowNode extends CSSNode { private @Nullable ThemedReactContext mThemedContext; private boolean mShouldNotifyOnLayout; private boolean mNodeUpdated = true; + private @Nullable ReactShadowNode mLayoutParent; // layout-only nodes private boolean mIsLayoutOnly; @@ -70,15 +72,24 @@ public boolean isVirtual() { /** * Nodes that return {@code true} will be treated as a root view for the virtual nodes tree. It - * means that {@link NativeViewHierarchyManager} will not try to perform {@code manageChildren} - * operation on such views. Good example is {@code InputText} view that may have children - * {@code Text} nodes but this whole hierarchy will be mapped to a single android {@link EditText} - * view. + * means that all of its descendants will be "virtual" nodes. Good example is {@code InputText} + * view that may have children {@code Text} nodes but this whole hierarchy will be mapped to a + * single android {@link EditText} view. */ public boolean isVirtualAnchor() { return false; } + /** + * When constructing the native tree, nodes that return {@code true} will be treated as leaves. + * Instead of adding this view's native children as subviews of it, they will be added as subviews + * of an ancestor. In other words, this view wants to support native children but it cannot host + * them itself (e.g. it isn't a ViewGroup). + */ + public boolean hoistNativeChildren() { + return false; + } + public final String getViewClass() { return Assertions.assertNotNull(mViewClassName); } @@ -113,6 +124,10 @@ public boolean hasUnseenUpdates() { protected void dirty() { if (!isVirtual()) { super.dirty(); + } else if (getParent() != null) { + // Virtual nodes aren't involved in layout but they need to have the dirty signal + // propagated to their ancestors. + getParent().dirty(); } } @@ -122,7 +137,7 @@ public void addChildAt(CSSNode child, int i) { markUpdated(); ReactShadowNode node = (ReactShadowNode) child; - int increase = node.mIsLayoutOnly ? node.mTotalNativeChildren : 1; + int increase = node.getTotalNativeNodeContributionToParent(); mTotalNativeChildren += increase; updateNativeChildrenCountInParent(increase); @@ -133,7 +148,7 @@ public ReactShadowNode removeChildAt(int i) { ReactShadowNode removed = (ReactShadowNode) super.removeChildAt(i); markUpdated(); - int decrease = removed.mIsLayoutOnly ? removed.mTotalNativeChildren : 1; + int decrease = removed.getTotalNativeNodeContributionToParent(); mTotalNativeChildren -= decrease; updateNativeChildrenCountInParent(-decrease); return removed; @@ -143,7 +158,7 @@ public void removeAllChildren() { int decrease = 0; for (int i = getChildCount() - 1; i >= 0; i--) { ReactShadowNode removed = (ReactShadowNode) super.removeChildAt(i); - decrease += removed.mIsLayoutOnly ? removed.mTotalNativeChildren : 1; + decrease += removed.getTotalNativeNodeContributionToParent(); } markUpdated(); @@ -152,11 +167,11 @@ public void removeAllChildren() { } private void updateNativeChildrenCountInParent(int delta) { - if (mIsLayoutOnly) { + if (getNativeKind() != NativeKind.PARENT) { ReactShadowNode parent = getParent(); while (parent != null) { parent.mTotalNativeChildren += delta; - if (!parent.mIsLayoutOnly) { + if (parent.getNativeKind() == NativeKind.PARENT) { break; } parent = parent.getParent(); @@ -169,7 +184,7 @@ private void updateNativeChildrenCountInParent(int delta) { * layout. Will be only called for nodes that are marked as updated with {@link #markUpdated()} * or require layouting (marked with {@link #dirty()}). */ - public void onBeforeLayout() { + public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { } public final void updateProperties(ReactStylesDiffMap props) { @@ -240,6 +255,15 @@ public final ReactShadowNode getChildAt(int i) { return (ReactShadowNode) super.getParent(); } + // Returns the node that is responsible for laying out this node. + public final @Nullable ReactShadowNode getLayoutParent() { + return mLayoutParent != null ? mLayoutParent : getNativeParent(); + } + + public final void setLayoutParent(@Nullable ReactShadowNode layoutParent) { + mLayoutParent = layoutParent; + } + /** * Get the {@link ThemedReactContext} associated with this {@link ReactShadowNode}. This will * never change during the lifetime of a {@link ReactShadowNode} instance, but different instances @@ -266,8 +290,8 @@ public boolean shouldNotifyOnLayout() { * corresponding to this node. */ public void addNativeChildAt(ReactShadowNode child, int nativeIndex) { - Assertions.assertCondition(!mIsLayoutOnly); - Assertions.assertCondition(!child.mIsLayoutOnly); + Assertions.assertCondition(getNativeKind() == NativeKind.PARENT); + Assertions.assertCondition(child.getNativeKind() != NativeKind.NONE); if (mNativeChildren == null) { mNativeChildren = new ArrayList<>(4); @@ -321,10 +345,25 @@ public boolean isLayoutOnly() { return mIsLayoutOnly; } + public NativeKind getNativeKind() { + return + isVirtual() || isLayoutOnly() ? NativeKind.NONE : + hoistNativeChildren() ? NativeKind.LEAF : + NativeKind.PARENT; + } + public int getTotalNativeChildren() { return mTotalNativeChildren; } + private int getTotalNativeNodeContributionToParent() { + NativeKind kind = getNativeKind(); + return + kind == NativeKind.NONE ? mTotalNativeChildren : + kind == NativeKind.LEAF ? 1 + mTotalNativeChildren : + 1; // kind == NativeKind.PARENT + } + /** * Returns the offset within the native children owned by all layout-only nodes in the subtree * rooted at this node for the given child. Put another way, this returns the number of native @@ -363,7 +402,7 @@ public int getNativeOffsetForChild(ReactShadowNode child) { found = true; break; } - index += (current.mIsLayoutOnly ? current.getTotalNativeChildren() : 1); + index += current.getTotalNativeNodeContributionToParent(); } if (!found) { throw new RuntimeException("Child " + child.mReactTag + " was not a child of " + mReactTag); @@ -398,4 +437,13 @@ public int getScreenWidth() { public int getScreenHeight() { return Math.round(mAbsoluteBottom - mAbsoluteTop); } + + public static final Iterable NO_CSS_NODES = new ArrayList(0); + public Iterable calculateLayoutOnChildren(CSSLayoutContext layoutContext, UIViewOperationQueue viewOperationsQueue) { + return isVirtualAnchor() ? + // All of the descendants are virtual so none of them are involved in layout. + NO_CSS_NODES : + // Just return the children. Flexbox calculations have already been run on them. + getChildrenIterable(); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java index b1d52cf0f38525..7e9e8ce689fd15 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java @@ -13,7 +13,9 @@ import java.util.Arrays; import java.util.List; +import com.facebook.csslayout.CSSConstants; import com.facebook.csslayout.CSSLayoutContext; +import com.facebook.csslayout.CSSNode; import com.facebook.infer.annotation.Assertions; import com.facebook.react.animation.Animation; import com.facebook.react.bridge.Arguments; @@ -310,14 +312,12 @@ public void manageChildren( cssNodeToManage.addChildAt(cssNodeToAdd, viewAtIndex.mIndex); } - if (!cssNodeToManage.isVirtual() && !cssNodeToManage.isVirtualAnchor()) { - mNativeViewHierarchyOptimizer.handleManageChildren( - cssNodeToManage, - indicesToRemove, - tagsToRemove, - viewsToAdd, - tagsToDelete); - } + mNativeViewHierarchyOptimizer.handleManageChildren( + cssNodeToManage, + indicesToRemove, + tagsToRemove, + viewsToAdd, + tagsToDelete); for (int i = 0; i < tagsToDelete.length; i++) { removeShadowNode(mShadowNodeRegistry.getNode(tagsToDelete[i])); @@ -346,11 +346,9 @@ public void setChildren( cssNodeToManage.addChildAt(cssNodeToAdd, i); } - if (!cssNodeToManage.isVirtual() && !cssNodeToManage.isVirtualAnchor()) { - mNativeViewHierarchyOptimizer.handleSetChildren( - cssNodeToManage, - childrenTags); - } + mNativeViewHierarchyOptimizer.handleSetChildren( + cssNodeToManage, + childrenTags); } /** @@ -569,7 +567,7 @@ public void configureNextLayoutAnimation( public void setJSResponder(int reactTag, boolean blockNativeResponder) { assertViewExists(reactTag, "setJSResponder"); ReactShadowNode node = mShadowNodeRegistry.getNode(reactTag); - while (node.isVirtual() || node.isLayoutOnly()) { + while (node.getNativeKind() == NativeKind.NONE) { node = node.getParent(); } mOperationsQueue.enqueueSetJSResponder(node.getReactTag(), reactTag, blockNativeResponder); @@ -699,14 +697,14 @@ private void assertViewExists(int reactTag, String operationNameForExceptionMess private void assertNodeDoesNotNeedCustomLayoutForChildren(ReactShadowNode node) { ViewManager viewManager = Assertions.assertNotNull(mViewManagers.get(node.getViewClass())); - ViewGroupManager viewGroupManager; - if (viewManager instanceof ViewGroupManager) { - viewGroupManager = (ViewGroupManager) viewManager; + IViewManagerWithChildren viewManagerWithChildren; + if (viewManager instanceof IViewManagerWithChildren) { + viewManagerWithChildren = (IViewManagerWithChildren) viewManager; } else { throw new IllegalViewOperationException("Trying to use view " + node.getViewClass() + " as a parent, but its Manager doesn't extends ViewGroupManager"); } - if (viewGroupManager != null && viewGroupManager.needsCustomLayoutForChildren()) { + if (viewManagerWithChildren != null && viewManagerWithChildren.needsCustomLayoutForChildren()) { throw new IllegalViewOperationException( "Trying to measure a view using measureLayout/measureLayoutRelativeToParent relative to" + " an ancestor that requires custom layout for it's children (" + node.getViewClass() + @@ -721,7 +719,7 @@ private void notifyOnBeforeLayoutRecursive(ReactShadowNode cssNode) { for (int i = 0; i < cssNode.getChildCount(); i++) { notifyOnBeforeLayoutRecursive(cssNode.getChildAt(i)); } - cssNode.onBeforeLayout(); + cssNode.onBeforeLayout(mNativeViewHierarchyOptimizer); } protected void calculateRootLayout(ReactShadowNode cssRoot) { @@ -744,14 +742,12 @@ protected void applyUpdatesRecursive( return; } - if (!cssNode.isVirtualAnchor()) { - for (int i = 0; i < cssNode.getChildCount(); i++) { - applyUpdatesRecursive( - cssNode.getChildAt(i), - absoluteX + cssNode.getLayoutX(), - absoluteY + cssNode.getLayoutY(), - eventDispatcher); - } + for (CSSNode cssChild : cssNode.calculateLayoutOnChildren(mLayoutContext, mOperationsQueue)) { + applyUpdatesRecursive( + (ReactShadowNode) cssChild, + absoluteX + cssNode.getLayoutX(), + absoluteY + cssNode.getLayoutY(), + eventDispatcher); } int tag = cssNode.getReactTag(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java index b04af822a124cb..508f5703457909 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -9,6 +9,8 @@ package com.facebook.react.uimanager; +import android.view.View; + import javax.annotation.Nullable; import java.util.List; @@ -25,6 +27,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.common.ReactConstants; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.uimanager.debug.NotThreadSafeViewHierarchyUpdateDebugListener; import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.systrace.Systrace; @@ -516,4 +519,9 @@ public void execute (NativeViewHierarchyManager nvhm) { public void addUIBlock (UIBlock block) { mUIImplementation.addUIBlock(block); } + + public View resolveView(int tag) { + UiThreadUtil.assertOnUiThread(); + return mUIImplementation.getUIViewOperationQueue().getNativeViewHierarchyManager().resolveView(tag); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java index a5094e7d5b8db4..e6c42b997dc9ac 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java @@ -22,7 +22,8 @@ * Class providing children management API for view managers of classes extending ViewGroup. */ public abstract class ViewGroupManager - extends BaseViewManager { + extends BaseViewManager + implements IViewManagerWithChildren { public static WeakHashMap mZIndexHash = new WeakHashMap<>(); @@ -134,6 +135,7 @@ public void removeAllViews(T parent) { * through the ViewGroup's onLayout method. In that case, onLayout for this View type must *not* * call layout on its children. */ + @Override public boolean needsCustomLayoutForChildren() { return false; } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java index 5178281a1d8c7a..6190e6cd1e6e8a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java @@ -12,7 +12,9 @@ import javax.annotation.Nullable; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import android.graphics.Typeface; import android.text.BoringLayout; @@ -30,6 +32,7 @@ import android.widget.TextView; import com.facebook.csslayout.CSSConstants; +import com.facebook.csslayout.CSSLayoutContext; import com.facebook.csslayout.CSSMeasureMode; import com.facebook.csslayout.CSSNode; import com.facebook.csslayout.MeasureOutput; @@ -38,6 +41,7 @@ import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.uimanager.IllegalViewOperationException; import com.facebook.react.uimanager.LayoutShadowNode; +import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactShadowNode; import com.facebook.react.uimanager.UIViewOperationQueue; @@ -60,7 +64,7 @@ */ public class ReactTextShadowNode extends LayoutShadowNode { - private static final String INLINE_IMAGE_PLACEHOLDER = "I"; + private static final String INLINE_VIEW_PLACEHOLDER = "I"; public static final int UNSET = -1; @VisibleForTesting @@ -102,29 +106,49 @@ public void execute(SpannableStringBuilder sb) { private static void buildSpannedFromTextCSSNode( ReactTextShadowNode textCSSNode, SpannableStringBuilder sb, - List ops) { + List ops, + boolean supportsInlineViews, + Map inlineViews) { int start = sb.length(); if (textCSSNode.mText != null) { sb.append(textCSSNode.mText); } for (int i = 0, length = textCSSNode.getChildCount(); i < length; i++) { - CSSNode child = textCSSNode.getChildAt(i); + ReactShadowNode child = textCSSNode.getChildAt(i); if (child instanceof ReactTextShadowNode) { - buildSpannedFromTextCSSNode((ReactTextShadowNode) child, sb, ops); + buildSpannedFromTextCSSNode((ReactTextShadowNode) child, sb, ops, supportsInlineViews, inlineViews); } else if (child instanceof ReactTextInlineImageShadowNode) { // We make the image take up 1 character in the span and put a corresponding character into // the text so that the image doesn't run over any following text. - sb.append(INLINE_IMAGE_PLACEHOLDER); + sb.append(INLINE_VIEW_PLACEHOLDER); ops.add( new SetSpanOperation( - sb.length() - INLINE_IMAGE_PLACEHOLDER.length(), + sb.length() - INLINE_VIEW_PLACEHOLDER.length(), sb.length(), ((ReactTextInlineImageShadowNode) child).buildInlineImageSpan())); + } else if (supportsInlineViews) { + int reactTag = child.getReactTag(); + float width = child.getStyleWidth(); + float height = child.getStyleHeight(); + + if (CSSConstants.isUndefined(width) || CSSConstants.isUndefined(height)) { + throw new IllegalViewOperationException("Views nested within a must have a width and height"); + } + + // We make the inline view take up 1 character in the span and put a corresponding character into + // the text so that the inline view doesn't run over any following text. + sb.append(INLINE_VIEW_PLACEHOLDER); + ops.add( + new SetSpanOperation( + sb.length() - INLINE_VIEW_PLACEHOLDER.length(), + sb.length(), + new TextInlineViewPlaceholderSpan(reactTag, (int) width, (int) height))); + inlineViews.put(reactTag, child); } else { - throw new IllegalViewOperationException("Unexpected view type nested under text node: " + throw new IllegalViewOperationException("Unexpected view type nested under a or node: " + child.getClass()); } - ((ReactShadowNode) child).markUpdateSeen(); + child.markUpdateSeen(); } int end = sb.length(); if (end >= start) { @@ -172,7 +196,12 @@ private static void buildSpannedFromTextCSSNode( } } - protected static Spannable fromTextCSSNode(ReactTextShadowNode textCSSNode) { + // nativeViewHierarchyOptimizer can be null as long as the textCSSNode does not support inline views. + protected static Spannable fromTextCSSNode(ReactTextShadowNode textCSSNode, + NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { + Assertions.assertCondition( + !textCSSNode.supportsInlineViews() || nativeViewHierarchyOptimizer != null, + "nativeViewHierarchyOptimizer is required when the textCSSNode supports inline views"); SpannableStringBuilder sb = new SpannableStringBuilder(); // TODO(5837930): Investigate whether it's worth optimizing this part and do it if so @@ -180,7 +209,8 @@ protected static Spannable fromTextCSSNode(ReactTextShadowNode textCSSNode) { // up-to-bottom, otherwise all the spannables that are withing the region for which one may set // a new spannable will be wiped out List ops = new ArrayList<>(); - buildSpannedFromTextCSSNode(textCSSNode, sb, ops); + Map inlineViews = textCSSNode.supportsInlineViews() ? new HashMap() : null; + buildSpannedFromTextCSSNode(textCSSNode, sb, ops, textCSSNode.supportsInlineViews(), inlineViews); if (textCSSNode.mFontSize == UNSET) { sb.setSpan( new AbsoluteSizeSpan((int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP))), @@ -190,16 +220,34 @@ protected static Spannable fromTextCSSNode(ReactTextShadowNode textCSSNode) { } textCSSNode.mContainsImages = false; - textCSSNode.mHeightOfTallestInlineImage = Float.NaN; + textCSSNode.mInlineViews = inlineViews; + textCSSNode.mHeightOfTallestInlineViewOrImage = Float.NaN; - // While setting the Spans on the final text, we also check whether any of them are images + // While setting the Spans on the final text, we also check whether any of them are inline views + // or images. for (int i = ops.size() - 1; i >= 0; i--) { SetSpanOperation op = ops.get(i); - if (op.what instanceof TextInlineImageSpan) { - int height = ((TextInlineImageSpan)op.what).getHeight(); - textCSSNode.mContainsImages = true; - if (Float.isNaN(textCSSNode.mHeightOfTallestInlineImage) || height > textCSSNode.mHeightOfTallestInlineImage) { - textCSSNode.mHeightOfTallestInlineImage = height; + boolean isInlineImage = op.what instanceof TextInlineImageSpan; + if (isInlineImage || op.what instanceof TextInlineViewPlaceholderSpan) { + int height; + if (isInlineImage) { + height = ((TextInlineImageSpan)op.what).getHeight(); + textCSSNode.mContainsImages = true; + } else { + TextInlineViewPlaceholderSpan placeholder = (TextInlineViewPlaceholderSpan) op.what; + height = placeholder.getHeight(); + + // Inline views cannot be layout-only because the ReactTextView needs to be able to grab + // ahold of them on the UI thread to size and position them. + ReactShadowNode childNode = inlineViews.get(placeholder.getReactTag()); + nativeViewHierarchyOptimizer.handleForceViewToBeNonLayoutOnly(childNode); + + // The ReactTextView is responsible for laying out the inline views. + childNode.setLayoutParent(textCSSNode); + } + + if (Float.isNaN(textCSSNode.mHeightOfTallestInlineViewOrImage) || height > textCSSNode.mHeightOfTallestInlineViewOrImage) { + textCSSNode.mHeightOfTallestInlineViewOrImage = height; } } op.execute(sb); @@ -346,7 +394,8 @@ private static int parseNumericFontWeight(String fontWeightString) { private final boolean mIsVirtual; protected boolean mContainsImages = false; - private float mHeightOfTallestInlineImage = Float.NaN; + private Map mInlineViews; + private float mHeightOfTallestInlineViewOrImage = Float.NaN; public ReactTextShadowNode(boolean isVirtual) { mIsVirtual = isVirtual; @@ -359,17 +408,17 @@ public ReactTextShadowNode(boolean isVirtual) { // and the height of the inline images. public float getEffectiveLineHeight() { boolean useInlineViewHeight = !Float.isNaN(mLineHeight) && - !Float.isNaN(mHeightOfTallestInlineImage) && - mHeightOfTallestInlineImage > mLineHeight; - return useInlineViewHeight ? mHeightOfTallestInlineImage : mLineHeight; + !Float.isNaN(mHeightOfTallestInlineViewOrImage) && + mHeightOfTallestInlineViewOrImage > mLineHeight; + return useInlineViewHeight ? mHeightOfTallestInlineViewOrImage : mLineHeight; } @Override - public void onBeforeLayout() { + public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { if (mIsVirtual) { return; } - mPreparedSpannableText = fromTextCSSNode(this); + mPreparedSpannableText = fromTextCSSNode(this, nativeViewHierarchyOptimizer); markUpdated(); } @@ -421,7 +470,7 @@ public void setColor(@Nullable Integer color) { @ReactProp(name = ViewProps.BACKGROUND_COLOR) public void setBackgroundColor(Integer color) { // Don't apply background color to anchor TextView since it will be applied on the View directly - if (!isVirtualAnchor()) { + if (isVirtual()) { mIsBackgroundColorSet = (color != null); if (mIsBackgroundColorSet) { mBackgroundColor = color; @@ -528,9 +577,18 @@ public void setTextShadowColor(int textShadowColor) { } } + protected boolean supportsInlineViews() { + return true; + } + + @Override + public boolean hoistNativeChildren() { + return supportsInlineViews(); + } + @Override public boolean isVirtualAnchor() { - return !mIsVirtual; + return !supportsInlineViews() && !mIsVirtual; } @Override @@ -550,4 +608,28 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate); } } + + public static final Iterable NO_CSS_NODES = new ArrayList(0); + @Override + public Iterable calculateLayoutOnChildren(CSSLayoutContext layoutContext, UIViewOperationQueue viewOperationsQueue) { + // Run flexbox on and return the descendants which are inline views. + + if (mInlineViews == null || mInlineViews.isEmpty()) { + return NO_CSS_NODES; + } + + Spanned text = Assertions.assertNotNull( + this.mPreparedSpannableText, + "Spannable element has not been prepared in onBeforeLayout"); + TextInlineViewPlaceholderSpan[] placeholders = text.getSpans(0, text.length(), TextInlineViewPlaceholderSpan.class); + ArrayList shadowNodes = new ArrayList(placeholders.length); + + for (TextInlineViewPlaceholderSpan placeholder : placeholders) { + ReactShadowNode child = mInlineViews.get(placeholder.getReactTag()); + child.calculateLayout(layoutContext); + shadowNodes.add(child); + } + + return shadowNodes; + } } 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 9ef3df91a8ab58..d541c76993943b 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 @@ -14,11 +14,14 @@ import android.text.Layout; import android.text.Spanned; import android.view.Gravity; +import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import com.facebook.csslayout.FloatUtil; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.uimanager.ReactCompoundView; +import com.facebook.react.uimanager.UIManagerModule; public class ReactTextView extends TextView implements ReactCompoundView { @@ -38,6 +41,69 @@ public ReactTextView(Context context) { mDefaultGravityVertical = getGravity() & Gravity.VERTICAL_GRAVITY_MASK; } + @Override + protected void onLayout(boolean changed, + int textViewLeft, + int textViewTop, + int textViewRight, + int textViewBottom) { + if (!(getText() instanceof Spanned)) { + /** + * In general, {@link #setText} is called via {@link ReactTextViewManager#updateExtraData} + * before we are laid out. This ordering is a requirement because we utilize the data from + * setText in onLayout. + * + * However, it's possible for us to get an extra layout before we've received our setText + * call. If this happens before the initial setText call, then getText() will have its default + * value which isn't a Spanned and we need to bail out. That's fine because we'll get a + * setText followed by a layout later. + * + * The cause for the extra early layout is that an ancestor gets transitioned from a + * layout-only node to a non layout-only node. + */ + return; + } + + UIManagerModule uiManager = ((ReactContext) getContext()).getNativeModule(UIManagerModule.class); + + Spanned text = (Spanned) getText(); + Layout layout = getLayout(); + TextInlineViewPlaceholderSpan[] placeholders = text.getSpans(0, text.length(), TextInlineViewPlaceholderSpan.class); + int textViewWidth = textViewRight - textViewLeft; + int textViewHeight = textViewBottom - textViewTop; + + for (TextInlineViewPlaceholderSpan placeholder : placeholders) { + View child = uiManager.resolveView(placeholder.getReactTag()); + + int width = placeholder.getWidth(); + int height = placeholder.getHeight(); + int start = text.getSpanStart(placeholder); + int line = layout.getLineForOffset(start); + + int primaryHorizontal; + // There's a bug on Samsung devices where calling getPrimaryHorizontal on + // the last offset in the layout will result in an endless loop. Work around + // this bug by avoiding getPrimaryHorizontal in that case. + if (start == text.length() - 1) { + primaryHorizontal = (int) layout.getLineRight(line) - width; + } else { + primaryHorizontal = (int) layout.getPrimaryHorizontal(start); + } + + int leftRelativeToTextView = getTotalPaddingLeft() + primaryHorizontal; + int left = textViewLeft + leftRelativeToTextView; + int topRelativeToTextView = getTotalPaddingTop() + layout.getLineBaseline(line) - height; + int top = textViewTop + topRelativeToTextView; + + boolean isLineTruncated = layout.getEllipsisCount(line) > 0; + boolean isFullyClipped = (isLineTruncated && start >= layout.getEllipsisStart(line)) || + textViewWidth <= leftRelativeToTextView || textViewHeight <= topRelativeToTextView; + child.setVisibility(isFullyClipped ? View.GONE : View.VISIBLE); + + child.layout(left, top, left + width, top + height); + } + } + public void setText(ReactTextUpdate update) { mContainsImages = update.containsImages(); // Android's TextView crashes when it tries to relayout if LayoutParams are @@ -62,6 +128,9 @@ public void setText(ReactTextUpdate update) { setLineSpacing(mLineHeight, 0); } } + + // Ensure onLayout is called so the inline views can be repositioned. + requestLayout(); } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java index dc200c1fcb7a74..bfcbf118cfd479 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.java @@ -18,6 +18,7 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.uimanager.BaseViewManager; +import com.facebook.react.uimanager.IViewManagerWithChildren; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.ThemedReactContext; @@ -33,7 +34,8 @@ * @{link ReactTextShadowNode} hierarchy to calculate a {@link Spannable} text representing the * whole text subtree. */ -public class ReactTextViewManager extends BaseViewManager { +public class ReactTextViewManager extends BaseViewManager + implements IViewManagerWithChildren { @VisibleForTesting public static final String REACT_CLASS = "RCTText"; @@ -127,4 +129,9 @@ public ReactTextShadowNode createShadowNodeInstance() { public Class getShadowNodeClass() { return ReactTextShadowNode.class; } + + @Override + public boolean needsCustomLayoutForChildren() { + return true; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java new file mode 100644 index 00000000000000..76223b39233c61 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java @@ -0,0 +1,63 @@ +/** + * 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.views.text; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.style.ReplacementSpan; + +/** + * TextInlineViewPlaceholderSpan is a span for inlined views that are inside . It computes + * its size based on the input size. It contains no draw logic, just positioning logic. + */ +public class TextInlineViewPlaceholderSpan extends ReplacementSpan { + private int mReactTag; + private int mWidth; + private int mHeight; + + public TextInlineViewPlaceholderSpan(int reactTag, int width, int height) { + mReactTag = reactTag; + mWidth = width; + mHeight = height; + } + + public int getReactTag() { + return mReactTag; + } + + public int getWidth() { + return mWidth; + } + + public int getHeight() { + return mHeight; + } + + @Override + public int getSize( + Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) { + // NOTE: This getSize code is copied from DynamicDrawableSpan and modified to not use a Drawable + + if (fm != null) { + fm.ascent = -mHeight; + fm.descent = 0; + + fm.top = fm.ascent; + fm.bottom = 0; + } + + return mWidth; + } + + @Override + public void draw( + Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) { + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java index cc71f6b88c13b0..72ba1c1172e36a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputShadowNode.java @@ -22,6 +22,7 @@ import com.facebook.csslayout.Spacing; import com.facebook.infer.annotation.Assertions; import com.facebook.react.common.annotations.VisibleForTesting; +import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.react.uimanager.UIViewOperationQueue; @@ -44,6 +45,11 @@ public ReactTextInputShadowNode() { setMeasureFunction(this); } + @Override + public boolean supportsInlineViews() { + return false; + } + @Override public void setThemedContext(ThemedReactContext themedContext) { super.setThemedContext(themedContext); @@ -98,7 +104,7 @@ public void measure( } @Override - public void onBeforeLayout() { + public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { // We don't have to measure the text within the text input. return; } @@ -117,7 +123,7 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { } if (mJsEventCount != UNSET) { - Spannable preparedSpannableText = fromTextCSSNode(this); + Spannable preparedSpannableText = fromTextCSSNode(this, null); ReactTextUpdate reactTextUpdate = new ReactTextUpdate(preparedSpannableText, mJsEventCount, mContainsImages, getPadding(), getEffectiveLineHeight()); uiViewOperationQueue.enqueueUpdateExtraData(getReactTag(), reactTextUpdate); diff --git a/docs/Text.md b/docs/Text.md index 86537f8a20d07b..56319e77f8f463 100644 --- a/docs/Text.md +++ b/docs/Text.md @@ -32,9 +32,9 @@ Behind the scenes, React Native converts this to a flat `NSAttributedString` or 9-17: bold, red ``` -## Nested Views (iOS Only) +## Nested Views -On iOS, you can nest views within your Text component. Here's an example: +You can nest views within your Text component. Here's an example: ```ReactNativeWebPlayer import React, { Component } from 'react';