Skip to content

Commit 7095ee9

Browse files
author
Adam Comella
committed
Android: Enable views to be nested within <Text>
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: facebook#7304 Previously, only Text and Image could be nested within Text. Now, any view can be nested within Text. One restriction of this feature is that developers must give inline views a width and a height via the style prop. Previously, inline Images were supported via FrescoBasedReactTextInlineImageSpan. To get support for nesting views within Text, we create one special kind of span per inline view. This span is called TextInlineViewPlaceholderSpan. It is the same size as the inline view. Its job is just to occupy space -- it doesn't render any visual. After the text is rendered, we query the Android Layout object associated with the TextView to find out where it has positioned each TextInlineViewPlaceholderSpan. We then position the views to be at those locations. One tricky aspect of the implementation is that the Text component needs to be able to render native children (the inline views) but the Android TextView cannot have children. This is solved by having the native parent of the ReactTextView also host the inline views. Implementation-wise, this was accomplished by extending the NativeViewHierarchyOptimizer to handle this case. The optimizer now handles these cases: - Node is not in the native tree. An ancestor must host its children. - Node is in the native tree and it can host its own children. - (new) Node is in the native tree but it cannot host its own children. An ancestor must host both this node and its children. Limitation: Clipping ---------- If Text's height/width is small such that an inline view doesn't completely fit, the inline view may still be fully visible due to hoisting (the inline view isn't actually parented to the Text which has the limited size. It is parented to an ancestor which may have a different clipping rectangle.). Prior to this change, layout-only views had a similar limitation. Test Plan: ---------- Created a test app which tested a variety of cases including: - An inline view that is smaller than the line height - An inline view that is bigger than the line height - An inline view that is in text that wraps to multiple lines - An inline view that is in text that gets truncated and the inline view occurs past the truncation point. - An inline view that is nested under 1 and 2 layers of Text I considered each of the above test cases under these variations: - Type of inline element: image or view - Type of hosting text component: Text or TextInput All of the inline image cases behave identically before and after my change. The inline view behaviors are similar to the inline image behaviors. Note that TextInput doesn't support inline views (only inline images). Lastly, we've been using a change like this in Skype for the past couple of years. Changelog: ---------- [Android] [Added] - Enable views to be nested within <Text>
1 parent 10b5218 commit 7095ee9

20 files changed

+669
-133
lines changed

Libraries/Components/View/View.js

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@
1111
'use strict';
1212

1313
const React = require('React');
14-
const TextAncestor = require('TextAncestor');
1514
const ViewNativeComponent = require('ViewNativeComponent');
1615

17-
const invariant = require('invariant');
18-
1916
import type {ViewProps} from 'ViewPropTypes';
2017

2118
export type Props = ViewProps;
@@ -36,15 +33,7 @@ if (__DEV__) {
3633
forwardedRef: React.Ref<typeof ViewNativeComponent>,
3734
) => {
3835
return (
39-
<TextAncestor.Consumer>
40-
{hasTextAncestor => {
41-
invariant(
42-
!hasTextAncestor,
43-
'Nesting of <View> within <Text> is not currently supported.',
44-
);
45-
return <ViewNativeComponent {...props} ref={forwardedRef} />;
46-
}}
47-
</TextAncestor.Consumer>
36+
<ViewNativeComponent {...props} ref={forwardedRef} />
4837
);
4938
};
5039
ViewToExport = React.forwardRef(View);

Libraries/Text/Text.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,15 @@ const viewConfig = {
6666
minimumFontScale: true,
6767
textBreakStrategy: true,
6868
onTextLayout: true,
69+
onInlineViewLayout: true,
6970
},
7071
directEventTypes: {
7172
topTextLayout: {
7273
registrationName: 'onTextLayout',
7374
},
75+
topInlineViewLayout: {
76+
registrationName: 'onInlineViewLayout',
77+
},
7478
},
7579
uiViewClassName: 'RCTText',
7680
};

