Skip to content

Instantly share code, notes, and snippets.

@cheadrian
Last active December 14, 2023 12:10
Show Gist options
  • Save cheadrian/cb15850fc31e8e235c948c8ba4442124 to your computer and use it in GitHub Desktop.
Save cheadrian/cb15850fc31e8e235c948c8ba4442124 to your computer and use it in GitHub Desktop.
Custom scroll view indicator with Reanimated and Gesture
/*
* Check the Snack demo here:
* https://snack.expo.dev/@cheadrian/custom-scroll-view-indicator-with-reanimated-and-gesture
* Article with gif and features:
* https://che-adrian.medium.com/b78b238067b3?source=friends_link&sk=66df3163984fbcacbf9f7f4193b92e25
*/
import { useEffect, useRef, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
withTiming,
useAnimatedStyle,
Easing,
runOnJS,
} from 'react-native-reanimated';
const SCROLLBAR_HIDE_TIMEOUT = 5000;
const SCROLLBAR_ANIM_TIME = 300;
export default function CustomScrollView({ children, height, style }) {
const scrollViewReference = useRef();
const scrollBarStartPosY = useRef(0);
const scrollTimeoutId = useRef();
const scrollIndicatorFromTopPos = useSharedValue(0);
const scrollIndicatorHeight = useSharedValue(0);
const scrollIndicatorOpacity = useSharedValue(1);
const [scrollViewHeight, setScrollViewHeight] = useState(0);
const [contentViewHeight, setContentViewHeight] = useState(0);
const [scrollPositionY, setScrollPositionY] = useState(0);
const scrollTimingAnimConfig = {
duration: SCROLLBAR_ANIM_TIME,
easing: Easing.linear,
};
const calculateScrollBarIndicatorHeight = () => {
if (
scrollViewHeight > 0 &&
contentViewHeight > 0 &&
contentViewHeight > scrollViewHeight
) {
const calculateScrollIndicatorHeight =
(scrollViewHeight / contentViewHeight) * scrollViewHeight;
return calculateScrollIndicatorHeight;
} else {
return 0;
}
};
const calculateScrollBarIndicatorPosition = (maxScrollFromTop) => {
const scrollPercentage = Math.min(
Math.max(scrollPositionY / (contentViewHeight - scrollViewHeight), 0),
1
);
return Math.ceil(maxScrollFromTop * scrollPercentage);
};
const createScrollHideTimeout = (timeout) => {
return setTimeout(() => {
scrollIndicatorOpacity.value = 0;
}, timeout);
};
const resetHideTimeout = (timeout) => {
scrollIndicatorOpacity.value = 1;
clearTimeout(scrollTimeoutId.current);
scrollTimeoutId.current = createScrollHideTimeout(timeout);
};
useEffect(() => {
const scrollBarIndicatorHeight = calculateScrollBarIndicatorHeight();
const maxScrollFromTop = scrollViewHeight - scrollBarIndicatorHeight;
const scrollBarIndicatorPosition =
calculateScrollBarIndicatorPosition(maxScrollFromTop);
scrollIndicatorHeight.value = scrollBarIndicatorHeight;
scrollIndicatorFromTopPos.value = scrollBarIndicatorPosition;
resetHideTimeout(SCROLLBAR_HIDE_TIMEOUT);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollViewHeight, contentViewHeight, scrollPositionY]);
useEffect(() => {
scrollTimeoutId.current = createScrollHideTimeout(SCROLLBAR_HIDE_TIMEOUT);
return () => {
clearTimeout(scrollTimeoutId.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateScrollPositionY = (scrollPositionY) => {
scrollViewReference.current?.scrollTo({
y: scrollPositionY,
animated: false,
});
};
const scrollByTranslationY = (translationY) => {
const scrollBarIndicatorHeight = calculateScrollBarIndicatorHeight();
const maxScrollFromTop = scrollViewHeight - scrollBarIndicatorHeight;
const scrollPosition =
(contentViewHeight - scrollViewHeight) *
(translationY / maxScrollFromTop) +
scrollBarStartPosY.current;
// This fix the scrolling hang on Android
setTimeout(() => {
updateScrollPositionY(scrollPosition);
}, 10);
};
const setScrollPositionStart = () => {
scrollBarStartPosY.current = scrollPositionY;
};
const scrollDragGesture = Gesture.Pan()
.onBegin(() => {
runOnJS(setScrollPositionStart)();
})
.onChange((e) => {
runOnJS(scrollByTranslationY)(e.translationY);
});
const scrollBarDynamicStyle = useAnimatedStyle(() => {
return {
opacity: withTiming(scrollIndicatorOpacity.value, scrollTimingAnimConfig),
height: scrollIndicatorHeight.value,
top: scrollIndicatorFromTopPos.value,
};
});
return (
<View style={[style, {height: height}]}>
<GestureDetector gesture={scrollDragGesture}>
<Animated.View
style={[scrollBarDynamicStyle, styles.indicatorStaticStyle]}
/>
</GestureDetector>
<ScrollView
ref={scrollViewReference}
showsVerticalScrollIndicator={false}
onLayout={({
nativeEvent: {
layout: { height },
},
}) => {
setScrollViewHeight(height);
}}
onContentSizeChange={(_, contentHeight) => {
setContentViewHeight(contentHeight);
}}
onScroll={({ nativeEvent: { contentOffset, contentSize } }) => {
setScrollPositionY(contentOffset.y);
setContentViewHeight(contentSize.height);
}}
scrollEventThrottle={10}
alwaysBounceHorizontal={false}
alwaysBounceVertical={false}
bounces={false}
overScrollMode="never">
{children}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
indicatorStaticStyle: {
position: 'absolute',
right: -19,
backgroundColor: '#ABABAB',
width: 15,
borderRadius: 8,
zIndex: 10,
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment