From a2285b17907cfd3d7cfd311e18027da800a48b2e Mon Sep 17 00:00:00 2001 From: Adam Comella Date: Mon, 1 Apr 2019 19:52:38 -0700 Subject: [PATCH] Android: Enable views to be nested within (#23195) Summary: Potential breaking change: The signature of ReactShadowNode's onBeforeLayout method was changed - Before: public void onBeforeLayout() - After: public void onBeforeLayout(NativeViewHierarchyOptimizer nativeViewHierarchyOptimizer) Implements same feature as this iOS PR: https://github.com/facebook/react-native/pull/7304 Previously, only Text and Image could be nested within Text. Now, any view can be nested within Text. One restriction of this feature is that developers must give inline views a width and a height via the style prop. Previously, inline Images were supported via FrescoBasedReactTextInlineImageSpan. To get support for nesting views within Text, we create one special kind of span per inline view. This span is called TextInlineViewPlaceholderSpan. It is the same size as the inline view. Its job is just to occupy space -- it doesn't render any visual. After the text is rendered, we query the Android Layout object associated with the TextView to find out where it has positioned each TextInlineViewPlaceholderSpan. We then position the views to be at those locations. One tricky aspect of the implementation is that the Text component needs to be able to render native children (the inline views) but the Android TextView cannot have children. This is solved by having the native parent of the ReactTextView also host the inline views. Implementation-wise, this was accomplished by extending the NativeViewHierarchyOptimizer to handle this case. The optimizer now handles these cases: - Node is not in the native tree. An ancestor must host its children. - Node is in the native tree and it can host its own children. - (new) Node is in the native tree but it cannot host its own children. An ancestor must host both this node and its children. I added the `onInlineViewLayout` event which is useful for writing tests for verifying that the inline views are positioned properly. Limitation: Clipping ---------- If Text's height/width is small such that an inline view doesn't completely fit, the inline view may still be fully visible due to hoisting (the inline view isn't actually parented to the Text which has the limited size. It is parented to an ancestor which may have a different clipping rectangle.). Prior to this change, layout-only views had a similar limitation. Pull Request resolved: https://github.com/facebook/react-native/pull/23195 Differential Revision: D14014668 Pulled By: shergin fbshipit-source-id: d46130f3d19cc83ac7ddf423adcc9e23988245d3 --- Libraries/Components/View/View.js | 15 +- Libraries/Text/Text.js | 4 + .../uimanager/IViewManagerWithChildren.java | 22 ++ .../facebook/react/uimanager/NativeKind.java | 25 +++ .../uimanager/NativeViewHierarchyManager.java | 12 +- .../NativeViewHierarchyOptimizer.java | 107 ++++++---- .../react/uimanager/ReactShadowNode.java | 29 ++- .../react/uimanager/ReactShadowNodeImpl.java | 89 ++++++-- .../react/uimanager/UIImplementation.java | 43 ++-- .../react/uimanager/UIManagerModule.java | 5 + .../react/uimanager/ViewGroupManager.java | 4 +- .../views/text/ReactBaseTextShadowNode.java | 99 +++++++-- .../text/ReactTextAnchorViewManager.java | 5 + .../react/views/text/ReactTextShadowNode.java | 44 +++- .../react/views/text/ReactTextView.java | 202 +++++++++++++++++- .../views/text/ReactTextViewManager.java | 13 +- .../react/views/text/TextAttributes.java | 20 +- .../text/TextInlineViewPlaceholderSpan.java | 61 ++++++ .../textinput/ReactTextInputShadowNode.java | 8 +- 19 files changed, 672 insertions(+), 135 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/IViewManagerWithChildren.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeKind.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/text/TextInlineViewPlaceholderSpan.java diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js index 3e9d7c10a1a7b6..8e75dcac461fd6 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; @@ -35,17 +32,7 @@ if (__DEV__) { props: Props, forwardedRef: React.Ref, ) => { - return ( - - {hasTextAncestor => { - invariant( - !hasTextAncestor, - 'Nesting of within is not currently supported.', - ); - return ; - }} - - ); + return ; }; ViewToExport = React.forwardRef(View); ViewToExport.displayName = '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),