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;
};