Skip to content

Commit

Permalink
Implement measure and scrollTo for web (#3661)
Browse files Browse the repository at this point in the history
## Description

This PR adds implementation of `measure` and `scrollTo` functions on
web.

The following example verifies the implementation using a component with
non-zero `margin`, `padding`, and `borderWidth`. The results are
identical to those from `measure` from React Native.

`measure` doesn't work when Chrome Debugger is attached because in such
case worklets are executed on the main JS context (like on web, but it's
still React Native, so we can't just check HTMLElement size).

<details>
<summary>MeasureExample.tsx</summary>

```tsx
import Animated, {
  measure,
  runOnUI,
  useAnimatedRef,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';
import { Button, ScrollView, View } from 'react-native';

import React from 'react';

export default function MeasureExample() {
  const aref = useAnimatedRef<Animated.View>();

  const ref = React.useRef(0);

  const sv = useSharedValue(0);

  const handleAnimateWidth = () => {
    ref.current = 1 - ref.current;
    sv.value = withTiming(ref.current, { duration: 1500 });
  };

  const handleMeasureFromJS = () => {
    aref.current?.measure((x, y, width, height, pageX, pageY) =>
      console.log({ width, height, x, y, pageX, pageY })
    );
  };

  const handleMeasureFromUI = () => {
    runOnUI(() => {
      'worklet';
      console.log(measure(aref));
    })();
  };

  const animatedStyle = useAnimatedStyle(() => {
    return {
      width: 80 + 100 * sv.value,
      height: 80,
      margin: 10,
      padding: 10,
      borderWidth: 10,
      borderColor: 'blue',
      backgroundColor: 'navy',
    };
  }, []);

  return (
    <View style={{ flexDirection: 'column', marginHorizontal: 'auto' }}>
      <View>
        <Button onPress={handleAnimateWidth} title="Animate width" />
        <Button onPress={handleMeasureFromJS} title="Measure from JS" />
        <Button onPress={handleMeasureFromUI} title="Measure from UI" />
      </View>
      <View>
        <ScrollView style={{ height: 400 }}>
          <View style={{ paddingVertical: 350, backgroundColor: 'lightgray' }}>
            <ScrollView style={{ width: 400 }} horizontal>
              <View
                style={{
                  paddingHorizontal: 350,
                  backgroundColor: 'lightgray',
                }}>
                <Animated.View ref={aref} style={animatedStyle} />
              </View>
            </ScrollView>
          </View>
        </ScrollView>
      </View>
    </View>
  );
}
```

</details>

<details>
<summary>ScrollToExample.tsx</summary>

```tsx
import Animated, {
  runOnUI,
  scrollTo,
  useAnimatedRef,
} from 'react-native-reanimated';
import { Button, Switch, Text, View } from 'react-native';

import React from 'react';

export default function ScrollToExample() {
  const [animated, setAnimated] = React.useState(true);

  const aref = useAnimatedRef<Animated.ScrollView>();

  const scrollFromJS = () => {
    aref.current?.scrollTo({ y: Math.random() * 2000, animated });
  };

  const scrollFromUI = () => {
    runOnUI(() => {
      'worklet';
      scrollTo(aref, 0, Math.random() * 2000, animated);
    })();
  };

  return (
    <View>
      <Switch value={animated} onValueChange={setAnimated} />
      <Button onPress={scrollFromJS} title="Scroll from JS" />
      <Button onPress={scrollFromUI} title="Scroll from UI" />
      <Animated.ScrollView ref={aref} style={{ width: 200, height: 400 }}>
        {[...Array(100)].map((_, i) => (
          <Text key={i} style={{ fontSize: 50, textAlign: 'center' }}>
            {i}
          </Text>
        ))}
      </Animated.ScrollView>
    </View>
  );
}
```

</details>


https://user-images.githubusercontent.com/20516055/194900280-45ad84bc-77ac-4ee3-af15-931d56d1516b.mov


https://user-images.githubusercontent.com/20516055/194924529-96644d3c-0388-489d-bb65-3e80564acd41.mov

Tested with:
- [x] Example app:
- [x] Example app with Chrome Debugger (I had to disable Hermes in
Podfile and use JSC instead because otherwise React DevTools didn't
work)
- [x] WebExample (Firefox, Chrome, Safari)
  • Loading branch information
tomekzaw authored Oct 26, 2022
1 parent 1ebf9e4 commit 77d02b3
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 87 deletions.
49 changes: 24 additions & 25 deletions docs/docs/api/nativeMethods/measure.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,35 @@ title: measure
sidebar_label: measure
---

Determines the location on screen, width, and height of the given view. Note that these measurements are not available until after the rendering has been completed in native. If you need the measurements as soon as possible, consider using [`onLayout`](https://reactnative.dev/docs/view#onlayout) instead.

This function is implemented on native platforms only. On web, it's sufficient to use a standard version of the `measure` which is available on most of the default components provided by React Native (it's [here](https://github.com/facebook/react-native/blob/65975dd28de0a7b8b8c4eef6479bf7eee5fcfb93/Libraries/Renderer/shims/ReactNativeTypes.js#L105)). In such a case it should be invoked in the following way (note it's asynchronous so if you want to make it synchronous you should use `Promise`):

```javascript
const aref = useAnimatedRef();
new Promise((resolve, reject) => {
if (aref && aref.current) {
aref.current.measure((x, y, width, height, pageX, pageY) => {
resolve({ x, y, width, height, pageX, pageY });
});
} else {
reject(new Error('measure: animated ref not ready'));
}
});
```
Determines the location on screen, width, and height in the viewport of the given view synchronously and returns an object with measured dimensions or `null` if the view cannot be measured.

If you need the measurements as soon as possible and you don't need `pageX` and `pageY`, consider using the [`onLayout`](https://reactnative.dev/docs/view#onlayout) property instead.

:::info
You can use `measure()` only on rendered components. For example, calling `measure()` on an offscreen `FlatList` item will return `null`. It is therefore a good practice to perform a `null`-check before using the response.
:::

:::tip
If you call `measure` inside [`useAnimatedStyle`](../hooks/useAnimatedStyle), you may get the following warning:

If you call `measure()` inside [`useAnimatedStyle()`](../hooks/useAnimatedStyle)
you may get a warning that `measure()` was called from the wrong thread. This
is safe to ignore, but if you don't want this error to appear then wrap the call
like this:
> [Reanimated] measure() was called from the main JS context. Measure is only available
in the UI runtime. (...)

That's because in React Native apps, `useAnimatedStyle` worklet is first evaluated on the JS context during the first render, thus before rendering has been completed in native. This is safe to ignore, but if you don't want this warning to appear then wrap the call like this:

```js
if (_WORKLET) {
const measure = measure(animatedRef);
// ...
if (_WORKLET || isWeb) {
const measured = measure(animatedRef);
if (measured !== null) {
// ...
}
}
```
:::

:::info
`measure` is not available when Chrome Developer Tools (remote JS debugger) is attached. However, the recommended tool for debugging React Native apps is Flipper (Chrome DevTools) which supports `measure`. Check out more details [here](../../guide/debugging).
:::

### Arguments

Expand All @@ -52,8 +53,6 @@ An object of type `MeasuredDimensions`, which contains these fields:

If the measurement could not be performed, returns `null`.

You can use `measure()` only on rendered components. For example, calling `measure()` on an offscreen `FlatList` item will return `null`. It is therefore a good practice to perform a `null`-check before using the response.

### Example

```js
Expand Down
16 changes: 4 additions & 12 deletions docs/docs/api/nativeMethods/scrollTo.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,27 @@ sidebar_label: scrollTo
---

Provides synchronous scroll on the UI thread to a given offset using an animated ref to a scroll view. This allows performing smooth scrolling without lags (which might have otherwise occured when it was asynchronous and based on lots of events).

This function is implemented on native platforms only. On web it's sufficient to use a standard version of `scrollTo` which comes with a `ScrollView` component (it's [here](https://github.com/facebook/react-native/blob/aebccd3f923c920bd85fb9e5fbdd2a8a75d3ad3d/Libraries/Components/ScrollView/ScrollView.js#L834)). In such a case it should be invoked in the following way:

```javascript
const aref = useAnimatedRef();
aref.current.scrollTo({ x, y });
```

### Arguments

#### `animatedRef`

The product of [`useAnimatedRef`](../hooks/useAnimatedRef) which is Reanimated's extension of a standard React ref (delivers the view tag on the UI thread).

#### `x-cord` [Float]
#### `x` [Float]

Corresponds to the pixel along the horizontal axis of the element that you want displayed in the upper left.

#### `y-cord` [Float]
#### `y` [Float]

Corresponds to the pixel along the vertical axis of the element that you want displayed in the upper left.

#### `animated` [Boolean]

Indicates whether the scroll should be smooth.
Indicates whether the scroll should be smooth (`true`) or instant (`false`).

### Returns

void
`void`

### Example

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/guide/debugging.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ functions. This means that the `scrollTo` function will work
function will not be available, and its usage will trigger this error:

```
[Reanimated] measure() cannot be used for web or Chrome Debugger
[Reanimated] measure() cannot be used with Chrome Debugger.
```

You may still use the standard web version of `measure` as described
Expand Down
133 changes: 84 additions & 49 deletions src/reanimated2/NativeMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component } from 'react';
import { findNodeHandle } from 'react-native';
import { MeasuredDimensions } from './commonTypes';
import { RefObjectFunction } from './hook/commonTypes';
import { shouldBeUseWeb } from './PlatformChecker';
import { isChromeDebugger, isWeb, shouldBeUseWeb } from './PlatformChecker';

export function getTag(
view: null | number | React.Component<any, any> | React.ComponentClass<any>
Expand All @@ -13,56 +13,71 @@ export function getTag(

const isNative = !shouldBeUseWeb();

export function measure(
export let measure: (
animatedRef: RefObjectFunction<Component>
): MeasuredDimensions | null {
'worklet';
if (!isNative) {
console.warn(
'[Reanimated] measure() cannot be used on web or Chrome Debugger'
);
return null;
}
) => MeasuredDimensions | null;

if (!_WORKLET) {
console.warn(
'[Reanimated] measure() was called from the main JS context. Measure is ' +
'only available in the UI runtime. This may also happen if measure() ' +
'was called by a worklet in the useAnimatedStyle hook, because useAnimatedStyle ' +
'calls the given worklet on the JS runtime during render. If you want to ' +
'prevent this warning then wrap the call with `if (_WORKLET)`. Then it will ' +
'only be called on the UI runtime after the render has been completed.'
);
if (isWeb()) {
measure = (animatedRef: RefObjectFunction<Component>) => {
const element = animatedRef() as unknown as HTMLElement; // TODO: fix typing of animated refs on web
const viewportOffset = element.getBoundingClientRect();
return {
width: element.offsetWidth,
height: element.offsetHeight,
x: element.offsetLeft,
y: element.offsetTop,
pageX: viewportOffset.left,
pageY: viewportOffset.top,
};
};
} else if (isChromeDebugger()) {
measure = (_animatedRef: RefObjectFunction<Component>) => {
console.warn('[Reanimated] measure() cannot be used with Chrome Debugger.');
return null;
}
};
} else {
measure = (animatedRef: RefObjectFunction<Component>) => {
'worklet';
if (!_WORKLET) {
console.warn(
'[Reanimated] measure() was called from the main JS context. Measure is ' +
'only available in the UI runtime. This may also happen if measure() ' +
'was called by a worklet in the useAnimatedStyle hook, because useAnimatedStyle ' +
'calls the given worklet on the JS runtime during render. If you want to ' +
'prevent this warning then wrap the call with `if (_WORKLET)`. Then it will ' +
'only be called on the UI runtime after the render has been completed.'
);
return null;
}

const viewTag = animatedRef();
if (viewTag === -1) {
console.warn(
`[Reanimated] The view with tag ${viewTag} is not a valid argument for measure(). This may be because the view is not currently rendered, which may not be a bug (e.g. an off-screen FlatList item).`
);
return null;
}
const viewTag = animatedRef();
if (viewTag === -1) {
console.warn(
`[Reanimated] The view with tag ${viewTag} is not a valid argument for measure(). This may be because the view is not currently rendered, which may not be a bug (e.g. an off-screen FlatList item).`
);
return null;
}

const measured = _measure(viewTag);
if (measured === null) {
console.warn(
`[Reanimated] The view with tag ${viewTag} has some undefined, not-yet-computed or meaningless value of \`LayoutMetrics\` type. This may be because the view is not currently rendered, which may not be a bug (e.g. an off-screen FlatList item).`
);
return null;
} else if (measured.x === -1234567) {
console.warn(
`[Reanimated] The view with tag ${viewTag} returned an invalid measurement response`
);
return null;
} else if (isNaN(measured.x)) {
console.warn(
`[Reanimated] The view with tag ${viewTag} gets view-flattened on Android. To disable view-flattening, set \`collapsable={false}\` on this component.`
);
return null;
} else {
return measured;
}
const measured = _measure(viewTag);
if (measured === null) {
console.warn(
`[Reanimated] The view with tag ${viewTag} has some undefined, not-yet-computed or meaningless value of \`LayoutMetrics\` type. This may be because the view is not currently rendered, which may not be a bug (e.g. an off-screen FlatList item).`
);
return null;
} else if (measured.x === -1234567) {
console.warn(
`[Reanimated] The view with tag ${viewTag} returned an invalid measurement response.`
);
return null;
} else if (isNaN(measured.x)) {
console.warn(
`[Reanimated] The view with tag ${viewTag} gets view-flattened on Android. To disable view-flattening, set \`collapsable={false}\` on this component.`
);
return null;
} else {
return measured;
}
};
}

export function dispatchCommand(
Expand All @@ -85,7 +100,19 @@ export let scrollTo: (
animated: boolean
) => void;

if (global._IS_FABRIC) {
if (isWeb()) {
scrollTo = (
animatedRef: RefObjectFunction<Component>,
x: number,
y: number,
animated: boolean
) => {
'worklet';
const element = animatedRef() as unknown as HTMLElement;
// @ts-ignore same call as in react-native-web
element.scrollTo({ x, y, animated });
};
} else if (isNative && global._IS_FABRIC) {
scrollTo = (
animatedRef: RefObjectFunction<Component>,
x: number,
Expand All @@ -95,20 +122,28 @@ if (global._IS_FABRIC) {
'worklet';
dispatchCommand(animatedRef, 'scrollTo', [x, y, animated]);
};
} else {
} else if (isNative) {
scrollTo = (
animatedRef: RefObjectFunction<Component>,
x: number,
y: number,
animated: boolean
) => {
'worklet';
if (!_WORKLET || !isNative) {
if (!_WORKLET) {
return;
}
const viewTag = animatedRef();
_scrollTo(viewTag, x, y, animated);
};
} else {
scrollTo = (
_animatedRef: RefObjectFunction<Component>,
_x: number,
_y: number
) => {
// no-op
};
}

export function setGestureState(handlerTag: number, newState: number): void {
Expand Down

0 comments on commit 77d02b3

Please sign in to comment.