From bc7ddf06ddee378215a832cd395c5e84d52b92d9 Mon Sep 17 00:00:00 2001 From: Adam Comella Date: Tue, 5 Jul 2016 15:19:22 -0700 Subject: [PATCH] Android: Enable views to be nested within 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. It's job is just to occupy space -- it doesn't render any visual. After the text is rendered, we query the Android Layout associated with the TextView to find out where it has positioned each TextInlineViewPlaceholderSpan. We then position the views to be at those locations. 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/TextExample.android.js | 5 +- .../java/com/facebook/csslayout/CSSNode.java | 13 +- .../facebook/react/uimanager/NativeKind.java | 23 ++++ .../NativeViewHierarchyOptimizer.java | 91 +++++++----- .../react/uimanager/ReactShadowNode.java | 60 ++++++-- .../react/uimanager/UIImplementation.java | 44 +++--- .../react/uimanager/UIManagerModule.java | 8 ++ .../react/views/text/ReactTextShadowNode.java | 129 ++++++++++++++---- .../react/views/text/ReactTextView.java | 55 ++++++++ .../text/TextInlineViewPlaceholderSpan.java | 63 +++++++++ .../textinput/ReactTextInputShadowNode.java | 10 +- docs/Text.md | 4 +- 12 files changed, 402 insertions(+), 103 deletions(-) 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/TextExample.android.js b/Examples/UIExplorer/TextExample.android.js index 230bd79bb170e9..74f67e939fcee8 100644 --- a/Examples/UIExplorer/TextExample.android.js +++ b/Examples/UIExplorer/TextExample.android.js @@ -383,9 +383,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 b9cacf68043051..8abc5ddcd3cf33 100644 --- a/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java +++ b/ReactAndroid/src/main/java/com/facebook/csslayout/CSSNode.java @@ -9,6 +9,8 @@ // 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; @@ -26,7 +28,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, float, float)}, {@link #layout} will be filled with the results of the layout. */ public class CSSNode { @@ -74,6 +76,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(); } @@ -149,8 +156,8 @@ public boolean isTextNode() { /** * Performs the actual layout and saves the results in {@link #layout} */ - public void calculateLayout(CSSLayoutContext layoutContext) { - LayoutEngine.layoutNode(layoutContext, this, CSSConstants.UNDEFINED, CSSConstants.UNDEFINED, null); + public void calculateLayout(CSSLayoutContext layoutContext, float availableWidth, float availableHeight) { + LayoutEngine.layoutNode(layoutContext, this, availableWidth, availableHeight, null); } /** 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/NativeViewHierarchyOptimizer.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java index 2d921bb0f1c230..7740879a3520f9 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,6 +223,7 @@ public void handleSetChildren( */ public void handleUpdateLayout(ReactShadowNode node) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(node); mUIViewOperationQueue.enqueueUpdateLayout( Assertions.assertNotNull(node.getParent()).getReactTag(), node.getReactTag(), @@ -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,7 +388,7 @@ 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(), 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..05f8a9a0a05934 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; @@ -70,15 +71,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); } @@ -122,7 +132,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 +143,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 +153,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 +162,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 +179,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) { @@ -266,8 +276,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 +331,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 +388,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 +423,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..f3ed8ad3882202 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); @@ -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) { @@ -729,7 +727,7 @@ protected void calculateRootLayout(ReactShadowNode cssRoot) { .arg("rootTag", cssRoot.getReactTag()) .flush(); try { - cssRoot.calculateLayout(mLayoutContext); + cssRoot.calculateLayout(mLayoutContext, CSSConstants.UNDEFINED, CSSConstants.UNDEFINED); } finally { Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE); } @@ -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 12bbc1e3af1e86..7b86b1849ca7d7 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; @@ -23,6 +25,7 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.uimanager.DisplayMetricsHolder; import com.facebook.react.uimanager.debug.NotThreadSafeViewHierarchyUpdateDebugListener; import com.facebook.react.uimanager.events.EventDispatcher; @@ -491,4 +494,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/views/text/ReactTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextShadowNode.java index 0485cbd08f3ed2..242b59ea4829c4 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,31 @@ 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); + } + + if (Float.isNaN(textCSSNode.mHeightOfTallestInlineViewOrImage) || height > textCSSNode.mHeightOfTallestInlineViewOrImage) { + textCSSNode.mHeightOfTallestInlineViewOrImage = height; } } op.execute(sb); @@ -346,7 +391,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 +405,17 @@ public ReactTextShadowNode(boolean isVirtual) { // and the height of the inline images. public float getAffectiveLineHeight() { 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 +467,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 +574,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 +605,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, placeholder.getWidth(), placeholder.getHeight()); + 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 d5dad3efb1c65a..4845a73f8e4d2c 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,58 @@ 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 leftRelativeToTextView = getTotalPaddingLeft() + (int) layout.getPrimaryHorizontal(start); + 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 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 d1e4020104e5dd..8b2c6ac552f318 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, getAffectiveLineHeight()); 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';