Last active
December 14, 2023 12:10
-
-
Save cheadrian/cb15850fc31e8e235c948c8ba4442124 to your computer and use it in GitHub Desktop.
Custom scroll view indicator with Reanimated and Gesture
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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