diff --git a/README.md b/README.md index ed08726e..c394cc4e 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ All props are optional, but if you are not rendering a header, you'd be probably | `disableSnap?` | Disable the snap animation. | `false` | | `renderTabBar?` | Same as [renderTabBar](https://github.com/satya164/react-native-tab-view#rendertabbar) of the original [TabView](https://github.com/satya164/react-native-tab-view#tabview), but with the additional `isGliding` property. | `undefined` | | `snapThreshold?` | Percentage of header height to make the snap effect. A number between 0 and 1. | `0.5` | +| `snapTimeout?` | How long to wait before initiating the snap effect, in milliseconds. | `100` | | `onHeaderHeightChange?` | Callback fired when the `headerHeight` state value inside `CollapsibleTabView` will be updated in the `onLayout` event from the tab/header container.

Useful to call layout animations. Example:

() => {LayoutAnimation.configureNext(preset)};
| `undefined` | | `routeKeyProp?` | The property from the `routes` map to use for the active route key. | `key` | diff --git a/src/CollapsibleTabView.tsx b/src/CollapsibleTabView.tsx index d98dfca2..3bc99016 100644 --- a/src/CollapsibleTabView.tsx +++ b/src/CollapsibleTabView.tsx @@ -82,6 +82,11 @@ export type Props = Partial> & * 0 and 1. Default is 0.5. */ snapThreshold?: number; + /** + * How long to wait before initiating the snap effect, in milliseconds. + * Default is 100 + */ + snapTimeout?: number; /** * The property from the `routes` map to use for the active route key * Default is 'key' @@ -106,19 +111,34 @@ const CollapsibleTabView = ({ renderTabBar: customRenderTabBar, onHeaderHeightChange, snapThreshold = 0.5, + snapTimeout = 100, routeKeyProp = 'key', ...tabViewProps }: React.PropsWithoutRef>): React.ReactElement => { const [headerHeight, setHeaderHeight] = React.useState(initialHeaderHeight); - const scrollY = React.useRef(animatedValue).current; + const scrollY = React.useRef(animatedValue); const listRefArr = React.useRef<{ key: T['key']; value?: ScrollRef }[]>([]); const listOffset = React.useRef<{ [key: string]: number }>({}); const isGliding = React.useRef(false); + /** Used to keep track if the user is actively scrolling */ + const isUserScrolling = React.useRef(false); + + const [canSnap, setCanSnap] = React.useState(false); + + const activateSnapDebounced = useDebouncedCallback( + () => { + if (!isUserScrolling.current) { + setCanSnap(true); + } + }, + snapTimeout, + { trailing: true, leading: false } + ); const [translateY, setTranslateY] = React.useState( headerHeight === 0 ? 0 - : scrollY.interpolate({ + : scrollY.current.interpolate({ inputRange: [0, Math.max(headerHeight, 0)], outputRange: [0, -headerHeight], extrapolateRight: 'clamp', @@ -126,24 +146,30 @@ const CollapsibleTabView = ({ ); React.useEffect(() => { - scrollY.addListener(({ value }) => { + const currY = scrollY.current; + currY.addListener(({ value }) => { const curRoute = routes[index][routeKeyProp as keyof Route] as string; listOffset.current[curRoute] = value; + // ensure we activate the snapping when scrollY stops changing (handled by debouncer) + activateSnapDebounced.callback(); }); return () => { - scrollY.removeAllListeners(); + currY.removeAllListeners(); }; - }, [routes, index, scrollY, routeKeyProp]); + }, [routes, index, routeKeyProp, activateSnapDebounced]); /** * Sync the scroll of unfocused routes to the current focused route, * the default behavior is to snap to 0 or the `headerHeight`, it * can be disabled with `disableSnap` prop. */ - const syncScrollOffset = React.useCallback(() => { + React.useEffect(() => { const curRouteKey = routes[index][routeKeyProp as keyof Route] as string; const offset = listOffset.current[curRouteKey]; + if (canSnap !== false) { + setCanSnap(false); + } const newOffset: number | null = offset >= 0 && offset <= headerHeight ? disableSnap @@ -156,21 +182,25 @@ const CollapsibleTabView = ({ : null; listRefArr.current.forEach((item) => { + const isCurrentRoute = item.key === curRouteKey; + const itemOffset = listOffset.current[item.key]; + if (newOffset !== null) { - if ((disableSnap && item.key !== curRouteKey) || !disableSnap) { - scrollScene({ - ref: item.value, - offset: newOffset, - animated: item.key === curRouteKey, - }); + if ((disableSnap && !isCurrentRoute) || !disableSnap) { + if (newOffset !== itemOffset) { + scrollScene({ + ref: item.value, + offset: newOffset, + animated: isCurrentRoute, + }); + } } - if (item.key !== curRouteKey) { + if (!isCurrentRoute) { listOffset.current[item.key] = newOffset; } } else if ( - item.key !== curRouteKey && - (listOffset.current[item.key] < headerHeight || - !listOffset.current[item.key]) + !isCurrentRoute && + (itemOffset < headerHeight || !itemOffset) ) { scrollScene({ ref: item.value, @@ -179,22 +209,31 @@ const CollapsibleTabView = ({ }); } }); - }, [routes, index, routeKeyProp, headerHeight, disableSnap, snapThreshold]); - - const syncScrollOffsetDebounced = useDebouncedCallback(syncScrollOffset, 16); - + }, [ + routes, + index, + routeKeyProp, + headerHeight, + disableSnap, + snapThreshold, + canSnap, + ]); const onMomentumScrollBegin = () => { isGliding.current = true; - syncScrollOffsetDebounced.cancel(); }; const onMomentumScrollEnd = () => { isGliding.current = false; - syncScrollOffsetDebounced.callback(); + }; + + const onScrollBeginDrag = () => { + isUserScrolling.current = true; }; const onScrollEndDrag = () => { - syncScrollOffsetDebounced.callback(); + isUserScrolling.current = false; + // make sure we snap if the user keeps his finger in the same position for a while then lifts it + activateSnapDebounced.callback(); }; /** @@ -235,7 +274,7 @@ const CollapsibleTabView = ({ setTranslateY( headerHeight === 0 ? 0 - : scrollY.interpolate({ + : scrollY.current.interpolate({ inputRange: [0, Math.max(value, 0)], outputRange: [0, -value], extrapolateRight: 'clamp', @@ -299,11 +338,12 @@ const CollapsibleTabView = ({ ) => void; + onScrollBeginDrag: (event: NativeSyntheticEvent) => void; onScrollEndDrag: (event: NativeSyntheticEvent) => void; onMomentumScrollEnd: (event: NativeSyntheticEvent) => void; };