RNTester/js/TextExample.android.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,9 +494,13 @@ class TextExample extends React.Component<{}> {
494494
This text will have a orange highlight on selection.
495495
</Text>
496496
</RNTesterBlock>
497-
<RNTesterBlock title="Inline images">
497+
<RNTesterBlock title="Inline views">
498498
<Text>
499-
This text contains an inline image{' '}
499+
This text contains an inline blue view{' '}
500+
<View
501+
style={{width: 25, height: 25, backgroundColor: 'steelblue'}}
502+
/>{' '}
503+
and an inline image{' '}
500504
<Image source={require('./flux.png')} />. Neat, huh?
501505
</Text>
502506
</RNTesterBlock>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
package com.facebook.react.uimanager;
11+
12+
public interface IViewManagerWithChildren {
13+
/**
14+
* Returns whether this View type needs to handle laying out its own children instead of
15+
* deferring to the standard css-layout algorithm.
16+
* Returns true for the layout to *not* be automatically invoked. Instead onLayout will be
17+
* invoked as normal and it is the View instance's responsibility to properly call layout on its
18+
* children.
19+
* Returns false for the default behavior of automatically laying out children without going
20+
* through the ViewGroup's onLayout method. In that case, onLayout for this View type must *not*
21+
* call layout on its children.
22+
*/
23+
public boolean needsCustomLayoutForChildren();
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
package com.facebook.react.uimanager;
11+
12+
// Common conditionals:
13+
// - `kind == PARENT` checks whether the node can host children in the native tree.
14+
// - `kind != NONE` checks whether the node appears in the native tree.
15+
16+
public enum NativeKind {
17+
// Node is in the native hierarchy and the HierarchyOptimizer should assume it can host children
18+
// (e.g. because it's a ViewGroup). Note that it's okay if the node doesn't support children. When
19+
// the HierarchyOptimizer generates children manipulation commands for that node, the
20+
// HierarchyManager will catch this case and throw an exception.
21+
PARENT,
22+
// Node is in the native hierarchy, it may have children, but it cannot host them itself (e.g.
23+
// because it isn't a ViewGroup). Consequently, its children need to be hosted by an ancestor.
24+
LEAF,
25+
// Node is not in the native hierarchy.
26+
NONE
27+
}

ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,16 @@ public synchronized void updateLayout(
193193
// Check if the parent of the view has to layout the view, or the child has to lay itself out.
194194
if (!mRootTags.get(parentTag)) {
195195
ViewManager parentViewManager = mTagsToViewManagers.get(parentTag);
196-
ViewGroupManager parentViewGroupManager;
197-
if (parentViewManager instanceof ViewGroupManager) {
198-
parentViewGroupManager = (ViewGroupManager) parentViewManager;
196+
IViewManagerWithChildren parentViewManagerWithChildren;
197+
if (parentViewManager instanceof IViewManagerWithChildren) {
198+
parentViewManagerWithChildren = (IViewManagerWithChildren) parentViewManager;
199199
} else {
200200
throw new IllegalViewOperationException(
201201
"Trying to use view with tag " + parentTag +
202-
" as a parent, but its Manager doesn't extends ViewGroupManager");
202+
" as a parent, but its Manager doesn't implement IViewManagerWithChildren");
203203
}
204-
if (parentViewGroupManager != null
205-
&& !parentViewGroupManager.needsCustomLayoutForChildren()) {
204+
if (parentViewManagerWithChildren != null
205+
&& !parentViewManagerWithChildren.needsCustomLayoutForChildren()) {
206206
updateLayout(viewToUpdate, x, y, width, height);
207207
}
208208
} else {

ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyOptimizer.java

Lines changed: 70 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@ private static class NodeIndexPair {
6464
private final ShadowNodeRegistry mShadowNodeRegistry;
6565
private final SparseBooleanArray mTagsWithLayoutVisited = new SparseBooleanArray();
6666

67+
public static void assertNodeSupportedWithoutOptimizer(ReactShadowNode node) {
68+
// NativeKind.LEAF nodes require the optimizer. They are not ViewGroups so they cannot host
69+
// their native children themselves. Their native children need to be hoisted by the optimizer
70+
// to an ancestor which is a ViewGroup.
71+
Assertions.assertCondition(
72+
node.getNativeKind() != NativeKind.LEAF,
73+
"Nodes with NativeKind.LEAF are not supported when the optimizer is disabled");
74+
}
75+
6776
public NativeViewHierarchyOptimizer(
6877
UIViewOperationQueue uiViewOperationQueue,
6978
ShadowNodeRegistry shadowNodeRegistry) {
@@ -79,6 +88,7 @@ public void handleCreateView(
7988
ThemedReactContext themedContext,
8089
@Nullable ReactStylesDiffMap initialProps) {
8190
if (!ENABLED) {
91+
assertNodeSupportedWithoutOptimizer(node);
8292
int tag = node.getReactTag();
8393
mUIViewOperationQueue.enqueueCreateView(
8494
themedContext,
@@ -92,7 +102,7 @@ public void handleCreateView(
92102
isLayoutOnlyAndCollapsable(initialProps);
93103
node.setIsLayoutOnly(isLayoutOnly);
94104

95-
if (!isLayoutOnly) {
105+
if (node.getNativeKind() != NativeKind.NONE) {
96106
mUIViewOperationQueue.enqueueCreateView(
97107
themedContext,
98108
node.getReactTag(),
@@ -118,6 +128,7 @@ public void handleUpdateView(
118128
String className,
119129
ReactStylesDiffMap props) {
120130
if (!ENABLED) {
131+
assertNodeSupportedWithoutOptimizer(node);
121132
mUIViewOperationQueue.enqueueUpdateProperties(node.getReactTag(), className, props);
122133
return;
123134
}
@@ -147,6 +158,7 @@ public void handleManageChildren(
147158
ViewAtIndex[] viewsToAdd,
148159
int[] tagsToDelete) {
149160
if (!ENABLED) {
161+
assertNodeSupportedWithoutOptimizer(nodeToManage);
150162
mUIViewOperationQueue.enqueueManageChildren(
151163
nodeToManage.getReactTag(),
152164
indicesToRemove,
@@ -187,6 +199,7 @@ public void handleSetChildren(
187199
ReadableArray childrenTags
188200
) {
189201
if (!ENABLED) {
202+
assertNodeSupportedWithoutOptimizer(nodeToManage);
190203
mUIViewOperationQueue.enqueueSetChildren(
191204
nodeToManage.getReactTag(),
192205
childrenTags);
@@ -206,8 +219,9 @@ public void handleSetChildren(
206219
*/
207220
public void handleUpdateLayout(ReactShadowNode node) {
208221
if (!ENABLED) {
222+
assertNodeSupportedWithoutOptimizer(node);
209223
mUIViewOperationQueue.enqueueUpdateLayout(
210-
Assertions.assertNotNull(node.getParent()).getReactTag(),
224+
Assertions.assertNotNull(node.getLayoutParent()).getReactTag(),
211225
node.getReactTag(),
212226
node.getScreenX(),
213227
node.getScreenY(),
@@ -219,6 +233,12 @@ public void handleUpdateLayout(ReactShadowNode node) {
219233
applyLayoutBase(node);
220234
}
221235

236+
public void handleForceViewToBeNonLayoutOnly(ReactShadowNode node) {
237+
if (node.isLayoutOnly()) {
238+
transitionLayoutOnlyViewToNativeView(node, null);
239+
}
240+
}
241+
222242
/**
223243
* Processes the shadow hierarchy to dispatch all necessary updateLayout calls to the native
224244
* hierarchy. Should be called after all updateLayout calls for a batch have been handled.
@@ -227,16 +247,18 @@ public void onBatchComplete() {
227247
mTagsWithLayoutVisited.clear();
228248
}
229249

230-
private NodeIndexPair walkUpUntilNonLayoutOnly(
250+
private NodeIndexPair walkUpUntilNativeKindIsParent(
231251
ReactShadowNode node,
232252
int indexInNativeChildren) {
233-
while (node.isLayoutOnly()) {
253+
while (node.getNativeKind() != NativeKind.PARENT) {
234254
ReactShadowNode parent = node.getParent();
235255
if (parent == null) {
236256
return null;
237257
}
238258

239-
indexInNativeChildren = indexInNativeChildren + parent.getNativeOffsetForChild(node);
259+
indexInNativeChildren = indexInNativeChildren +
260+
(node.getNativeKind() == NativeKind.LEAF ? 1 : 0) +
261+
parent.getNativeOffsetForChild(node);
240262
node = parent;
241263
}
242264

@@ -245,8 +267,8 @@ private NodeIndexPair walkUpUntilNonLayoutOnly(
245267

246268
private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int index) {
247269
int indexInNativeChildren = parent.getNativeOffsetForChild(parent.getChildAt(index));
248-
if (parent.isLayoutOnly()) {
249-
NodeIndexPair result = walkUpUntilNonLayoutOnly(parent, indexInNativeChildren);
270+
if (parent.getNativeKind() != NativeKind.PARENT) {
271+
NodeIndexPair result = walkUpUntilNativeKindIsParent(parent, indexInNativeChildren);
250272
if (result == null) {
251273
// If the parent hasn't been attached to its native parent yet, don't issue commands to the
252274
// native hierarchy. We'll do that when the parent node actually gets attached somewhere.
@@ -256,20 +278,26 @@ private void addNodeToNode(ReactShadowNode parent, ReactShadowNode child, int in
256278
indexInNativeChildren = result.index;
257279
}
258280

259-
if (!child.isLayoutOnly()) {
260-
addNonLayoutNode(parent, child, indexInNativeChildren);
281+
if (child.getNativeKind() != NativeKind.NONE) {
282+
addNativeChild(parent, child, indexInNativeChildren);
261283
} else {
262-
addLayoutOnlyNode(parent, child, indexInNativeChildren);
284+
addNonNativeChild(parent, child, indexInNativeChildren);
263285
}
264286
}
265287

266288
/**
267-
* For handling node removal from manageChildren. In the case of removing a layout-only node, we
268-
* need to instead recursively remove all its children from their native parents.
289+
* For handling node removal from manageChildren. In the case of removing a node which isn't
290+
* hosting its own children (e.g. layout-only or NativeKind.LEAF), we need to recursively remove
291+
* all its children from their native parents.
269292
*/
270293
private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDelete) {
271-
ReactShadowNode nativeNodeToRemoveFrom = nodeToRemove.getNativeParent();
294+
if (nodeToRemove.getNativeKind() != NativeKind.PARENT) {
295+
for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) {
296+
removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete);
297+
}
298+
}
272299

300+
ReactShadowNode nativeNodeToRemoveFrom = nodeToRemove.getNativeParent();
273301
if (nativeNodeToRemoveFrom != null) {
274302
int index = nativeNodeToRemoveFrom.indexOfNativeChild(nodeToRemove);
275303
nativeNodeToRemoveFrom.removeNativeChildAt(index);
@@ -279,21 +307,17 @@ private void removeNodeFromParent(ReactShadowNode nodeToRemove, boolean shouldDe
279307
new int[]{index},
280308
null,
281309
shouldDelete ? new int[]{nodeToRemove.getReactTag()} : null);
282-
} else {
283-
for (int i = nodeToRemove.getChildCount() - 1; i >= 0; i--) {
284-
removeNodeFromParent(nodeToRemove.getChildAt(i), shouldDelete);
285-
}
286310
}
287311
}
288312

289-
private void addLayoutOnlyNode(
290-
ReactShadowNode nonLayoutOnlyNode,
291-
ReactShadowNode layoutOnlyNode,
313+
private void addNonNativeChild(
314+
ReactShadowNode nativeParent,
315+
ReactShadowNode nonNativeChild,
292316
int index) {
293-
addGrandchildren(nonLayoutOnlyNode, layoutOnlyNode, index);
317+
addGrandchildren(nativeParent, nonNativeChild, index);
294318
}
295319

296-
private void addNonLayoutNode(
320+
private void addNativeChild(
297321
ReactShadowNode parent,
298322
ReactShadowNode child,
299323
int index) {
@@ -303,30 +327,33 @@ private void addNonLayoutNode(
303327
null,
304328
new ViewAtIndex[]{new ViewAtIndex(child.getReactTag(), index)},
305329
null);
330+
331+
if (child.getNativeKind() != NativeKind.PARENT) {
332+
addGrandchildren(parent, child, index + 1);
333+
}
306334
}
307335

308336
private void addGrandchildren(
309337
ReactShadowNode nativeParent,
310338
ReactShadowNode child,
311339
int index) {
312-
Assertions.assertCondition(!nativeParent.isLayoutOnly());
340+
Assertions.assertCondition(child.getNativeKind() != NativeKind.PARENT);
313341

314342
// `child` can't hold native children. Add all of `child`'s children to `parent`.
315343
int currentIndex = index;
316344
for (int i = 0; i < child.getChildCount(); i++) {
317345
ReactShadowNode grandchild = child.getChildAt(i);
318346
Assertions.assertCondition(grandchild.getNativeParent() == null);
319347

320-
if (grandchild.isLayoutOnly()) {
321-
// Adding this child could result in adding multiple native views
322-
int grandchildCountBefore = nativeParent.getNativeChildCount();
323-
addLayoutOnlyNode(nativeParent, grandchild, currentIndex);
324-
int grandchildCountAfter = nativeParent.getNativeChildCount();
325-
currentIndex += grandchildCountAfter - grandchildCountBefore;
348+
// Adding this child could result in adding multiple native views
349+
int grandchildCountBefore = nativeParent.getNativeChildCount();
350+
if (grandchild.getNativeKind() == NativeKind.NONE) {
351+
addNonNativeChild(nativeParent, grandchild, currentIndex);
326352
} else {
327-
addNonLayoutNode(nativeParent, grandchild, currentIndex);
328-
currentIndex++;
353+
addNativeChild(nativeParent, grandchild, currentIndex);
329354
}
355+
int grandchildCountAfter = nativeParent.getNativeChildCount();
356+
currentIndex += grandchildCountAfter - grandchildCountBefore;
330357
}
331358
}
332359

@@ -345,10 +372,16 @@ private void applyLayoutBase(ReactShadowNode node) {
345372
int x = node.getScreenX();
346373
int y = node.getScreenY();
347374

348-
while (parent != null && parent.isLayoutOnly()) {
349-
// TODO(7854667): handle and test proper clipping
350-
x += Math.round(parent.getLayoutX());
351-
y += Math.round(parent.getLayoutY());
375+
while (parent != null && parent.getNativeKind() != NativeKind.PARENT) {
376+
if (!parent.isVirtual()) {
377+
// Skip these additions for virtual nodes. This has the same effect as `getLayout*`
378+
// returning `0`. Virtual nodes aren't in the Yoga tree so we can't call `getLayout*` on
379+
// them.
380+
381+
// TODO(7854667): handle and test proper clipping
382+
x += Math.round(parent.getLayoutX());
383+
y += Math.round(parent.getLayoutY());
384+
}
352385

353386
parent = parent.getParent();
354387
}
@@ -357,10 +390,10 @@ private void applyLayoutBase(ReactShadowNode node) {
357390
}
358391

359392
private void applyLayoutRecursive(ReactShadowNode toUpdate, int x, int y) {
360-
if (!toUpdate.isLayoutOnly() && toUpdate.getNativeParent() != null) {
393+
if (toUpdate.getNativeKind() != NativeKind.NONE && toUpdate.getNativeParent() != null) {
361394
int tag = toUpdate.getReactTag();
362395
mUIViewOperationQueue.enqueueUpdateLayout(
363-
toUpdate.getNativeParent().getReactTag(),
396+
toUpdate.getLayoutParent().getReactTag(),
364397
tag,
365398
x,
366399
y,

0 commit comments

Comments
 (0)