diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js index 3e9d7c10a1a7b6..0ec01dfcd1efa2 100644 --- a/Libraries/Components/View/View.js +++ b/Libraries/Components/View/View.js @@ -11,11 +11,8 @@ 'use strict'; const React = require('React'); -const TextAncestor = require('TextAncestor'); const ViewNativeComponent = require('ViewNativeComponent'); -const invariant = require('invariant'); - import type {ViewProps} from 'ViewPropTypes'; export type Props = ViewProps; @@ -36,15 +33,7 @@ if (__DEV__) { forwardedRef: React.Ref, ) => { return ( - - {hasTextAncestor => { - invariant( - !hasTextAncestor, - 'Nesting of within is not currently supported.', - ); - return ; - }} - + ); }; ViewToExport = React.forwardRef(View); diff --git a/Libraries/Text/Text.js b/Libraries/Text/Text.js index dd1ccffc7e1901..d14aa513444bf5 100644 --- a/Libraries/Text/Text.js +++ b/Libraries/Text/Text.js @@ -66,12 +66,16 @@ const viewConfig = { minimumFontScale: true, textBreakStrategy: true, onTextLayout: true, + onInlineViewLayout: true, dataDetectorType: true, }, directEventTypes: { topTextLayout: { registrationName: 'onTextLayout', }, + topInlineViewLayout: { + registrationName: 'onInlineViewLayout', + }, }, uiViewClassName: 'RCTText', }; 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..c07021107a0490 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager; + +public interface IViewManagerWithChildren { + /** + * Returns whether this View type needs to handle laying out its own children instead of + * deferring to the standard css-layout algorithm. + * Returns true for the layout to *not* be automatically invoked. Instead onLayout will be + * invoked as normal and it is the View instance's responsibility to properly call layout on its + * children. + * Returns false for the default behavior of automatically laying out children without going + * through the ViewGroup's onLayout method. In that case, onLayout for this View type must *not* + * call layout on its children. + */ + public boolean needsCustomLayoutForChildren(); +} 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..44ca85d02876ce --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager; + +// Common conditionals: +// - `kind == PARENT` checks whether the node can host children in the native tree. +// - `kind != NONE` checks whether the node appears in the native tree. + +public enum NativeKind { + // Node is in the native hierarchy and the HierarchyOptimizer should assume it can host children + // (e.g. because it's a ViewGroup). Note that it's okay if the node doesn't support children. When + // the HierarchyOptimizer generates children manipulation commands for that node, the + // HierarchyManager will catch this case and throw an exception. + PARENT, + // Node is in the native hierarchy, it may have children, but it cannot host them itself (e.g. + // because it isn't a ViewGroup). Consequently, its children need to be hosted by an ancestor. + LEAF, + // Node is not in the native hierarchy. + NONE +} 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 e846052e09f4e7..b4321252de9758 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java @@ -195,16 +195,16 @@ public synchronized void updateLayout( // Check if the parent of the view has to layout the view, or the child has to lay itself out. 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 " + parentTag + - " as a parent, but its Manager doesn't extends ViewGroupManager"); + " 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 f371f7ed0a9a8c..a22e8b90501afd 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java @@ -64,6 +64,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) { @@ -79,6 +88,7 @@ public void handleCreateView( ThemedReactContext themedContext, @Nullable ReactStylesDiffMap initialProps) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(node); int tag = node.getReactTag(); mUIViewOperationQueue.enqueueCreateView( themedContext, @@ -92,7 +102,7 @@ public void handleCreateView( isLayoutOnlyAndCollapsable(initialProps); node.setIsLayoutOnly(isLayoutOnly); - if (!isLayoutOnly) { + if (node.getNativeKind() != NativeKind.NONE) { mUIViewOperationQueue.enqueueCreateView( themedContext, node.getReactTag(), @@ -118,6 +128,7 @@ public void handleUpdateView( String className, ReactStylesDiffMap props) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(node); mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props); return; } @@ -148,6 +159,7 @@ public void handleManageChildren( int[] tagsToDelete, int[] indicesToDelete) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(nodeToManage); mUIViewOperationQueue.enqueueManageChildren( nodeToManage.getReactTag(), indicesToRemove, @@ -189,6 +201,7 @@ public void handleSetChildren( ReadableArray childrenTags ) { if (!ENABLED) { + assertNodeSupportedWithoutOptimizer(nodeToManage); mUIViewOperationQueue.enqueueSetChildren( nodeToManage.getReactTag(), childrenTags); @@ -208,8 +221,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(), @@ -221,6 +235,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. @@ -229,16 +249,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; } @@ -247,8 +269,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. @@ -258,20 +280,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); @@ -282,21 +310,17 @@ private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDe null, shouldDelete ? new int[] {nodeToRemove.getReactTag()} : null, shouldDelete ? new int[] {index} : null); - } else { - for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) { - removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete); - } } } - private void addLayoutOnlyNode( - ReactShadowNode nonLayoutOnlyNode, - ReactShadowNode layoutOnlyNode, + private void addNonNativeChild( + ReactShadowNode nativeParent, + ReactShadowNode nonNativeChild, int index) { - addGrandchildren(nonLayoutOnlyNode, layoutOnlyNode, index); + addGrandchildren(nativeParent, nonNativeChild, index); } - private void addNonLayoutNode( + private void addNativeChild( ReactShadowNode parent, ReactShadowNode child, int index) { @@ -307,13 +331,17 @@ private void addNonLayoutNode( new ViewAtIndex[] {new ViewAtIndex(child.getReactTag(), index)}, null, null); + + if (child.getNativeKind() != NativeKind.PARENT) { + addGrandchildren(parent, child, index + 1); + } } private void addGrandchildren( ReactShadowNode nativeParent, ReactShadowNode child, int index) { - Assertions.assertCondition(!nativeParent.isLayoutOnly()); + Assertions.assertCondition(child.getNativeKind() != NativeKind.PARENT); // `child` can't hold native children. Add all of `child`'s children to `parent`. int currentIndex = index; @@ -321,16 +349,15 @@ 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,10 +376,16 @@ private void applyLayoutBase(ReactShadowNode node) { int x = node.getScreenX(); int y = node.getScreenY(); - while (parent != null && parent.isLayoutOnly()) { - // TODO(7854667): handle and test proper clipping - x += Math.round(parent.getLayoutX()); - y += Math.round(parent.getLayoutY()); + while (parent != null && parent.getNativeKind() != NativeKind.PARENT) { + if (!parent.isVirtual()) { + // Skip these additions for virtual nodes. This has the same effect as `getLayout*` + // returning `0`. Virtual nodes aren't in the Yoga tree so we can't call `getLayout*` on + // them. + + // TODO(7854667): handle and test proper clipping + x += Math.round(parent.getLayoutX()); + y += Math.round(parent.getLayoutY()); + } parent = parent.getParent(); } @@ -361,10 +394,10 @@ 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 7d3e43aac40657..50c7140466bbd6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNode.java @@ -48,15 +48,16 @@ public interface ReactShadowNode { /** * Nodes that return {@code true} will be treated as "virtual" nodes. That is, nodes that are not - * mapped into native views (e.g. nested text node). By default this method returns {@code false}. + * mapped into native views or Yoga nodes (e.g. nested text node). By default this method returns + * {@code false}. */ 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. */ boolean isVirtualAnchor(); @@ -68,6 +69,14 @@ public interface ReactShadowNode { */ boolean isYogaLeafNode(); + /** + * 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). + */ + boolean hoistNativeChildren(); + String getViewClass(); boolean hasUpdates(); @@ -99,7 +108,7 @@ public interface ReactShadowNode { * layout. Will be only called for nodes that are marked as updated with {@link #markUpdated()} or * require layouting (marked with {@link #dirty()}). */ - void onBeforeLayout(); + void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer); void updateProperties(ReactStylesDiffMap props); @@ -135,6 +144,12 @@ public interface ReactShadowNode { @Nullable T getParent(); + // Returns the node that is responsible for laying out this node. + @Nullable + T getLayoutParent(); + + void setLayoutParent(@Nullable T 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 @@ -179,6 +194,8 @@ public interface ReactShadowNode { boolean isLayoutOnly(); + NativeKind getNativeKind(); + int getTotalNativeChildren(); boolean isDescendantOf(T ancestorNode); @@ -354,4 +371,6 @@ public interface ReactShadowNode { Integer getWidthMeasureSpec(); Integer getHeightMeasureSpec(); + + Iterable calculateLayoutOnChildren(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNodeImpl.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNodeImpl.java index 5c44f6c4d97d9a..47a2a7157e4735 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNodeImpl.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactShadowNodeImpl.java @@ -67,6 +67,7 @@ public class ReactShadowNodeImpl implements ReactShadowNode private boolean mNodeUpdated = true; private @Nullable ArrayList mChildren; private @Nullable ReactShadowNodeImpl mParent; + private @Nullable ReactShadowNodeImpl mLayoutParent; // layout-only nodes private boolean mIsLayoutOnly; @@ -98,7 +99,8 @@ public ReactShadowNodeImpl() { /** * Nodes that return {@code true} will be treated as "virtual" nodes. That is, nodes that are not - * mapped into native views (e.g. nested text node). By default this method returns {@code false}. + * mapped into native views or Yoga nodes (e.g. nested text node). By default this method returns + * {@code false}. */ @Override public boolean isVirtual() { @@ -107,9 +109,9 @@ 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. */ @Override public boolean isVirtualAnchor() { @@ -127,6 +129,17 @@ public boolean isYogaLeafNode() { return isMeasureDefined(); } + /** + * 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). + */ + @Override + public boolean hoistNativeChildren() { + return false; + } + @Override public final String getViewClass() { return Assertions.assertNotNull(mViewClassName); @@ -166,6 +179,18 @@ public final boolean hasUnseenUpdates() { public void dirty() { if (!isVirtual()) { mYogaNode.dirty(); + } else if (getParent() != null) { + // Virtual nodes aren't involved in layout but they need to have the dirty signal + // propagated to their ancestors. + // + // TODO: There are some edge cases that currently aren't supported. For example, if the size + // of your inline image/view changes, its size on-screen is not be updated. Similarly, + // if the size of a view inside of an inline view changes, its size on-screen is not + // updated. The problem may be that dirty propagation stops at inline views because the + // parent of each inline view is null. A possible fix would be to implement an `onDirty` + // handler in Yoga that will propagate the dirty signal to the ancestors of the inline view. + // + getParent().dirty(); } } @@ -199,7 +224,7 @@ public void addChildAt(ReactShadowNodeImpl child, int i) { } markUpdated(); - int increase = child.isLayoutOnly() ? child.getTotalNativeChildren() : 1; + int increase = child.getTotalNativeNodeContributionToParent(); mTotalNativeChildren += increase; updateNativeChildrenCountInParent(increase); @@ -219,7 +244,7 @@ public ReactShadowNodeImpl removeChildAt(int i) { } markUpdated(); - int decrease = removed.isLayoutOnly() ? removed.getTotalNativeChildren() : 1; + int decrease = removed.getTotalNativeNodeContributionToParent(); mTotalNativeChildren -= decrease; updateNativeChildrenCountInParent(-decrease); return removed; @@ -257,9 +282,8 @@ public void removeAndDisposeAllChildren() { } ReactShadowNodeImpl toRemove = getChildAt(i); toRemove.mParent = null; + decrease += toRemove.getTotalNativeNodeContributionToParent(); toRemove.dispose(); - - decrease += toRemove.isLayoutOnly() ? toRemove.getTotalNativeChildren() : 1; } Assertions.assertNotNull(mChildren).clear(); markUpdated(); @@ -269,11 +293,11 @@ public void removeAndDisposeAllChildren() { } private void updateNativeChildrenCountInParent(int delta) { - if (mIsLayoutOnly) { + if (getNativeKind() != NativeKind.PARENT) { ReactShadowNodeImpl parent = getParent(); while (parent != null) { parent.mTotalNativeChildren += delta; - if (!parent.isLayoutOnly()) { + if (parent.getNativeKind() == NativeKind.PARENT) { break; } parent = parent.getParent(); @@ -287,7 +311,8 @@ private void updateNativeChildrenCountInParent(int delta) { * require layouting (marked with {@link #dirty()}). */ @Override - public void onBeforeLayout() {} + public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { + } @Override public final void updateProperties(ReactStylesDiffMap props) { @@ -397,6 +422,17 @@ public final void setViewClassName(String viewClassName) { return mParent; } + // Returns the node that is responsible for laying out this node. + @Override + public final @Nullable ReactShadowNodeImpl getLayoutParent() { + return mLayoutParent != null ? mLayoutParent : getNativeParent(); + } + + @Override + public final void setLayoutParent(@Nullable ReactShadowNodeImpl layoutParent) { + mLayoutParent = layoutParent; + } + /** * Get the {@link ThemedReactContext} associated with this {@link ReactShadowNodeImpl}. This will * never change during the lifetime of a {@link ReactShadowNodeImpl} instance, but different @@ -446,8 +482,8 @@ public final void markLayoutSeen() { */ @Override public final void addNativeChildAt(ReactShadowNodeImpl 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); @@ -508,6 +544,14 @@ public final boolean isLayoutOnly() { return mIsLayoutOnly; } + @Override + public NativeKind getNativeKind() { + return + isVirtual() || isLayoutOnly() ? NativeKind.NONE : + hoistNativeChildren() ? NativeKind.LEAF : + NativeKind.PARENT; + } + @Override public final int getTotalNativeChildren() { return mTotalNativeChildren; @@ -531,6 +575,14 @@ public boolean isDescendantOf(ReactShadowNodeImpl ancestorNode) { return isDescendant; } + private int getTotalNativeNodeContributionToParent() { + NativeKind kind = getNativeKind(); + return + kind == NativeKind.NONE ? mTotalNativeChildren : + kind == NativeKind.LEAF ? 1 + mTotalNativeChildren : + 1; // kind == NativeKind.PARENT + } + @Override public String toString() { return "[" + mViewClassName + " " + getReactTag() + "]"; @@ -585,7 +637,7 @@ public final int getNativeOffsetForChild(ReactShadowNodeImpl child) { found = true; break; } - index += (current.isLayoutOnly() ? current.getTotalNativeChildren() : 1); + index += current.getTotalNativeNodeContributionToParent(); } if (!found) { throw new RuntimeException( @@ -978,4 +1030,13 @@ public Integer getWidthMeasureSpec() { public Integer getHeightMeasureSpec() { return mHeightMeasureSpec; } + + @Override + public Iterable calculateLayoutOnChildren() { + return isVirtualAnchor() ? + // All of the descendants are virtual so none of them are involved in layout. + null : + // Just return the children. Flexbox calculations have already been run on them. + mChildren; + } } 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 decf97488273d4..c013a55e455fe8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java @@ -429,15 +429,13 @@ public void manageChildren( cssNodeToManage.addChildAt(cssNodeToAdd, viewAtIndex.mIndex); } - if (!cssNodeToManage.isVirtual() && !cssNodeToManage.isVirtualAnchor()) { - mNativeViewHierarchyOptimizer.handleManageChildren( - cssNodeToManage, - indicesToRemove, - tagsToRemove, - viewsToAdd, - tagsToDelete, - indicesToDelete); - } + mNativeViewHierarchyOptimizer.handleManageChildren( + cssNodeToManage, + indicesToRemove, + tagsToRemove, + viewsToAdd, + tagsToDelete, + indicesToDelete); for (int i = 0; i < tagsToDelete.length; i++) { removeShadowNode(mShadowNodeRegistry.getNode(tagsToDelete[i])); @@ -467,11 +465,9 @@ public void setChildren( cssNodeToManage.addChildAt(cssNodeToAdd, i); } - if (!cssNodeToManage.isVirtual() && !cssNodeToManage.isVirtualAnchor()) { - mNativeViewHierarchyOptimizer.handleSetChildren( - cssNodeToManage, - childrenTags); - } + mNativeViewHierarchyOptimizer.handleSetChildren( + cssNodeToManage, + childrenTags); } } @@ -764,7 +760,7 @@ public void setJSResponder(int reactTag, boolean blockNativeResponder) { return; } - while (node.isVirtual() || node.isLayoutOnly()) { + while (node.getNativeKind() == NativeKind.NONE) { node = node.getParent(); } mOperationsQueue.enqueueSetJSResponder(node.getReactTag(), reactTag, blockNativeResponder); @@ -903,14 +899,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() + @@ -925,7 +921,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) { @@ -957,10 +953,11 @@ protected void applyUpdatesRecursive( return; } - if (!cssNode.isVirtualAnchor()) { - for (int i = 0; i < cssNode.getChildCount(); i++) { + Iterable cssChildren = cssNode.calculateLayoutOnChildren(); + if (cssChildren != null) { + for (ReactShadowNode cssChild : cssChildren) { applyUpdatesRecursive( - cssNode.getChildAt(i), + cssChild, absoluteX + cssNode.getLayoutX(), absoluteY + cssNode.getLayoutY()); } 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 1d2e4fbb6a7749..0de24d15ecfab5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -878,4 +878,9 @@ public void onConfigurationChanged(Configuration newConfig) {} @Override public void onLowMemory() {} } + + 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 d210d63e2d161b..253c3ef0a94c15 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewGroupManager.java @@ -17,7 +17,8 @@ * Class providing children management API for view managers of classes extending ViewGroup. */ public abstract class ViewGroupManager - extends BaseViewManager { + extends BaseViewManager + implements IViewManagerWithChildren { private static WeakHashMap mZIndexHash = new WeakHashMap<>(); @@ -97,6 +98,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/ReactBaseTextShadowNode.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java index e5c94e0084f971..5a420bbb977f73 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java @@ -14,18 +14,26 @@ import android.text.Spannable; import android.text.SpannableStringBuilder; import android.view.Gravity; + +import com.facebook.infer.annotation.Assertions; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReadableMap; 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.ViewProps; import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.yoga.YogaConstants; import com.facebook.yoga.YogaDirection; +import com.facebook.yoga.YogaUnit; +import com.facebook.yoga.YogaValue; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import javax.annotation.Nullable; /** @@ -41,7 +49,10 @@ @TargetApi(Build.VERSION_CODES.M) public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { - private static final String INLINE_IMAGE_PLACEHOLDER = "I"; + // Use a direction weak character so the placeholder doesn't change the direction of the previous + // character. + // https://en.wikipedia.org/wiki/Bi-directional_text#weak_characters + private static final String INLINE_VIEW_PLACEHOLDER = "0"; public static final int UNSET = -1; public static final String PROP_SHADOW_OFFSET = "textShadowOffset"; @@ -84,6 +95,8 @@ private static void buildSpannedFromShadowNode( SpannableStringBuilder sb, List ops, TextAttributes parentTextAttributes, + boolean supportsInlineViews, + Map inlineViews, int start) { TextAttributes textAttributes; @@ -102,19 +115,39 @@ private static void buildSpannedFromShadowNode( ((ReactRawTextShadowNode) child).getText(), textAttributes.getTextTransform())); } else if (child instanceof ReactBaseTextShadowNode) { - buildSpannedFromShadowNode((ReactBaseTextShadowNode) child, sb, ops, textAttributes, sb.length()); + buildSpannedFromShadowNode((ReactBaseTextShadowNode) child, sb, ops, textAttributes, supportsInlineViews, inlineViews, sb.length()); } 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(); + YogaValue widthValue = child.getStyleWidth(); + YogaValue heightValue = child.getStyleHeight(); + + if (widthValue.unit != YogaUnit.POINT || heightValue.unit != YogaUnit.POINT) { + throw new IllegalViewOperationException("Views nested within a must have a width and height"); + } + float width = widthValue.value; + float height = heightValue.value; + + // 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: " + child.getClass()); + "Unexpected view type nested under a or node: " + child.getClass()); } child.markUpdateSeen(); } @@ -192,8 +225,15 @@ private static void buildSpannedFromShadowNode( } } + // `nativeViewHierarchyOptimizer` can be `null` as long as `supportsInlineViews` is `false`. protected static Spannable spannedFromShadowNode( - ReactBaseTextShadowNode textShadowNode, String text) { + ReactBaseTextShadowNode textShadowNode, + String text, + boolean supportsInlineViews, + NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { + Assertions.assertCondition( + !supportsInlineViews || nativeViewHierarchyOptimizer != null, + "nativeViewHierarchyOptimizer is required when inline views are supported"); SpannableStringBuilder sb = new SpannableStringBuilder(); // TODO(5837930): Investigate whether it's worth optimizing this part and do it if so @@ -202,6 +242,7 @@ protected static Spannable spannedFromShadowNode( // 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<>(); + Map inlineViews = supportsInlineViews ? new HashMap() : null; if (text != null) { // Handle text that is provided via a prop (e.g. the `value` and `defaultValue` props on @@ -209,20 +250,37 @@ protected static Spannable spannedFromShadowNode( sb.append(TextTransform.apply(text, textShadowNode.mTextAttributes.getTextTransform())); } - buildSpannedFromShadowNode(textShadowNode, sb, ops, null, 0); + buildSpannedFromShadowNode(textShadowNode, sb, ops, null, supportsInlineViews, inlineViews, 0); textShadowNode.mContainsImages = false; - float heightOfTallestInlineImage = Float.NaN; + textShadowNode.mInlineViews = inlineViews; + float heightOfTallestInlineViewOrImage = 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. int priority = 0; for (SetSpanOperation op : ops) { - if (op.what instanceof TextInlineImageSpan) { - int height = ((TextInlineImageSpan) op.what).getHeight(); - textShadowNode.mContainsImages = true; - if (Float.isNaN(heightOfTallestInlineImage) - || height > heightOfTallestInlineImage) { - heightOfTallestInlineImage = height; + boolean isInlineImage = op.what instanceof TextInlineImageSpan; + if (isInlineImage || op.what instanceof TextInlineViewPlaceholderSpan) { + int height; + if (isInlineImage) { + height = ((TextInlineImageSpan)op.what).getHeight(); + textShadowNode.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(textShadowNode); + } + + if (Float.isNaN(heightOfTallestInlineViewOrImage) || height > heightOfTallestInlineViewOrImage) { + heightOfTallestInlineViewOrImage = height; } } @@ -232,7 +290,7 @@ protected static Spannable spannedFromShadowNode( priority++; } - textShadowNode.mTextAttributes.setHeightOfTallestInlineImage(heightOfTallestInlineImage); + textShadowNode.mTextAttributes.setHeightOfTallestInlineViewOrImage(heightOfTallestInlineViewOrImage); return sb; } @@ -305,7 +363,7 @@ private static int parseNumericFontWeight(String fontWeightString) { protected @Nullable String mFontFamily = null; protected boolean mContainsImages = false; - protected float mHeightOfTallestInlineImage = Float.NaN; + protected Map mInlineViews; public ReactBaseTextShadowNode() { mTextAttributes = new TextAttributes(); @@ -403,8 +461,11 @@ 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()) { + // Background color needs to be handled here for virtual nodes so it can be incorporated into + // the span. However, it doesn't need to be applied to non-virtual nodes because non-virtual + // nodes get mapped to native views and native views get their background colors get set via + // {@link BaseViewManager}. + if (isVirtual()) { mIsBackgroundColorSet = (color != null); if (mIsBackgroundColorSet) { mBackgroundColor = color; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java index 73ec452920590c..2cedc5afbe9341 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java @@ -183,4 +183,9 @@ public void setDataDetectorType(ReactTextView view, @Nullable String type) { break; } } + + @ReactProp(name = "onInlineViewLayout") + public void setNotifyOnInlineViewLayout(ReactTextView view, boolean notifyOnInlineViewLayout) { + view.setNotifyOnInlineViewLayout(notifyOnInlineViewLayout); + } } 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 e5eaf49ff01df9..0a572581eefa04 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 @@ -20,6 +20,8 @@ import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; +import com.facebook.react.uimanager.ReactShadowNode; import com.facebook.react.uimanager.Spacing; import com.facebook.react.uimanager.UIViewOperationQueue; import com.facebook.react.uimanager.annotations.ReactProp; @@ -30,6 +32,9 @@ import com.facebook.yoga.YogaMeasureMode; import com.facebook.yoga.YogaMeasureOutput; import com.facebook.yoga.YogaNode; + +import java.util.ArrayList; + import javax.annotation.Nullable; /** @@ -189,13 +194,25 @@ private int getTextAlign() { } @Override - public void onBeforeLayout() { - mPreparedSpannableText = spannedFromShadowNode(this, null); + public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) { + mPreparedSpannableText = spannedFromShadowNode( + this, + /* text (e.g. from `value` prop): */ null, + /* supportsInlineViews: */ true, + nativeViewHierarchyOptimizer); markUpdated(); } @Override public boolean isVirtualAnchor() { + // Text's descendants aren't necessarily all virtual nodes. Text can contain a combination of + // virtual and non-virtual (e.g. inline views) nodes. Therefore it's not a virtual anchor + // by the doc comment on {@link ReactShadowNode#isVirtualAnchor}. + return false; + } + + @Override + public boolean hoistNativeChildren() { return true; } @@ -231,4 +248,27 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { public void setShouldNotifyOnTextLayout(boolean shouldNotifyOnTextLayout) { mShouldNotifyOnTextLayout = shouldNotifyOnTextLayout; } + + @Override + public Iterable calculateLayoutOnChildren() { + // Run flexbox on and return the descendants which are inline views. + + if (mInlineViews == null || mInlineViews.isEmpty()) { + return null; + } + + 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(); + 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 f6f5201ecae34d..b14e49e8bf9afd 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 @@ -11,22 +11,35 @@ import android.graphics.drawable.Drawable; import android.os.Build; import androidx.appcompat.widget.AppCompatTextView; +import androidx.appcompat.widget.TintContextWrapper; import android.text.Layout; import android.text.Spannable; import android.text.Spanned; -import android.text.Spannable; import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.text.util.Linkify; import android.view.Gravity; +import android.view.View; import android.view.ViewGroup; + import com.facebook.common.logging.FLog; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.ReactConstants; +import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactCompoundView; +import com.facebook.react.uimanager.UIManagerModule; import com.facebook.react.uimanager.ViewDefaults; +import com.facebook.react.uimanager.events.RCTEventEmitter; import com.facebook.react.views.view.ReactViewBackgroundManager; import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + public class ReactTextView extends AppCompatTextView implements ReactCompoundView { private static final ViewGroup.LayoutParams EMPTY_LAYOUT_PARAMS = @@ -39,6 +52,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie private int mNumberOfLines = ViewDefaults.NUMBER_OF_LINES; private TextUtils.TruncateAt mEllipsizeLocation = TextUtils.TruncateAt.END; private int mLinkifyMaskType = 0; + private boolean mNotifyOnInlineViewLayout; private ReactViewBackgroundManager mReactBackgroundManager; private Spannable mSpanned; @@ -51,6 +65,185 @@ public ReactTextView(Context context) { mDefaultGravityVertical = getGravity() & Gravity.VERTICAL_GRAVITY_MASK; } + private WritableMap inlineViewJson(int visibility, int index, int left, int top, int right, int bottom) { + WritableMap json = Arguments.createMap(); + if (visibility == View.GONE) { + json.putString("visibility", "gone"); + json.putInt("index", index); + } else if (visibility == View.VISIBLE) { + json.putString("visibility", "visible"); + json.putInt("index", index); + json.putDouble("left", PixelUtil.toDIPFromPixel(left)); + json.putDouble("top", PixelUtil.toDIPFromPixel(top)); + json.putDouble("right", PixelUtil.toDIPFromPixel(right)); + json.putDouble("bottom", PixelUtil.toDIPFromPixel(bottom)); + } else { + json.putString("visibility", "unknown"); + json.putInt("index", index); + } + return json; + } + + private ReactContext getReactContext() { + Context context = getContext(); + return (context instanceof TintContextWrapper) + ? (ReactContext)((TintContextWrapper)context).getBaseContext() + : (ReactContext)context; + } + + @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 = getReactContext().getNativeModule(UIManagerModule.class); + + Spanned text = (Spanned) getText(); + Layout layout = getLayout(); + TextInlineViewPlaceholderSpan[] placeholders = text.getSpans(0, text.length(), TextInlineViewPlaceholderSpan.class); + ArrayList inlineViewInfoArray = mNotifyOnInlineViewLayout ? new ArrayList(placeholders.length) : null; + int textViewWidth = textViewRight - textViewLeft; + int textViewHeight = textViewBottom - textViewTop; + + for (TextInlineViewPlaceholderSpan placeholder : placeholders) { + View child = uiManager.resolveView(placeholder.getReactTag()); + + int start = text.getSpanStart(placeholder); + int line = layout.getLineForOffset(start); + boolean isLineTruncated = layout.getEllipsisCount(line) > 0; + + if (// This truncation check works well on recent versions of Android (tested on 5.1.1 and + // 6.0.1) but not on Android 4.4.4. The reason is that getEllipsisCount is buggy on + // Android 4.4.4. Specifically, it incorrectly returns 0 if an inline view is the first + // thing to be truncated. + (isLineTruncated && start >= layout.getLineStart(line) + layout.getEllipsisStart(line)) || + + // This truncation check works well on Android 4.4.4 but not on others (e.g. 6.0.1). + // On Android 4.4.4, getLineEnd returns the first truncated character whereas on 6.0.1, + // it appears to return the position after the last character on the line even if that + // character is truncated. + line >= mNumberOfLines || start >= layout.getLineEnd(line)) { + // On some versions of Android (e.g. 4.4.4, 5.1.1), getPrimaryHorizontal can infinite + // loop when called on a character that appears after the ellipsis. Avoid this bug by + // special casing the character truncation case. + child.setVisibility(View.GONE); + if (mNotifyOnInlineViewLayout) { + inlineViewInfoArray.add(inlineViewJson(View.GONE, start, -1, -1, -1, -1)); + } + } else { + int width = placeholder.getWidth(); + int height = placeholder.getHeight(); + + // Calculate if the direction of the placeholder character is Right-To-Left. + boolean isRtlChar = layout.isRtlCharAt(start); + + boolean isRtlParagraph = layout.getParagraphDirection(line) == Layout.DIR_RIGHT_TO_LEFT; + + int placeholderHorizontalPosition; + // 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) { + placeholderHorizontalPosition = isRtlParagraph + // Equivalent to `layout.getLineLeft(line)` but `getLineLeft` returns incorrect + // values when the paragraph is RTL and `setSingleLine(true)`. + ? textViewWidth - (int)layout.getLineWidth(line) + : (int) layout.getLineRight(line) - width; + } else { + // The direction of the paragraph may not be exactly the direction the string is heading in at the + // position of the placeholder. So, if the direction of the character is the same as the paragraph + // use primary, secondary otherwise. + boolean characterAndParagraphDirectionMatch = isRtlParagraph == isRtlChar; + + placeholderHorizontalPosition = characterAndParagraphDirectionMatch + ? (int) layout.getPrimaryHorizontal(start) + : (int) layout.getSecondaryHorizontal(start); + + if (isRtlParagraph) { + // Adjust `placeholderHorizontalPosition` to work around an Android bug. + // The bug is when the paragraph is RTL and `setSingleLine(true)`, some layout + // methods such as `getPrimaryHorizontal`, `getSecondaryHorizontal`, and + // `getLineRight` return incorrect values. Their return values seem to be off + // by the same number of pixels so subtracting these values cancels out the error. + // + // The result is equivalent to bugless versions of `getPrimaryHorizontal`/`getSecondaryHorizontal`. + placeholderHorizontalPosition = textViewWidth - ((int)layout.getLineRight(line) - placeholderHorizontalPosition); + } + + if (isRtlChar) { + placeholderHorizontalPosition -= width; + } + } + + int leftRelativeToTextView = isRtlChar + ? placeholderHorizontalPosition + getTotalPaddingRight() + : placeholderHorizontalPosition + getTotalPaddingLeft(); + + int left = textViewLeft + leftRelativeToTextView; + + // Vertically align the inline view to the baseline of the line of text. + int topRelativeToTextView = getTotalPaddingTop() + layout.getLineBaseline(line) - height; + int top = textViewTop + topRelativeToTextView; + + boolean isFullyClipped = textViewWidth <= leftRelativeToTextView || textViewHeight <= topRelativeToTextView; + int layoutVisibility = isFullyClipped ? View.GONE : View.VISIBLE; + int layoutLeft = left; + int layoutTop = top; + int layoutRight = left + width; + int layoutBottom = top + height; + + // Keep these parameters in sync with what goes into `inlineViewInfoArray`. + child.setVisibility(layoutVisibility); + child.layout(layoutLeft, layoutTop, layoutRight, layoutBottom); + if (mNotifyOnInlineViewLayout) { + inlineViewInfoArray.add( + inlineViewJson(layoutVisibility, start, layoutLeft, layoutTop, layoutRight, layoutBottom)); + } + } + } + + if (mNotifyOnInlineViewLayout) { + Collections.sort(inlineViewInfoArray, new Comparator() { + @Override + public int compare(Object o1, Object o2) { + WritableMap m1 = (WritableMap)o1; + WritableMap m2 = (WritableMap)o2; + return m1.getInt("index") - m2.getInt("index"); + } + }); + WritableArray inlineViewInfoArray2 = Arguments.createArray(); + for (Object item : inlineViewInfoArray) { + inlineViewInfoArray2.pushMap((WritableMap)item); + } + + WritableMap event = Arguments.createMap(); + event.putArray("inlineViews", inlineViewInfoArray2); + getReactContext().getJSModule(RCTEventEmitter.class).receiveEvent( + getId(), + "topInlineViewLayout", + event + ); + } + } + public void setText(ReactTextUpdate update) { mContainsImages = update.containsImages(); // Android's TextView crashes when it tries to relayout if LayoutParams are @@ -86,6 +279,9 @@ public void setText(ReactTextUpdate update) { setJustificationMode(update.getJustificationMode()); } } + + // Ensure onLayout is called so the inline views can be repositioned. + requestLayout(); } @Override @@ -248,6 +444,10 @@ public void setEllipsizeLocation(TextUtils.TruncateAt ellipsizeLocation) { mEllipsizeLocation = ellipsizeLocation; } + public void setNotifyOnInlineViewLayout(boolean notifyOnInlineViewLayout) { + mNotifyOnInlineViewLayout = notifyOnInlineViewLayout; + } + public void updateView() { @Nullable TextUtils.TruncateAt ellipsizeLocation = mNumberOfLines == ViewDefaults.NUMBER_OF_LINES ? null : mEllipsizeLocation; setEllipsize(ellipsizeLocation); 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 edcaf4a22cd59b..7e20919bfc7069 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 @@ -13,10 +13,12 @@ import com.facebook.react.common.MapBuilder; import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.IViewManagerWithChildren; import com.facebook.react.uimanager.ReactStylesDiffMap; import com.facebook.react.uimanager.ThemedReactContext; import com.facebook.yoga.YogaMeasureMode; import java.util.Map; + import javax.annotation.Nullable; /** @@ -25,7 +27,8 @@ */ @ReactModule(name = ReactTextViewManager.REACT_CLASS) public class ReactTextViewManager - extends ReactTextAnchorViewManager { + extends ReactTextAnchorViewManager + implements IViewManagerWithChildren { @VisibleForTesting public static final String REACT_CLASS = "RCTText"; @@ -65,6 +68,10 @@ protected void onAfterUpdateTransaction(ReactTextView view) { view.updateView(); } + public boolean needsCustomLayoutForChildren() { + return true; + } + @Override public Object updateLocalData( ReactTextView view, ReactStylesDiffMap props, ReactStylesDiffMap localData) { @@ -98,7 +105,9 @@ public Object updateLocalData( @Override public @Nullable Map getExportedCustomDirectEventTypeConstants() { - return MapBuilder.of("topTextLayout", MapBuilder.of("registrationName", "onTextLayout")); + return MapBuilder.of( + "topTextLayout", MapBuilder.of("registrationName", "onTextLayout"), + "topInlineViewLayout", MapBuilder.of("registrationName", "onInlineViewLayout")); } @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java index 020cfdc35211f1..9ebf1d83d1e56f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributes.java @@ -27,7 +27,7 @@ public class TextAttributes { private float mLineHeight = Float.NaN; private float mLetterSpacing = Float.NaN; private float mMaxFontSizeMultiplier = Float.NaN; - private float mHeightOfTallestInlineImage = Float.NaN; + private float mHeightOfTallestInlineViewOrImage = Float.NaN; private TextTransform mTextTransform = TextTransform.UNSET; public TextAttributes() { @@ -44,7 +44,7 @@ public TextAttributes applyChild(TextAttributes child) { result.mLineHeight = !Float.isNaN(child.mLineHeight) ? child.mLineHeight : mLineHeight; result.mLetterSpacing = !Float.isNaN(child.mLetterSpacing) ? child.mLetterSpacing : mLetterSpacing; result.mMaxFontSizeMultiplier = !Float.isNaN(child.mMaxFontSizeMultiplier) ? child.mMaxFontSizeMultiplier : mMaxFontSizeMultiplier; - result.mHeightOfTallestInlineImage = !Float.isNaN(child.mHeightOfTallestInlineImage) ? child.mHeightOfTallestInlineImage : mHeightOfTallestInlineImage; + result.mHeightOfTallestInlineViewOrImage = !Float.isNaN(child.mHeightOfTallestInlineViewOrImage) ? child.mHeightOfTallestInlineViewOrImage : mHeightOfTallestInlineViewOrImage; result.mTextTransform = child.mTextTransform != TextTransform.UNSET ? child.mTextTransform : mTextTransform; return result; @@ -96,12 +96,12 @@ public void setMaxFontSizeMultiplier(float maxFontSizeMultiplier) { mMaxFontSizeMultiplier = maxFontSizeMultiplier; } - public float getHeightOfTallestInlineImage() { - return mHeightOfTallestInlineImage; + public float getHeightOfTallestInlineViewOrImage() { + return mHeightOfTallestInlineViewOrImage; } - public void setHeightOfTallestInlineImage(float value) { - mHeightOfTallestInlineImage = value; + public void setHeightOfTallestInlineViewOrImage(float value) { + mHeightOfTallestInlineViewOrImage = value; } public TextTransform getTextTransform() { @@ -137,9 +137,9 @@ public float getEffectiveLineHeight() { // Take into account the requested line height // and the height of the inline images. boolean useInlineViewHeight = - !Float.isNaN(mHeightOfTallestInlineImage) - && mHeightOfTallestInlineImage > lineHeight; - return useInlineViewHeight ? mHeightOfTallestInlineImage : lineHeight; + !Float.isNaN(mHeightOfTallestInlineViewOrImage) + && mHeightOfTallestInlineViewOrImage > lineHeight; + return useInlineViewHeight ? mHeightOfTallestInlineViewOrImage : lineHeight; } public float getEffectiveLetterSpacing() { @@ -169,7 +169,7 @@ public String toString() { + "\n getAllowFontScaling(): " + getAllowFontScaling() + "\n getFontSize(): " + getFontSize() + "\n getEffectiveFontSize(): " + getEffectiveFontSize() - + "\n getHeightOfTallestInlineImage(): " + getHeightOfTallestInlineImage() + + "\n getHeightOfTallestInlineViewOrImage(): " + getHeightOfTallestInlineViewOrImage() + "\n getLetterSpacing(): " + getLetterSpacing() + "\n getEffectiveLetterSpacing(): " + getEffectiveLetterSpacing() + "\n getLineHeight(): " + getLineHeight() 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..9e6726efe3d563 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.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 implements ReactSpan { + 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 3189164288ed1c..7ad7a2920a0763 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 @@ -18,6 +18,7 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.common.annotations.VisibleForTesting; import com.facebook.react.uimanager.LayoutShadowNode; +import com.facebook.react.uimanager.NativeViewHierarchyOptimizer; import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactShadowNodeImpl; import com.facebook.react.uimanager.Spacing; @@ -196,7 +197,12 @@ public void onCollectExtraUpdates(UIViewOperationQueue uiViewOperationQueue) { if (mMostRecentEventCount != UNSET) { ReactTextUpdate reactTextUpdate = new ReactTextUpdate( - spannedFromShadowNode(this, getText()), + spannedFromShadowNode( + this, + getText(), + /* supportsInlineViews: */ false, + /* nativeViewHierarchyOptimizer: */ null // only needed to support inline views + ), mMostRecentEventCount, mContainsImages, getPadding(Spacing.LEFT),