Search code examples
javascriptreact-nativescrollcustom-scrolling

How can I get my ScrollView to move synchronously with my Scrollbar Custom Indicator in React Native?


enter image description here

So I am trying to get this to work like a scrollbar browser, and have the ScrollView move in sync with the Custom Indicator. Right now I have the scrollTo() being called from within the onPanResponderRelease like so...

onPanResponderRelease: () => {
        pan.flattenOffset();
        scrollRef.current.scrollTo({x: 0, y:animatedScrollViewInterpolateY.__getValue(), animated: true,});

However to get the effect I want I believe scrollTo() needs to be called from within onPanResponderMove, which is currently set to this...

onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {useNativeDriver: false})

The Animated.event that you see here is what is driving the indicator to move when touched. But how can I call the scrollTo() from within the onPanResponderMove also? Or is that even a good idea? If I am able to do this, the ScrollView will move synchronously with the indicator. At least I think so...

At the end of the day all I want is a ScrollView that has a scrollbar that works like a browser's scrollbar. Meaning the scrollbar indicator is touchable and draggable. Ideally I want this on a FlatList however I figured doing it on the ScrollView first would be simpler, and then I could just apply the same principles to a FlatList component afterwards.

This is all the code...

import React, { useRef, useState } from "react";
import {
  Animated,
  View,
  StyleSheet,
  PanResponder,
  Dimensions,
  Text,
  ScrollView,
  TouchableWithoutFeedback,
} from "react-native";

const boxSize = { width: 50, height: 50 };

const App = () => {
  const AnimatedTouchable = Animated.createAnimatedComponent(
    TouchableWithoutFeedback
  );
  const { width, height } = Dimensions.get("window");
  const pan = useRef(new Animated.ValueXY({ x: 0, y: 50 })).current;
  const scrollRef = useRef();
  const [scrollHeight, setScrollHeight] = useState(0);

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: () => true,
      onPanResponderGrant: () => {
        pan.setOffset({
          x: pan.x._value,
          y: pan.y._value,
        });
      },
      onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
        useNativeDriver: false,
      }),
      onPanResponderRelease: () => {
        pan.flattenOffset();
        scrollRef.current.scrollTo({
          x: 0,
          y: animatedScrollViewInterpolateY.__getValue(),
          animated: true,
        });
      },
    })
  ).current;

  function scrollLayout(width, height) {
    setScrollHeight(height);
  }

  const animatedInterpolateX = pan.x.interpolate({
    inputRange: [0, width - boxSize.width],
    outputRange: [0, width - boxSize.width],
    extrapolate: "clamp",
  });

  const animatedInterpolateY = pan.y.interpolate({
    inputRange: [0, height - boxSize.height],
    outputRange: [height * 0.2, height * 0.8],
    extrapolate: "clamp",
  });
  const animatedScrollViewInterpolateY = animatedInterpolateY.interpolate({
    inputRange: [height * 0.2, height * 0.8],
    outputRange: [0, 411],
    // extrapolate: "clamp",
  });

  return (
    <View style={styles.container}>
      <Animated.View style={{ backgroundColor: "red" }}>
        <ScrollView
          ref={scrollRef}
          onContentSizeChange={scrollLayout}
          scrollEnabled={false}
        >
          <Text style={styles.titleText}>
            <Text>Twenty to Last!</Text>
            <Text>Ninete to Last!</Text>
            <Text>Eight to Last!</Text>
            <Text>Sevent to Last!</Text>
            <Text>Sixtee to Last!</Text>
            <Text>Fiftee to Last!</Text>
            <Text>Fourte to Last!</Text>
            <Text>Thirte to Last!</Text>
            <Text>Twelet to Last!</Text>
            <Text>Eleveh to Last!</Text>
            <Text>Tenth to Last!</Text>
            <Text>Nineth to Last!</Text>
            <Text>Eighth to Last!</Text>
            <Text>Seventh to Last!</Text>
            <Text>Sixth to Last!</Text>
            <Text>Fifth to Last!</Text>
            <Text>Fourth to Last!</Text>
            <Text>Third to Last!</Text>
            <Text>Second to Last!</Text>
            <Text>The End!</Text>
          </Text>
        </ScrollView>
      </Animated.View>
      <Animated.View
        style={{position: "absolute",
     transform: [
            { translateX: width - boxSize.width },
            { translateY: animatedInterpolateY },
          ],}}
        {...panResponder.panHandlers}>
        <View style={styles.box} />
      </Animated.View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  titleText: {
    fontSize: 54,
    fontWeight: "bold",
  },
  box: {
    height: boxSize.height,
    width: boxSize.width,
    backgroundColor: "blue",
    borderRadius: 5,
  },
});

export default App;

Solution

  • I'll start by saying that regardless of your use case, it might be a bad user experience to bring web-style scrolling to mobile. The whole view is usually scrollable so that you can scroll from anywhere without holding a scrollbar (also think of left-handed people).

    With that said, you can change onPanResponderMove to a regular callback so that you get more control over what you need:

    // 1) Transform Animated.event(...) to a simple callback
    onPanResponderMove: (event, gestureState) => {
      pan.x.setValue(gestureState.dx);
      pan.y.setValue(gestureState.dy);
    
      // 2) Scroll synchronously, without animation (..because it's synchronous!)
      scrollRef.current.scrollTo({
        x: 0,
        y: animatedScrollViewInterpolateY.__getValue(),
        animated: false,
      });
    },
    onPanResponderRelease: () => {
      pan.flattenOffset();
      // 3) Remove scrollTo from the release event
    },