Skip to content

Instantly share code, notes, and snippets.

@lunaleaps
Created February 23, 2024 07:18
Show Gist options
  • Save lunaleaps/148756563999c83220887757f2e549a3 to your computer and use it in GitHub Desktop.
Save lunaleaps/148756563999c83220887757f2e549a3 to your computer and use it in GitHub Desktop.
Synchronous measurement and layout updates
import * as React from 'react';
import {Pressable, Text, View} from 'react-native';
function calculateX(toolTip, target, rootView) {
// Try and center the tooltip
let toolTipX = target.x + target.width / 2 - toolTip.width / 2;
// If the tooltip is outside of root, we need to move it
if (toolTipX < rootView.x) {
toolTipX = target.x;
}
if (toolTipX + toolTip.width > rootView.x + rootView.width) {
toolTipX = rootView.x + rootView.width - toolTip.width;
}
return toolTipX - rootView.x;
}
function calculateY(toolTip, target, rootView) {
// Try and place tooltip above
let toolTipY = target.y - toolTip.height;
// If the tooltip is outside of root, we need to move it below
if (toolTipY < rootView.y) {
toolTipY = target.y + target.height;
}
return toolTipY - rootView.y;
}
function wait(ms) {
const end = Date.now() + ms;
while (Date.now() < end);
}
function ToolTip({position, targetRect, rootRect, children}) {
const ref = React.useRef(null);
const [rect, setRect] = React.useState(null);
React.useLayoutEffect(() => {
// Place an artificial delay to illustrate synchronous update
wait(200);
setRect(ref.current?.getBoundingClientRect());
}, [setRect, position]);
let left = 0;
let top = 0;
if (rect != null && targetRect != null && rootRect != null) {
left = calculateX(rect, targetRect, rootRect);
top = calculateY(rect, targetRect, rootRect);
}
return (
<View
ref={ref}
style={{
position: 'absolute',
borderColor: 'green',
borderRadius: 8,
borderWidth: 2,
padding: 4,
top,
left,
}}>
{children}
</View>
);
}
function Target({toolTipText, targetText, position, rootRect}) {
const targetRef = React.useRef(null);
const [rect, setRect] = React.useState(null);
React.useLayoutEffect(() => {
// Place an artificial delay to illustrate synchronous update
wait(200);
setRect(targetRef.current?.getBoundingClientRect());
}, [setRect, position]);
return (
<>
<View
ref={targetRef}
style={{
borderColor: 'red',
borderWidth: 2,
padding: 10,
}}>
<Text>{targetText}</Text>
</View>
<ToolTip position={position} rootRect={rootRect} targetRect={rect}>
<Text>{toolTipText}</Text>
</ToolTip>
</>
);
}
const positions = [
'top-left',
'top-right',
'center-center',
'bottom-left',
'bottom-right',
];
function Example() {
const toolTipText = 'This is the tooltip';
const targetText = 'This is the target';
const ref = React.useRef(null);
const [index, setIndex] = React.useState(0);
const [rect, setRect] = React.useState(null);
React.useEffect(() => {
const setPosition = setInterval(() => {
setIndex((index + 1) % positions.length);
}, 1000);
return () => {
clearInterval(setPosition);
};
});
const position = positions[index];
const style = getStyle(position);
React.useLayoutEffect(() => {
// Place an artificial delay to illustrate synchronous update
wait(200);
setRect(ref.current?.getBoundingClientRect());
}, [setRect, position]);
return (
<>
<Text style={{margin: 20}}>Position: {position}</Text>
<View
style={{...style, flex: 1, borderWidth: 1}}
ref={ref}>
<Target
toolTipText={toolTipText}
targetText={targetText}
rootRect={rect}
position={position}
/>
</View>
</>
);
}
function getStyle(position) {
switch (position) {
case 'top-left':
return {
justifyContent: 'flex-start',
alignItems: 'flex-start',
};
case 'top-center':
return {
justifyContent: 'flex-start',
alignItems: 'center',
};
case 'top-right':
return {
justifyContent: 'flex-start',
alignItems: 'flex-end',
};
case 'center-center':
return {
justifyContent: 'center',
alignItems: 'center',
};
case 'bottom-center':
return {
justifyContent: 'flex-end',
alignItems: 'center',
};
case 'bottom-left':
return {
justifyContent: 'flex-end',
alignItems: 'flex-start',
};
case 'bottom-right':
return {
alignItems: 'flex-end',
justifyContent: 'flex-end',
};
}
}
export default Example;
@Jpoliachik
Copy link

What version of React Native is being used?

I'm seeing ref.current?.getBoundingClientRect is not a function (it is undefined) on RN 0.73.6 with New Arch enabled

@lunaleaps
Copy link
Author

This was off of main, so getBoundingClientRect may be available only in 0.74 but from a search it seems the API was landed before the 0.73 cut.

It may be behind a flag currently as @rubennorte is rolling out https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0607-dom-traversal-and-layout-apis.md

To clarify, getBoundingClientRect shouldn't be relevant from what the example is trying to illustrate, using measureInWindow should be equivalent. However, what is necessary to replicate this example is synchronous useLayoutEffect commits which will require Bridgeless mode. Beyond Bridgeless mode, it may be behind feature flags. I would follow up either on the Bridgeless announcement or generally in New Architecture WG on timelines for shipping.

@Jpoliachik
Copy link

Thanks for the help @lunaleaps - great info!
I'm excited and curious to test this out and learn about how it works but measureInWindow wasn't properly working here either because it returns measurements in an async callback, which prevented me from getting updated data as part of the synchronous useLayoutEffect commit. Am I missing something?

@rubennorte
Copy link

Thanks for the help @lunaleaps - great info! I'm excited and curious to test this out and learn about how it works but measureInWindow wasn't properly working here either because it returns measurements in an async callback, which prevented me from getting updated data as part of the synchronous useLayoutEffect commit. Am I missing something?

The callback isn't really async in this case. You can do something like this:

  React.useLayoutEffect(() => {
    let measurements;
    targetRef.current?.measureInWindow((x, y, width, height) => {
      measurements = {x, y, width, height};
    });
    // measurements is {x, y, width, height} here
  }, []);

@Jpoliachik
Copy link

You're right, thanks @rubennorte - looks like I'm getting the measurements synchronously but useLayoutEffect is not blocking like expected. I must be missing some config or flag to properly enable bridgeless or synchronous effects, I'm not sure
I moved to a discussion here: reactwg/react-native-new-architecture#164

@Dartion1
Copy link

This was off of main, so getBoundingClientRect may be available only in 0.74 but from a search it seems the API was landed before the 0.73 cut.

It may be behind a flag currently as @rubennorte is rolling out https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0607-dom-traversal-and-layout-apis.md

To clarify, getBoundingClientRect shouldn't be relevant from what the example is trying to illustrate, using measureInWindow should be equivalent. However, what is necessary to replicate this example is synchronous useLayoutEffect commits which will require Bridgeless mode. Beyond Bridgeless mode, it may be behind feature flags. I would follow up either on the Bridgeless announcement or generally in New Architecture WG on timelines for shipping.

on React Native 0.74 also getting same error "ref.current?.getBoundingClientRect is not a function (it is undefined)"

@DigitalZebra
Copy link

I can confirm this works in RN 75 (and the nightly) when replacing getBoundingClientRect with measureInWindow and the new architecture is enabled :)

@gorhom
Copy link

gorhom commented Oct 26, 2024

i think is not stable yet, since it is available under name unstable_getBoundingClientRect

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment