Skip to content

Commit

Permalink
Add web implementation for useScrollViewOffset (#5805)
Browse files Browse the repository at this point in the history
## Summary

Our `useScrollViewOffset` had no support for web, so I added it using
some basic web scroll listeners.
There are some funny typing shenanigans, if anyone comes up with better
solutions I will happily use it.

| before | after |
| --- | --- |
| <video
src="https://github.com/software-mansion/react-native-reanimated/assets/77503811/fd61dd7d-3070-4d6e-a8e5-d00b25fe32db"/>
| <video
src="https://github.com/software-mansion/react-native-reanimated/assets/77503811/5b49a3dd-1a34-4d01-8c09-ae4bd723c9c3"
/> |

## Test plan

Check `useScrollViewOffset` example from Reanimated WebExample and watch
console logs or copy [example
code](https://docs.swmansion.com/react-native-reanimated/docs/scroll/useScrollViewOffset/#example)
from our docs.

More in-depth testing can be done using the following code - it tests
switching and mouting/dismounting components that the hook refers to:
<details><summary>Code</summary>

``` TYPESCRIPT
import React, { useState } from 'react';
import Animated, {
  useAnimatedRef,
  useDerivedValue,
  useSharedValue,
  useScrollViewOffset,
} from 'react-native-reanimated';
import { Button, StyleSheet, Text, View } from 'react-native';

export default function ScrollViewOffsetExample() {
  const aref = useAnimatedRef<Animated.ScrollView>();
  const bref = useAnimatedRef<Animated.ScrollView>();
  const scrollHandler = useSharedValue(0);
  const [scrollAMounted, setScrollAMounted] = useState(true);
  const [scrollBMounted, setScrollBMounted] = useState(true);
  const [scrollAPassed, setScrollAPassed] = useState(true);

  useDerivedValue(() => {
    console.log(scrollHandler.value);
  });

  const onAMountPress = () => {
    setScrollAMounted(!scrollAMounted);
  };
  const onBMountPress = () => {
    setScrollBMounted(!scrollBMounted);
  };
  const onPassTogglePress = () => {
    setScrollAPassed(!scrollAPassed);
  };

  useScrollViewOffset(scrollAPassed ? aref : bref, scrollHandler);

  return (
    <>
      <View style={styles.positionView}>
        <Text>Test</Text>
      </View>
      <View style={styles.divider} />
      <Button
        title={`${scrollAMounted ? 'Dismount' : 'Mount'} scroll A`}
        onPress={onAMountPress}
      />
      <Button
        title={`${scrollBMounted ? 'Dismount' : 'Mount'} scroll B`}
        onPress={onBMountPress}
      />
      <Button
        title={`Toggle the ref, currently passed to ${
          scrollAPassed ? 'scroll A' : 'scroll B'
        }`}
        onPress={onPassTogglePress}
      />
      {scrollAMounted ? (
        <Animated.ScrollView
          ref={aref}
          style={[styles.scrollView, { backgroundColor: 'purple' }]}>
          {[...Array(100)].map((_, i) => (
            <Text key={i} style={styles.text}>
              A: {i}
            </Text>
          ))}
        </Animated.ScrollView>
      ) : null}
      {scrollBMounted ? (
        <Animated.ScrollView
          ref={bref}
          style={[styles.scrollView, { backgroundColor: 'lime' }]}>
          {[...Array(100)].map((_, i) => (
            <Text key={i} style={styles.text}>
              B: {i}
            </Text>
          ))}
        </Animated.ScrollView>
      ) : null}
    </>
  );
}

const styles = StyleSheet.create({
  positionView: {
    margin: 20,
    alignItems: 'center',
  },
  scrollView: {
    flex: 1,
    width: '100%',
  },
  text: {
    fontSize: 50,
    textAlign: 'center',
  },
  divider: {
    backgroundColor: 'black',
    height: 1,
  },
});

```
  • Loading branch information
szydlovsky authored Mar 20, 2024
1 parent 167241b commit d6dab8f
Showing 1 changed file with 58 additions and 11 deletions.
69 changes: 58 additions & 11 deletions src/reanimated2/hook/useScrollViewOffset.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use strict';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import type { SharedValue } from '../commonTypes';
import { findNodeHandle } from 'react-native';
import type { EventHandlerInternal } from './useEvent';
Expand All @@ -15,22 +15,69 @@ import { isWeb } from '../PlatformChecker';

const IS_WEB = isWeb();

const scrollEventNames = [
'onScroll',
'onScrollBeginDrag',
'onScrollEndDrag',
'onMomentumScrollBegin',
'onMomentumScrollEnd',
];

/**
* Lets you synchronously get the current offset of a `ScrollView`.
*
* @param animatedRef - An [animated ref](https://docs.swmansion.com/react-native-reanimated/docs/core/useAnimatedRef) attached to an Animated.ScrollView component.
* @returns A shared value which holds the current offset of the `ScrollView`.
* @see https://docs.swmansion.com/react-native-reanimated/docs/scroll/useScrollViewOffset
*/
export function useScrollViewOffset(
export const useScrollViewOffset = IS_WEB
? useScrollViewOffsetJS
: useScrollViewOffsetNative;

function useScrollViewOffsetJS(
animatedRef: AnimatedRef<AnimatedScrollView>,
initialRef?: SharedValue<number>
): SharedValue<number> {
const offsetRef = useRef(
// eslint-disable-next-line react-hooks/rules-of-hooks
initialRef !== undefined ? initialRef : useSharedValue(0)
);
const scrollRef = useRef<AnimatedScrollView | null>(null);

const eventHandler = useCallback(() => {
'worklet';
const element = animatedRef.current as unknown as HTMLElement;
// scrollLeft is the X axis scrolled offset, works properly also with RTL layout
offsetRef.current.value =
element.scrollLeft === 0 ? element.scrollTop : element.scrollLeft;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animatedRef, animatedRef.current]);

useEffect(() => {
// We need to make sure that listener for old animatedRef value is removed
if (scrollRef.current !== null) {
(scrollRef.current as unknown as HTMLElement).removeEventListener(
'scroll',
eventHandler
);
}
scrollRef.current = animatedRef.current;

const element = animatedRef.current as unknown as HTMLElement;
element.addEventListener('scroll', eventHandler);
return () => {
element.removeEventListener('scroll', eventHandler);
};
// React here has a problem with `animatedRef.current` since a Ref .current
// field shouldn't be used as a dependency. However, in this case we have
// to do it this way.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animatedRef, animatedRef.current, eventHandler]);

return offsetRef.current;
}

const scrollNativeEventNames = [
'onScroll',
'onScrollBeginDrag',
'onScrollEndDrag',
'onMomentumScrollBegin',
'onMomentumScrollEnd',
];

function useScrollViewOffsetNative(
animatedRef: AnimatedRef<AnimatedScrollView>,
initialRef?: SharedValue<number>
): SharedValue<number> {
Expand All @@ -47,7 +94,7 @@ export function useScrollViewOffset(
? event.contentOffset.y
: event.contentOffset.x;
},
scrollEventNames
scrollNativeEventNames
// Read https://github.com/software-mansion/react-native-reanimated/pull/5056
// for more information about this cast.
) as unknown as EventHandlerInternal<ReanimatedScrollEvent>;
Expand Down

0 comments on commit d6dab8f

Please sign in to comment.