import React, { memo, useCallback, useEffect, useRef, useMemo, useState, } from 'react'; import {ScrollableView} from 'react-native-head-tab-view-flashlist-support/types'; import { Platform, StyleSheet, ScrollViewProps, View, DeviceEventEmitter, } from 'react-native'; import RefreshControlContainer from 'react-native-head-tab-view-flashlist-support/RefreshControlContainer'; import { NativeViewGestureHandler, ScrollView, TapGestureHandler, } from 'react-native-gesture-handler'; import { NormalSceneProps, HPageViewProps, } from 'react-native-head-tab-view-flashlist-support/types'; import { useSceneContext, useSharedScrollableRef, useSyncInitialPosition, useRefreshDerivedValue, useVerifyProps, } from 'react-native-head-tab-view-flashlist-support/hook'; import { mScrollTo, animateToRefresh, snapAfterGlideOver, } from 'react-native-head-tab-view-flashlist-support/utils'; import Animated, { runOnJS, useDerivedValue, useAnimatedScrollHandler, useSharedValue, useAnimatedStyle, useAnimatedReaction, withTiming, cancelAnimation, interpolate, Extrapolate, } from 'react-native-reanimated'; import {Events} from 'react-native-head-tab-view-flashlist-support/enum'; import RNTapGestureHandler from './RNTapGestureHandler'; const __IOS = Platform.OS === 'ios'; const createCollapsibleFlashList = (Component: ScrollableView<any>) => { const AnimatePageView = Animated.createAnimatedComponent(Component); return React.forwardRef((props: any, ref) => { return ( <SceneComponent {...props} forwardedRef={ref} ContainerView={AnimatePageView} /> ); }); }; const SceneComponent: React.FC<NormalSceneProps & HPageViewProps> = ({ index, bounces, scrollEnabled = true, forwardedRef, onScroll, onStartRefresh, onContentSizeChange, onScrollBeginDrag, renderLoadingView, ContainerView, isRefreshing: _isRefreshing = false, renderRefreshControl: _renderRefreshControl, StickyHeaderComponent, ...restProps }) => { if (onScroll !== undefined) { console.warn('Please do not assign onScroll'); } const { shareAnimatedValue = useSharedValue(0), tabbarHeight, headerHeight, expectHeight, tabsRefreshEnabled, curIndexValue, tabsIsWorking, isTouchTabs, isSlidingHeader, refreshHeight, overflowPull, frozeTop, pullExtendedCoefficient, enableSnap, scrollingCheckDuration, refHasChanged, updateSceneInfo, floatingButtonHeight, componentId, } = useSceneContext(); const _scrollView = useSharedScrollableRef<ScrollView>(forwardedRef); const panRef = useRef(); const scrollY = useSharedValue(0); const realY = useSharedValue(0); const trans = useSharedValue(0); const refreshTrans = useSharedValue(refreshHeight); const isTouchTabsPrev = useSharedValue(false); const isSlidingHeaderPrev = useSharedValue(false); const isRefreshing = useSharedValue(false); const isRefreshingWithAnimation = useSharedValue(false); const isDragging: {value: boolean} = useSharedValue(false); const isLosingMomentum: {value: boolean} = useSharedValue(false); const {opacityValue, syncInitialPosition} = useSyncInitialPosition(_scrollView); const needSnap = useSharedValue(false); const isScrolling = useSharedValue(0); const calcHeight = useMemo(() => { return tabbarHeight + headerHeight; }, [tabbarHeight, headerHeight]); const isInitial = useRef(true); const [stickyHeaderHeight, setStickyHeaderHeight] = useState(0); const scrollEnabledValue = useDerivedValue(() => { return ( !isDragging.value && !tabsIsWorking.value && !isRefreshing.value && !isRefreshingWithAnimation.value && curIndexValue.value === index ); }); const canSnapFunc = () => { 'worklet'; return ( needSnap.value && !isTouchTabs.value && !isSlidingHeader.value && !isRefreshing.value && !isRefreshingWithAnimation.value && !tabsIsWorking.value ); }; const refreshValue = useDerivedValue(() => { if (isRefreshing.value && isRefreshingWithAnimation.value) { return refreshHeight - refreshTrans.value; } return trans.value - shareAnimatedValue.value; }); useAnimatedReaction( () => { return scrollEnabledValue.value; }, (mScrollEnabled) => { _scrollView && _scrollView.current && _scrollView.current.setNativeProps({scrollEnabled: mScrollEnabled}); }, [scrollEnabledValue, _scrollView], ); const updateScrollYTrans = useCallback( (value: number) => { 'worklet'; scrollY.value = Math.max(value, 0); }, [scrollY], ); const updateShareValue = useCallback( (value: number) => { 'worklet'; if (curIndexValue.value !== index) return; //Avoid causing updates to the ShareAnimatedValue after the drop-down has finished if (isRefreshing.value !== isRefreshingWithAnimation.value) return; shareAnimatedValue.value = value; }, [ curIndexValue.value, shareAnimatedValue, index, isRefreshing.value, isRefreshingWithAnimation.value, ], ); const tryToSnap = useCallback(() => { 'worklet'; if (!enableSnap) return; cancelAnimation(isScrolling); if (canSnapFunc()) { isScrolling.value = 1; isScrolling.value = withTiming( 0, {duration: scrollingCheckDuration}, (isFinished) => { if (isFinished && canSnapFunc()) { needSnap.value = false; snapAfterGlideOver({ sceneRef: _scrollView, shareAnimatedValue, headerHeight, frozeTop, }); } }, ); } }, [ isScrolling.value, _scrollView, needSnap, shareAnimatedValue, headerHeight, frozeTop, enableSnap, scrollingCheckDuration, ]); const onScrollAnimateEvent = useAnimatedScrollHandler( { onScroll: (event, ctx) => { realY.value = event.contentOffset.y; let moveY = Math.max(event.contentOffset.y, 0); if (isRefreshingWithAnimation.value || isRefreshing.value) return; tryToSnap(); moveY = isRefreshing.value && isRefreshingWithAnimation.value ? moveY + refreshHeight : moveY; updateScrollYTrans(moveY); updateShareValue(moveY); }, onMomentumBegin: () => { isLosingMomentum.value = true; }, onMomentumEnd: () => { isLosingMomentum.value = false; }, }, [ curIndexValue, updateShareValue, updateScrollYTrans, isRefreshingWithAnimation, tryToSnap, ], ); const onRefreshStatusCallback = React.useCallback( (isToRefresh: boolean) => { if (isToRefresh) { animateToRefresh({ transRefreshing: refreshTrans, isRefreshing, isRefreshingWithAnimation, destPoi: shareAnimatedValue.value, isToRefresh: true, onStartRefresh, }); } else { const destPoi = shareAnimatedValue.value > headerHeight + refreshHeight ? shareAnimatedValue.value : shareAnimatedValue.value + refreshHeight; animateToRefresh({ transRefreshing: refreshTrans, isRefreshing, isRefreshingWithAnimation, destPoi, isToRefresh: false, }); } }, [onStartRefresh, refreshHeight, headerHeight], ); useEffect(() => { refHasChanged && refHasChanged(panRef); }, [refHasChanged, panRef]); useEffect(() => { if (_scrollView && _scrollView.current) { updateSceneInfo({ scrollRef: _scrollView, index, refreshTrans, isRefreshing, isRefreshingWithAnimation, canPullRefresh: onStartRefresh !== undefined, scrollY, isDragging, scrollEnabledValue, isLosingMomentum, onRefreshStatusCallback, }); } }, [ _scrollView, index, refreshTrans, isRefreshing, isRefreshingWithAnimation, onStartRefresh, scrollY, isDragging, onRefreshStatusCallback, ]); //adjust the scene size const _onContentSizeChange = useCallback( (contentWidth: number, contentHeight: number) => { onContentSizeChange && onContentSizeChange(contentWidth, contentHeight); //Some mobile phones measure less than their actual height. And the difference in height is not more than a pixel if (Math.ceil(contentHeight) >= expectHeight) { syncInitialPosition( isRefreshing.value ? shareAnimatedValue.value - refreshHeight : shareAnimatedValue.value, ); } }, [ onContentSizeChange, syncInitialPosition, expectHeight, isRefreshing, refreshTrans, shareAnimatedValue, ], ); //Pull-refresh useEffect(() => { if (_isRefreshing) { onRefreshStatusCallback(true); } else { if (isInitial.current) return; onRefreshStatusCallback(false); } isInitial.current = false; }, [_isRefreshing, onRefreshStatusCallback, isInitial]); //Finger off the screen useAnimatedReaction( () => { return isTouchTabs.value !== isTouchTabsPrev.value && enableSnap; }, (result) => { if (!result) return; isTouchTabsPrev.value = isTouchTabs.value; if (isTouchTabs.value === true) return; needSnap.value = true; tryToSnap(); }, [isTouchTabs, isTouchTabsPrev, tryToSnap, enableSnap], ); //Slide header over useAnimatedReaction( () => { return isSlidingHeader.value !== isSlidingHeaderPrev.value && enableSnap; }, (result) => { if (!result) return; isSlidingHeaderPrev.value = isSlidingHeader.value; if (isSlidingHeader.value === true) return; needSnap.value = true; tryToSnap(); }, [isSlidingHeader, isSlidingHeaderPrev, tryToSnap, enableSnap], ); useAnimatedReaction( () => { return refreshTrans.value; }, (mTrans) => { trans.value = Math.max(refreshHeight - mTrans, 0); }, [refreshTrans, refreshHeight], ); useAnimatedReaction( () => { return ( isRefreshing.value === false && isRefreshingWithAnimation.value === true && refreshTrans ); }, (isStart) => { if (!isStart) return; if (realY.value === refreshTrans.value - refreshHeight) return; mScrollTo(_scrollView, 0, refreshTrans.value - refreshHeight, false); }, [isRefreshing, isRefreshingWithAnimation, refreshTrans, refreshHeight], ); useAnimatedReaction( () => { return ( refreshTrans.value <= refreshHeight && (isDragging.value || isRefreshingWithAnimation.value) ); }, (isStart) => { if (!isStart) return; if (realY.value !== 0) { mScrollTo(_scrollView, 0, 0, false); } }, [refreshTrans, refreshHeight, isDragging, isRefreshingWithAnimation], ); useAnimatedReaction( () => { return ( refreshTrans.value >= 0 && isRefreshingWithAnimation.value && isRefreshing.value ); }, (isStart) => { if (!isStart) return; updateScrollYTrans(refreshTrans.value); updateShareValue(refreshTrans.value); }, [ refreshTrans, refreshHeight, isRefreshingWithAnimation, isRefreshing, _scrollView, updateShareValue, updateScrollYTrans, ], ); useAnimatedReaction( () => { return ( refreshTrans.value > refreshHeight && isRefreshing.value && isRefreshingWithAnimation.value ); }, (start) => { if (!start) return; if (realY.value !== refreshTrans.value - refreshHeight) { mScrollTo(_scrollView, 0, refreshTrans.value - refreshHeight, false); } }, [ refreshTrans, refreshHeight, isRefreshing, isRefreshingWithAnimation, _scrollView, ], ); const translateY = useRefreshDerivedValue({ animatedValue: trans, refreshHeight, overflowPull, pullExtendedCoefficient, }); const stickyHeaderAnimatedStyles = useAnimatedStyle(() => { return { transform: [ { translateY: interpolate( realY.value, [0, calcHeight - tabbarHeight], [calcHeight, tabbarHeight], Extrapolate.CLAMP, ), }, ], }; }); const animatedStyle = useAnimatedStyle(() => { return { transform: [ { translateY: translateY.value, }, ], }; }); const renderRefreshControl = () => { if (!onStartRefresh) return; return ( <RefreshControlContainer top={calcHeight} refreshHeight={refreshHeight} overflowPull={overflowPull} refreshValue={refreshValue} opacityValue={opacityValue} isRefreshing={isRefreshing} isRefreshingWithAnimation={isRefreshingWithAnimation} pullExtendedCoefficient={pullExtendedCoefficient} renderContent={_renderRefreshControl} /> ); }; const bouncesEnabled = useMemo(() => { return __IOS && !tabsRefreshEnabled && onStartRefresh === undefined; }, [tabsRefreshEnabled, onStartRefresh]); const sceneStyle = useAnimatedStyle(() => { return { opacity: opacityValue.value, }; }); return ( <Animated.View style={[styles.container, {}]}> {opacityValue.value !== 1 && renderLoadingView ? ( <Animated.View style={StyleSheet.absoluteFill}> {renderLoadingView(headerHeight)} </Animated.View> ) : null} <Animated.View style={[styles.container, sceneStyle]}> <Animated.View style={[ styles.container, animatedStyle, {flex: 1, height: expectHeight}, ]}> <MemoList panRef={panRef} ContainerView={ContainerView} zForwardedRef={_scrollView} onScroll={onScrollAnimateEvent} onContentSizeChange={_onContentSizeChange} bounces={bouncesEnabled} headerHeight={calcHeight} expectHeight={expectHeight} stickyHeaderHeight={stickyHeaderHeight} floatingButtonHeight={floatingButtonHeight} calcHeight={calcHeight} tabbarHeight={tabbarHeight} componentId={componentId} {...restProps} /> <Animated.View onLayout={(event) => { const height = event.nativeEvent.layout.height; if (height) { setStickyHeaderHeight(height); } }} style={[ {position: 'absolute', width: '100%'}, stickyHeaderAnimatedStyles, ]}> {StickyHeaderComponent && <StickyHeaderComponent />} </Animated.View> </Animated.View> {renderRefreshControl()} </Animated.View> </Animated.View> ); }; interface SceneListComponentProps { panRef: any; ContainerView: any; zForwardedRef: any; headerHeight: number; expectHeight: number; renderItemWithTapGestureHandler: boolean; componentId?: string; } const SceneListComponent: React.FC< SceneListComponentProps & ScrollViewProps > = ({ panRef, ContainerView, zForwardedRef, headerHeight, expectHeight, scrollEventThrottle, directionalLockEnabled, contentContainerStyle, scrollIndicatorInsets, stickyHeaderHeight, maintainVisibleContentPosition, floatingButtonHeight, tabbarHeight, calcHeight, renderItemWithTapGestureHandler = true, componentId, ...rest }) => { const { contentContainerStyle: _contentContainerStyle, scrollIndicatorInsets: _scrollIndicatorInsets, } = useVerifyProps({ scrollEventThrottle, directionalLockEnabled, contentContainerStyle, scrollIndicatorInsets, }); const {data} = rest; const [contentHeight, setContentHeight] = useState(0); const [itemHeight, setItemHeight] = useState(0); const isFirstMount = useRef(true); const cacheContentHeight = useRef({headerHeight, tabbarHeight}); const RenderItemComponent = renderItemWithTapGestureHandler ? RNTapGestureHandler : View; const renderItem = (props) => { return ( <RenderItemComponent> <View onLayout={(event) => { if (isFirstMount.current) { const height = event.nativeEvent.layout.height; if (height) { isFirstMount.current = false; setItemHeight(height); } } }}> {rest.renderItem(props)} </View> </RenderItemComponent> ); }; useEffect(() => { cacheContentHeight.current = {headerHeight, tabbarHeight}; }, [headerHeight, tabbarHeight]); useEffect(() => { const scrollToTopEvent = DeviceEventEmitter.addListener( Events.LIST_SCROLL_TO_TOP, (eventParams) => { if (eventParams?.componentId === componentId) { zForwardedRef?.current?.scrollToOffset?.({ animated: true, offset: 0, ...eventParams, }); } }, ); const scrollToTabBarEvent = DeviceEventEmitter.addListener( Events.LIST_SCROLL_DOWN_TO_TAB_BAR, (eventParams) => { if (eventParams?.componentId === componentId) { zForwardedRef?.current?.scrollToOffset?.({ animated: true, offset: cacheContentHeight.current?.headerHeight + eventParams?.extraOffset ?? 0, ...eventParams, }); } }, ); const scrollToOffset = DeviceEventEmitter.addListener( Events.LIST_SCROLL_TO_OFFSET, (eventParams) => { if (eventParams?.componentId === componentId) { zForwardedRef?.current?.scrollToOffset?.({ animated: true, offset: eventParams?.offset, ...eventParams, }); } }, ); return () => { scrollToTopEvent.remove(); scrollToTabBarEvent.remove(); scrollToOffset.remove(); }; }, []); // Todo: Improve after const tempHeight = useMemo(() => { let height = 0; if ( data?.length && itemHeight * data.length < contentHeight - tabbarHeight - stickyHeaderHeight ) { height = contentHeight - tabbarHeight - stickyHeaderHeight - itemHeight * data.length; } return height; }, [contentHeight, itemHeight, data, tabbarHeight, stickyHeaderHeight]); const ListFooterComponent = rest.ListFooterComponent; const ListEmptyComponent = rest.ListEmptyComponent; return ( <NativeViewGestureHandler ref={panRef}> <ContainerView {...rest} ref={zForwardedRef} scrollEventThrottle={16} directionalLockEnabled renderItem={renderItem} contentContainerStyle={_contentContainerStyle} scrollIndicatorInsets={{ top: headerHeight, ..._scrollIndicatorInsets, }} renderScrollComponent={ScrollView} maintainVisibleContentPosition={null} ListHeaderComponent={() => { return ( <> <View style={[ { height: headerHeight + stickyHeaderHeight ?? 0, justifyContent: 'flex-end', }, ]} /> {rest?.ListHeaderComponent && rest?.ListHeaderComponent} </> ); }} ListEmptyComponent={() => { return ( <View style={{ height: contentHeight - tabbarHeight, width: '100%', }}> {ListEmptyComponent && <ListEmptyComponent />} </View> ); }} ListFooterComponent={() => { return ( <> {ListFooterComponent && <ListFooterComponent />} <View style={{ height: data?.length ? tempHeight ? tempHeight : floatingButtonHeight ?? 0 : 0, }} /> </> ); }} onLayout={(event) => { const height = event.nativeEvent.layout.height; if (height) { setContentHeight(height); } }} /> </NativeViewGestureHandler> ); }; const MemoList = memo(SceneListComponent); const styles = StyleSheet.create({ container: { flex: 1, }, }); export default createCollapsibleFlashList;