import React, { useRef } from 'react'; import { StyleSheet, View, ScrollView as RNScrollView, ViewStyle, StyleProp, ImageBackground, PanResponder, TouchableWithoutFeedback, } from 'react-native'; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, } from 'react-native'; import useAsyncReference from '../../hooks/useAsyncReference'; import type { Theme } from '../../types'; import { withTheme } from '../../core/theming'; import { Panel, Button, ArrowIcon } from '../..'; type Direction = -1 | 1; type ScrollViewProps = React.ComponentProps & { alwaysShowScrollbars?: boolean; children: React.ReactNode; horizontal?: boolean; small?: boolean; scrollViewProps?: React.ComponentProps; style?: StyleProp; theme: Theme; }; // TODO: performance improvements (callbacks, refs ...etc) // TODO: disable scroll buttons when scroll position reached min or max // TODO: add 'scrollIncrement' prop (granularity of scroll buttons) const ScrollView = ({ alwaysShowScrollbars = false, children, horizontal = false, small = false, scrollViewProps = {}, style, theme, ...rest }: ScrollViewProps) => { const scrollViewRef = useRef(null); const [contentOffset, setContentOffset] = useAsyncReference(0); const [contentSize, setContentSize] = useAsyncReference(0); const [scrollViewSize, setScrollViewSize] = useAsyncReference(0); const scrollbarThickness = small ? 26 : 30; const scrollbarButtonSize = scrollbarThickness; const scrollbarAxis = horizontal ? 'x' : 'y'; const scrollbarLengthDimension = horizontal ? 'width' : 'height'; const scrollbarThicknessDimension = horizontal ? 'height' : 'width'; const contentFullyVisible = contentSize.current <= scrollViewSize.current; const visibleContentRatio = scrollViewSize.current / contentSize.current; const scrolledContentRatio = contentOffset.current / contentSize.current; const thumbPosition = Math.max( 0, Math.min( 1 - visibleContentRatio, parseFloat(scrolledContentRatio.toFixed(3)), ), ) * 100; // CALLBACKS const scrollTo = (distance: number, animated = false) => { if (scrollViewRef.current) { scrollViewRef.current.scrollTo({ [scrollbarAxis]: distance, animated, }); } }; const handleScrollButtonPress = (direction: Direction) => { scrollTo(contentOffset.current + 24 * direction, true); }; const handleTrackPress = (direction: Direction) => { scrollTo(contentOffset.current + scrollViewSize.current * direction); }; const handleScroll = (e: NativeSyntheticEvent) => { scrollViewProps.onScroll?.(e); setContentOffset(e.nativeEvent.contentOffset[scrollbarAxis]); }; const handleContentSizeChange = (width: number, height: number) => { scrollViewProps.onContentSizeChange?.(width, height); setContentSize(horizontal ? width : height); }; const handleLayout = (e: LayoutChangeEvent) => { scrollViewProps.onLayout?.(e); setScrollViewSize(e.nativeEvent.layout[scrollbarLengthDimension]); }; const dragStartScrollPositionRef = useRef(0); const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderGrant: () => { dragStartScrollPositionRef.current = contentOffset.current; }, onPanResponderMove: (_evt, gestureState) => { const scrollTrackLength = scrollViewSize.current - 2 * scrollbarButtonSize; const scrollDistanceChange = gestureState[horizontal ? 'dx' : 'dy'] / scrollTrackLength; const translatedDistance = scrollDistanceChange * contentSize.current; scrollTo(dragStartScrollPositionRef.current + translatedDistance); }, onPanResponderTerminationRequest: () => true, }), ).current; return ( {children} {(!contentFullyVisible || alwaysShowScrollbars) && ( {!contentFullyVisible && ( handleTrackPress(-1)} > {/* SCROLLBAR THUMB */} handleTrackPress(1)}> )} )} ); }; const styles = StyleSheet.create({ wrapper: { display: 'flex', position: 'relative', }, content: { flexGrow: 1, flexShrink: 1, }, scrollbarTrack: { overflow: 'hidden', flex: 1, }, background: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, }, }); export default withTheme(ScrollView);