Skip to content

Commit

Permalink
Implement measure and scrollTo for web (#3661)
Browse files Browse the repository at this point in the history
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 and piaskowyk committed Oct 26, 2022
1 parent 20e0afc commit f655d5a
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 91 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`.
:::

### 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
152 changes: 98 additions & 54 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,70 +13,114 @@ 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 scrollTo(
export let scrollTo: (
animatedRef: RefObjectFunction<Component>,
x: number,
y: number,
animated: boolean
): void {
'worklet';
if (!_WORKLET || !isNative) {
return;
}
const viewTag = animatedRef();
_scrollTo(viewTag, x, y, animated);
) => void;

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) {
scrollTo = (
animatedRef: RefObjectFunction<Component>,
x: number,
y: number,
animated: boolean
) => {
'worklet';
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 f655d5a

Please sign in to comment.