import React, { useCallback, useEffect, useImperativeHandle, useMemo, useState, } from "react"; import { Dimensions, LayoutChangeEvent, StyleSheet, View } from "react-native"; import { Gesture, GestureDetector, NativeGesture, } from "react-native-gesture-handler"; import Animated, { cancelAnimation, Extrapolation, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withDecay, withTiming, } from "react-native-reanimated"; import type { SceneRendererProps } from "react-native-tab-view"; import { HeaderTabContext } from "./context"; import { useRefreshDerivedValue } from "./hooks/use-refresh-value"; import { useSceneInfo } from "./hooks/use-scene-info"; import RefreshControlContainer from "./refresh-control"; import type { GestureContainerProps, Route } from "./types"; import { animateToRefresh, isIOS, _ScrollTo } from "./utils"; import { SCROLLABLE_STATE } from "./constants"; const { width } = Dimensions.get("window"); export type GestureContainerRef = { setCurrentIndex: (index: number) => void; } | null; export const GestureContainer = React.forwardRef< GestureContainerRef, GestureContainerProps >(function GestureContainer( { refreshHeight = 80, pullExtendedCoefficient = 0.1, overflowPull = 50, overflowHeight = 0, scrollEnabled = true, minHeaderHeight = 0, isRefreshing: isRefreshingProp = false, initialPage, onStartRefresh, initTabbarHeight = 49, initHeaderHeight = 0, renderScrollHeader, overridenShareAnimatedValue, overridenTranslateYValue, renderTabView, renderRefreshControl: renderRefreshControlProp, animationHeaderPosition, animationHeaderHeight, panHeaderMaxOffset, onPullEnough, refreshControlColor, refreshControlTop = 0, emptyBodyComponent, navigationState, renderSceneHeader: renderSceneHeaderProp, enableGestureRunOnJS = false, }, forwardedRef ) { //#region animation value const defaultShareAnimatedValue = useSharedValue(0); const shareAnimatedValue = overridenShareAnimatedValue || defaultShareAnimatedValue; const defaultTranslateYValue = useSharedValue(0); const translateYValue = overridenTranslateYValue || defaultTranslateYValue; const curIndexValue = useSharedValue(initialPage); const isSlidingHeader = useSharedValue(false); const slideIndex = useSharedValue(curIndexValue.value); const headerTrans = useSharedValue(0); const opacityValue = useSharedValue(initHeaderHeight === 0 ? 0 : 1); /* pull-refresh */ const isDragging = useSharedValue(false); const tabsTrans = useSharedValue(0); const tabsRefreshTrans = useSharedValue(refreshHeight); const isRefreshing = useSharedValue(false); const isStartRefreshing = useSharedValue(false); const isRefreshingWithAnimation = useSharedValue(false); const basyY = useSharedValue(0); const startY = useSharedValue(0); const isPullEnough = useSharedValue(false); const disableBounces = useSharedValue(false); const headerTransStartY = useSharedValue(0); const dragIndex = useSharedValue(curIndexValue.value); //#endregion //#region hooks const { childScrollRef, childScrollYTrans, sceneIsReady, updateSceneInfo } = useSceneInfo(curIndexValue); //#endregion //#region state const [tabbarHeight, setTabbarHeight] = useState(initTabbarHeight); const [tabviewHeight, setTabviewHeight] = useState(0); const [headerHeight, setHeaderHeight] = useState( initHeaderHeight - overflowHeight ); const [scrollStickyHeaderHeight, setStickyHeaderHeight] = useState(0); const [childGestures, setChildRefs] = useState([]); //#endregion const calcHeight = useMemo( () => headerHeight - minHeaderHeight, [headerHeight, minHeaderHeight] ); const animatedScrollableState = useDerivedValue(() => { if ( tabsRefreshTrans.value <= refreshHeight && shareAnimatedValue.value <= 0 && (isDragging.value || isRefreshingWithAnimation.value || isStartRefreshing.value) ) { return SCROLLABLE_STATE.LOCKED; } return SCROLLABLE_STATE.UNLOCKED; }); //#region methods const animateTabsToRefresh = useCallback( (isToRefresh: boolean) => { "worklet"; if (isToRefresh) { animateToRefresh({ transRefreshing: tabsRefreshTrans, isRefreshing: isRefreshing, isRefreshingWithAnimation: isRefreshingWithAnimation, destPoi: 0, isToRefresh, onStartRefresh, }); } else { const destPoi = tabsRefreshTrans.value > refreshHeight ? tabsRefreshTrans.value + refreshHeight : refreshHeight; animateToRefresh({ transRefreshing: tabsRefreshTrans, isRefreshing: isRefreshing, isRefreshingWithAnimation: isRefreshingWithAnimation, destPoi, isToRefresh, }); } }, [ tabsRefreshTrans, isRefreshing, isRefreshingWithAnimation, onStartRefresh, refreshHeight, ] ); const stopScrollView = () => { "worklet"; if (!sceneIsReady.value[curIndexValue.value]) return; _ScrollTo( childScrollRef[curIndexValue.value], 0, childScrollYTrans[curIndexValue.value]!.value + 0.1, false ); }; const onTabsStartRefresh = useCallback(() => { "worklet"; animateTabsToRefresh(true); }, [animateTabsToRefresh]); const onTabsEndRefresh = useCallback(() => { "worklet"; animateTabsToRefresh(false); }, [animateTabsToRefresh]); const stopAllAnimation = () => { "worklet"; if (!sceneIsReady.value[curIndexValue.value]) return; cancelAnimation(headerTrans); slideIndex.value = -1; dragIndex.value = -1; const handleSceneSync = (index: number) => { if (!childScrollYTrans[index]) return; const syncPosition = Math.min(shareAnimatedValue.value, calcHeight); if ( childScrollYTrans[index]!.value >= calcHeight && shareAnimatedValue.value >= calcHeight ) return; _ScrollTo(childScrollRef[index], 0, syncPosition, false); }; for (const key in childScrollRef) { if (Object.prototype.hasOwnProperty.call(childScrollRef, key)) { if (parseInt(key, 10) === curIndexValue.value) continue; handleSceneSync(parseInt(key, 10)); } } }; const refHasChanged = useCallback( (ref: NativeGesture) => { if (!ref) return; const findItem = childGestures.find((item) => item === ref); if (findItem) return; setChildRefs((prechildRefs) => { return [...prechildRefs, ref]; }); }, [childGestures] ); const headerOnLayout = useCallback( ({ nativeEvent: { layout } }: LayoutChangeEvent) => { const height = layout.height - overflowHeight; setHeaderHeight(height); if (animationHeaderHeight) { animationHeaderHeight.value = Math.abs(calcHeight - minHeaderHeight); } opacityValue.value = withTiming(1); }, [ animationHeaderHeight, calcHeight, minHeaderHeight, opacityValue, overflowHeight, ] ); //#endregion //#region gesture handler const gestureHandlerHeader = Gesture.Pan() .activeOffsetY([-10, 10]) .shouldCancelWhenOutside(false) .enabled(scrollEnabled !== false) .onBegin(() => { if (isRefreshing.value) return; stopScrollView(); }) .onUpdate((event) => { if (!sceneIsReady.value[curIndexValue.value]) return; if (isSlidingHeader.value === false) { slideIndex.value = curIndexValue.value; headerTransStartY.value = childScrollYTrans[curIndexValue.value]!.value + event.translationY; isSlidingHeader.value = true; } headerTrans.value = Math.max( -event.translationY + headerTransStartY.value, 0 ); }) .onEnd((event) => { if (!sceneIsReady.value[curIndexValue.value]) return; if (isSlidingHeader.value === false) return; headerTransStartY.value = 0; headerTrans.value = withDecay( { velocity: -event.velocityY, clamp: [ 0, panHeaderMaxOffset ?? headerHeight - minHeaderHeight + overflowHeight, ], }, () => { isSlidingHeader.value = false; } ); }) .runOnJS(enableGestureRunOnJS); const gestureHandler = Gesture.Pan() .simultaneousWithExternalGesture(gestureHandlerHeader, ...childGestures) .shouldCancelWhenOutside(false) .enabled(scrollEnabled) .activeOffsetX([-width, width]) .activeOffsetY([-10, 10]) .onBegin(() => { stopAllAnimation(); }) .onStart((e) => { isPullEnough.value = false; if (e.velocityY > 0 && shareAnimatedValue.value <= 0) { isStartRefreshing.value = true; } }) .onUpdate((event) => { if ( !sceneIsReady.value[curIndexValue.value] || !onStartRefresh || childScrollYTrans[curIndexValue.value]?.value === undefined ) return; const onReadyToActive = (isPulling: boolean) => { dragIndex.value = curIndexValue.value; if (isPulling) { return event.translationY; } else { return ( refreshHeight - tabsTrans.value + childScrollYTrans[curIndexValue.value]!.value ); } }; if (isRefreshing.value !== isRefreshingWithAnimation.value) return; if (isRefreshing.value) { isStartRefreshing.value = true; if (isDragging.value === false) { const starty = onReadyToActive(false); startY.value = starty; isDragging.value = true; } tabsRefreshTrans.value = Math.max( -event.translationY + startY.value, 0 ); } else { if (shareAnimatedValue.value > 0 || event.translationY <= 0) return; if (isDragging.value === false) { basyY.value = onReadyToActive(true); isDragging.value = true; return; } if (basyY.value > event.translationY) { isStartRefreshing.value = false; } isStartRefreshing.value = false; tabsRefreshTrans.value = refreshHeight - (event.translationY - basyY.value); if (!isPullEnough.value && tabsRefreshTrans.value < 0 && onPullEnough) { isPullEnough.value = true; runOnJS(onPullEnough)(); } } }) .onEnd((event) => { if (!sceneIsReady.value[curIndexValue.value] || !onStartRefresh) return; if (!onStartRefresh) return; if (isDragging.value === false) return; isDragging.value = false; if (isRefreshing.value !== isRefreshingWithAnimation.value) return; if (isRefreshing.value) { startY.value = 0; tabsRefreshTrans.value = withDecay( { velocity: -event.velocityY, deceleration: 0.998, clamp: [0, Number.MAX_VALUE], }, () => { isDragging.value = false; } ); } else { tabsRefreshTrans.value < 0 ? onTabsStartRefresh() : onTabsEndRefresh(); } }) .runOnJS(enableGestureRunOnJS); //#endregion useEffect(() => { animateTabsToRefresh(isRefreshingProp); }, [isRefreshingProp, animateTabsToRefresh]); // render Refresh component const renderRefreshControl = useCallback(() => { if (!onStartRefresh) return; return ( ); }, [ renderRefreshControlProp, isRefreshing, isRefreshingWithAnimation, onStartRefresh, opacityValue, overflowPull, pullExtendedCoefficient, refreshControlColor, refreshControlTop, refreshHeight, tabsTrans, ]); //#region animation hooks useAnimatedReaction( () => { return tabsRefreshTrans.value; }, (mTrans) => { tabsTrans.value = Math.max(refreshHeight - mTrans, 0); }, [refreshHeight, tabsRefreshTrans] ); useAnimatedReaction( () => { return shareAnimatedValue.value; }, (scrollY) => { // for scrollview bounces effect on iOS if (isIOS && animationHeaderPosition && scrollY < calcHeight) { animationHeaderPosition.value = -scrollY; } }, [calcHeight] ); // slide header useAnimatedReaction( () => { return ( headerTrans && slideIndex.value === curIndexValue.value && isSlidingHeader.value ); }, (start) => { if (!start) return; if (!childScrollRef[curIndexValue.value]) return; if (childScrollYTrans[curIndexValue.value]!.value === headerTrans.value) return; _ScrollTo( childScrollRef[curIndexValue.value], 0, headerTrans.value || 0, false ); }, [ headerTrans, slideIndex, curIndexValue, childScrollRef, childScrollYTrans, isSlidingHeader, ] ); // isRefreshing useAnimatedReaction( () => { return ( tabsRefreshTrans.value > refreshHeight && isRefreshingWithAnimation.value ); }, (isStart) => { if (!isStart) return; if (!childScrollRef[curIndexValue.value]) return; const transY = tabsRefreshTrans.value - refreshHeight; if (childScrollYTrans[curIndexValue.value]!.value === transY) return; _ScrollTo(childScrollRef[curIndexValue.value], 0, transY, false); }, [ tabsRefreshTrans, curIndexValue, isRefreshingWithAnimation, childScrollRef, refreshHeight, ] ); const headerTransValue = useDerivedValue(() => { const headerTransY = interpolate( shareAnimatedValue.value, [0, calcHeight], [0, -calcHeight], Extrapolation.CLAMP ); if ( isIOS && !isDragging.value && !isRefreshing.value && !isRefreshingWithAnimation.value && !disableBounces.value && shareAnimatedValue.value < 0 && animatedScrollableState.value === SCROLLABLE_STATE.UNLOCKED ) { return -shareAnimatedValue.value; } if (animationHeaderPosition && headerTransY < calcHeight) { animationHeaderPosition.value = headerTransY; } return headerTransY; }); const tabbarAnimateStyle = useAnimatedStyle(() => { return { transform: [ { translateY: headerTransValue.value, }, ], }; }); useRefreshDerivedValue(translateYValue, { animatedValue: tabsTrans, refreshHeight, overflowPull, pullExtendedCoefficient, }); const animateStyle = useAnimatedStyle(() => { return { transform: [ { translateY: translateYValue.value, }, ], }; }); const opacityStyle = useAnimatedStyle(() => { return { opacity: opacityValue.value, }; }); const headerStyle = useAnimatedStyle(() => { return { transform: [ { translateY: headerTransValue.value, }, ], }; }); //#endregion const renderTabBarContainer = (children: React.ReactElement) => { return ( {renderScrollHeader && ( {renderScrollHeader()} )} {navigationState?.routes.length === 0 && emptyBodyComponent ? ( {emptyBodyComponent} ) : ( { if (overflowHeight > height) { console.warn( "overflowHeight preferably less than the tabbar height" ); } if (Math.abs(tabbarHeight - height) < 1) return; setTabbarHeight(height); }} > {children} )} ); }; const renderSceneHeader = ( children: React.ReactElement, props: SceneRendererProps & { route: Route } ) => { return ( {children} { setStickyHeaderHeight(height); }} style={[ { top: headerHeight + tabbarHeight, ...styles.tabbarStyle, }, headerStyle, ]} > {renderSceneHeaderProp?.(props.route)} ); }; useImperativeHandle( forwardedRef, () => ({ setCurrentIndex: (index: number) => { curIndexValue.value = index; }, }), [curIndexValue] ); return ( { setTabviewHeight(event.nativeEvent.layout.height); }} > {renderTabView({ renderTabBarContainer: renderTabBarContainer, renderSceneHeader: renderSceneHeader, })} {renderRefreshControl()} ); }); const styles = StyleSheet.create({ container: { flex: 1, overflow: "hidden", }, header: { flex: 1, }, tabbarStyle: { left: 0, position: "absolute", right: 0, zIndex: 10, }, });