From 26fdb44c5714b8cc26ea0b14a9602a0be2a8876c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Thu, 13 Apr 2023 09:19:00 -0700 Subject: [PATCH] Implement offsetParent, offsetTop and offsetLeft Summary: This adds a new method in Fabric to get the offset for an element, and uses it to implement the following methods as defined in as defined in https://github.com/react-native-community/discussions-and-proposals/pull/607 : * `offsetParent`: returns the ancestor that's used to position the element relative to. In React Native, this is always the immediate parent, except if the element has `display: none`, in which case is `null`. * `offsetTop`, `offsetLeft`: returns the position of the outer border of the element, relative to the inner border of its offset parent. This is similar to our existing `measure` function, except that `measure` uses the outer border of the parent, instead of the inner one. As with `offsetWidth` and `offsetHeight`, these methods return integers instead of double precision values. Extra: added descriptions to some fields in `LayoutMetrics` :) Changelog: [internal] bypass-github-export-checks Reviewed By: rshest Differential Revision: D44512594 fbshipit-source-id: 8f05e21f73397db7b88e6841cc117320d1229979 --- .../Libraries/DOM/Nodes/ReactNativeElement.js | 44 +++++++++- .../Libraries/ReactNative/FabricUIManager.js | 7 ++ .../ReactNative/__mocks__/FabricUIManager.js | 57 ++++++++++++ .../react/renderer/core/LayoutMetrics.h | 7 ++ .../renderer/uimanager/UIManagerBinding.cpp | 87 +++++++++++++++++++ 5 files changed, 198 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/DOM/Nodes/ReactNativeElement.js b/packages/react-native/Libraries/DOM/Nodes/ReactNativeElement.js index f76e57b3d55..e9ade0185aa 100644 --- a/packages/react-native/Libraries/DOM/Nodes/ReactNativeElement.js +++ b/packages/react-native/Libraries/DOM/Nodes/ReactNativeElement.js @@ -27,7 +27,10 @@ import {create as createAttributePayload} from '../../ReactNative/ReactFabricPub import warnForStyleProps from '../../ReactNative/ReactFabricPublicInstance/warnForStyleProps'; import ReadOnlyElement from './ReadOnlyElement'; import ReadOnlyNode from './ReadOnlyNode'; -import {getShadowNode} from './ReadOnlyNode'; +import { + getPublicInstanceFromInternalInstanceHandle, + getShadowNode, +} from './ReadOnlyNode'; import nullthrows from 'nullthrows'; const noop = () => {}; @@ -59,15 +62,48 @@ export default class ReactNativeElement } get offsetLeft(): number { - throw new TypeError('Unimplemented'); + const node = getShadowNode(this); + + if (node != null) { + const offset = nullthrows(getFabricUIManager()).getOffset(node); + if (offset != null) { + return Math.round(offset[2]); + } + } + + return 0; } get offsetParent(): ReadOnlyElement | null { - throw new TypeError('Unimplemented'); + const node = getShadowNode(this); + + if (node != null) { + const offset = nullthrows(getFabricUIManager()).getOffset(node); + if (offset != null) { + const offsetParentInstanceHandle = offset[0]; + const offsetParent = getPublicInstanceFromInternalInstanceHandle( + offsetParentInstanceHandle, + ); + // $FlowExpectedError[incompatible-type] The value returned by `getOffset` is always an instance handle for `ReadOnlyElement`. + const offsetParentElement: ReadOnlyElement = offsetParent; + return offsetParentElement; + } + } + + return null; } get offsetTop(): number { - throw new TypeError('Unimplemented'); + const node = getShadowNode(this); + + if (node != null) { + const offset = nullthrows(getFabricUIManager()).getOffset(node); + if (offset != null) { + return Math.round(offset[1]); + } + } + + return 0; } get offsetWidth(): number { diff --git a/packages/react-native/Libraries/ReactNative/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/FabricUIManager.js index 3ff2ae7d218..e0c062fb854 100644 --- a/packages/react-native/Libraries/ReactNative/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/FabricUIManager.js @@ -79,6 +79,13 @@ export type Spec = {| /* width:*/ number, /* height:*/ number, ], + +getOffset: ( + node: Node, + ) => ?[ + /* offsetParent: */ InternalInstanceHandle, + /* offsetTop: */ number, + /* offsetLeft: */ number, + ], |}; // This is exposed as a getter because apps using the legacy renderer AND diff --git a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js index 4674931a840..34cf9f82871 100644 --- a/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/__mocks__/FabricUIManager.js @@ -136,6 +136,13 @@ function* dfs(node: ?Node): Iterator { } } +function hasDisplayNone(node: Node): boolean { + const props = fromNode(node).props; + // Style is flattened when passed to native, so there's no style object. + // $FlowFixMe[prop-missing] + return props != null && props.display === 'none'; +} + const FabricUIManagerMock: FabricUIManager = { createNode: jest.fn( ( @@ -374,6 +381,56 @@ const FabricUIManagerMock: FabricUIManager = { return ReadOnlyNode.DOCUMENT_POSITION_FOLLOWING; }), + getOffset: jest.fn( + ( + node: Node, + ): ?[ + /* offsetParent: */ InternalInstanceHandle, + /* offsetTop: */ number, + /* offsetLeft: */ number, + ] => { + const ancestors = getAncestorsInCurrentTree(node); + if (ancestors == null) { + return null; + } + + const [parent, position] = ancestors[ancestors.length - 1]; + const nodeInCurrentTree = fromNode(parent).children[position]; + + const currentProps = + nodeInCurrentTree != null ? fromNode(nodeInCurrentTree).props : null; + if (currentProps == null || hasDisplayNone(nodeInCurrentTree)) { + return null; + } + + const offsetForTests: ?{ + top: number, + left: number, + } = + // $FlowExpectedError[prop-missing] + currentProps.__offsetForTests; + + if (offsetForTests == null) { + return null; + } + + let currentIndex = ancestors.length - 1; + while (currentIndex >= 0 && !hasDisplayNone(ancestors[currentIndex][0])) { + currentIndex--; + } + + if (currentIndex >= 0) { + // The node or one of its ancestors have display: none + return null; + } + + return [ + fromNode(parent).instanceHandle, + offsetForTests.top, + offsetForTests.left, + ]; + }, + ), }; global.nativeFabricUIManager = FabricUIManagerMock; diff --git a/packages/react-native/ReactCommon/react/renderer/core/LayoutMetrics.h b/packages/react-native/ReactCommon/react/renderer/core/LayoutMetrics.h index 97bc25f3319..da69829292d 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/LayoutMetrics.h +++ b/packages/react-native/ReactCommon/react/renderer/core/LayoutMetrics.h @@ -21,14 +21,21 @@ namespace react { * Describes results of layout process for particular shadow node. */ struct LayoutMetrics { + // Origin: relative to its parent content frame (unless using a method that + // computes it relative to other parent or the viewport) + // Size: includes border, padding and content. Rect frame; + // Width of the border + padding in all directions. EdgeInsets contentInsets{0}; + // Width of the border in all directions. EdgeInsets borderWidth{0}; DisplayType displayType{DisplayType::Flex}; LayoutDirection layoutDirection{LayoutDirection::Undefined}; Float pointScaleFactor{1.0}; EdgeInsets overflowInset{}; + // Origin: the outer border of the node. + // Size: includes content only. Rect getContentFrame() const { return Rect{ Point{contentInsets.left, contentInsets.top}, diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 1f261fc2148..e4398f865c1 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -923,6 +923,93 @@ jsi::Value UIManagerBinding::get( }); } + if (methodName == "getOffset") { + // This is a method to access offset information for React Native nodes, to + // implement these methods: + // * `HTMLElement.prototype.offsetParent`: see + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent. + // * `HTMLElement.prototype.offsetTop`: see + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetTop. + // * `HTMLElement.prototype.offsetLeft`: see + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetLeft. + + // It uses the version of the shadow node that is present in the current + // revision of the shadow tree. If the node is not present or is not + // displayed (because any of its ancestors or itself have 'display: none'), + // it returns undefined. Otherwise, it returns its parent (as all nodes in + // React Native are currently "positioned") and its offset relative to its + // parent. + + // getOffset(shadowNode: ShadowNode): + // ?[ + // /* parent: */ InstanceHandle, + // /* top: */ number, + // /* left: */ number, + // ] + return jsi::Function::createFromHostFunction( + runtime, + name, + 1, + [uiManager]( + jsi::Runtime &runtime, + jsi::Value const & /*thisValue*/, + jsi::Value const *arguments, + size_t /*count*/) noexcept -> jsi::Value { + auto shadowNode = shadowNodeFromValue(runtime, arguments[0]); + + auto newestCloneOfShadowNode = + uiManager->getNewestCloneOfShadowNode(*shadowNode); + auto newestParentOfShadowNode = + uiManager->getNewestParentOfShadowNode(*shadowNode); + // The node is no longer part of an active shadow tree, or it is the + // root node + if (newestCloneOfShadowNode == nullptr || + newestParentOfShadowNode == nullptr) { + return jsi::Value::undefined(); + } + + // If the node is not displayed (itself or any of its ancestors has + // "display: none", it returns an empty layout metrics object. + auto layoutMetrics = uiManager->getRelativeLayoutMetrics( + *shadowNode, nullptr, {/* .includeTransform = */ true}); + if (layoutMetrics == EmptyLayoutMetrics) { + return jsi::Value::undefined(); + } + + auto layoutableShadowNode = traitCast( + newestCloneOfShadowNode.get()); + // This should never happen + if (layoutableShadowNode == nullptr) { + return jsi::Value::undefined(); + } + + auto layoutableParentShadowNode = + traitCast( + newestParentOfShadowNode.get()); + // This should never happen + if (layoutableParentShadowNode == nullptr) { + return jsi::Value::undefined(); + } + + auto originRelativeToParentOuterBorder = + layoutableShadowNode->getLayoutMetrics().frame.origin; + + // On the Web, offsets are computed from the inner border of the + // parent. + auto offsetTop = originRelativeToParentOuterBorder.y - + layoutableParentShadowNode->getLayoutMetrics().borderWidth.top; + auto offsetLeft = originRelativeToParentOuterBorder.x - + layoutableParentShadowNode->getLayoutMetrics().borderWidth.left; + + return jsi::Array::createWithElements( + runtime, + getInstanceHandleFromShadowNode( + newestParentOfShadowNode, runtime), + jsi::Value{runtime, (double)offsetTop}, + jsi::Value{runtime, (double)offsetLeft}); + }); + } + return jsi::Value::undefined(); }